Python」カテゴリーアーカイブ

[Python3] scikit-learnによる機械学習4: 学習が完了したモデルを永続化する

前提

以下の環境で実装、動作確認済み

要素 バージョン
debian 8.6
python 3.6.2

scikit-learnによる機械学習シリーズ

機械学習も数学もPythonもよくわからなくても機械学習はデキるシリーズ

概要

前回、自動車の情報から価格を予測する学習モデルを構築し、それを評価した。

しかし、予測を行おうとスクリプトを実行するたびに学習モデルを構築しているのは無駄でしかないので、一度作成した学習モデルをシリアライズし、使いまわせるようにする。以降では、シリアライズされた学習モデルをデシリアライズして予測を行うことで、学習作業を省略することができる。

シリアライズ

今回は、scikit-learnのjoblibを用いてシリアライズする。以下のimport文が必要

from sklearn.externals import joblib

学習が完了したモデル(clf)を、cars.plkのファイル名で保存する場合は以下のようにする

joblib.dump(clf, 'cars.pkl')

実行するとcars.plkが生成される。可読性のあるものではないので中身は気にしなくて良い

$ file cars.pkl
cars.pkl: 8086 relocatable (Microsoft)

デシリアライズ

シリアライズされた学習モデルは、load関数でデシリアライズすることができる。以降ではclfを用いれば従来通りpredictメソッドを使って予測を行うことができる

clf = joblib.load('cars.pkl');

ソースコード

自動車の情報と価格をランダムフォレストで学習し、シリアライズするスクリプト

import pandas as pd
from sklearn.ensemble import RandomForestClassifier
from sklearn import preprocessing
from sklearn.externals import joblib

# CSVファイルを読み込む
df = pd.read_csv('cars.csv')

# 学習に不要な行、列を削除
del df['normalized-losses']
for col in ['num-of-doors', 'price', 'bore', 'stroke', 'horsepower', 'peak-rpm']:
 df = df[df[col] != '?']

# 説明変数列と目的変数列に分割
label = df['price']
data  = df.drop('price', axis=1)

# 文字列データを数値化
le = preprocessing.LabelEncoder()
for col in ['make', 'fuel-type', 'aspiration', 'num-of-doors', 'body-style', 'drive-wheels', 'engine-location', 'engine-type', 'num-of-cylinders', 'fuel-system']:
  data[col] = le.fit_transform(data[col])

# 学習する
clf = RandomForestClassifier()
clf.fit(data, label)

# 学習結果をシリアライズ
joblib.dump(clf, 'cars.pkl')

シリアライズされた学習モデルをデシリアライズし、予測、評価するスクリプト

import pandas as pd
from sklearn.ensemble import RandomForestClassifier
from sklearn import preprocessing
from sklearn.externals import joblib

# CSVファイルを読み込む
df = pd.read_csv('cars.csv')

# 学習に不要な行、列を削除
del df['normalized-losses']
for col in ['num-of-doors', 'price', 'bore', 'stroke', 'horsepower', 'peak-rpm']:
 df = df[df[col] != '?']

# 説明変数列と目的変数列に分割
label = df['price']
data  = df.drop('price', axis=1)

# 文字列データを数値化
le = preprocessing.LabelEncoder()
for col in ['make', 'fuel-type', 'aspiration', 'num-of-doors', 'body-style', 'drive-wheels', 'engine-location', 'engine-type', 'num-of-cylinders', 'fuel-system']:
  data[col] = le.fit_transform(data[col])

# 学習モデルをデシリアライズ
clf = joblib.load('cars.pkl');

# 評価する
predict = clf.predict(data)
rate_sum = 0
for i in range(len(label)):
  t = int(label.iloc[i])
  p = int(predict[i])
  rate_sum += int(min(t, p) / max(t, p) * 100)
print(rate_sum / len(label))

[Python3] scikit-learnによる機械学習3: 自動車の情報から価格を予測する

前提

以下の環境で実装、動作確認済み

要素 バージョン
debian 8.6
python 3.6.2

scikit-learnによる機械学習シリーズ

機械学習も数学もPythonもよくわからなくても機械学習はデキるシリーズ

概要

scikit-learnによる機械学習がなんたるかがほんの少しわかってきたので、足し算などという実用性皆無な例でなく、もう少し実用的な使い方をしてみる。

今回は、自動車の各種情報と、その自動車の価格を学習させ、新たに情報を与えると価格を予測してくれるスクリプトを作成する。なお、学習データにはMicrosoftのクラウド機械学習サービスである、Azure Maschine Learningより、そのチュートリアルで使用されているサンプルデータを拝借する。

学習用データについて

今回使用する学習データは以下のようなCSVファイル。

画像をだとわかりづらいが、以下の列で構成されている。列の内容は列名から概ね察せるが、最終列にあるprice列の値(自動車の価格)を、他の列の情報を元に予測するということがわかれば問題ない。

  • symboling
  • normalized-losses
  • make
  • fuel-type
  • aspiration
  • num-of-doors
  • body-style
  • drive-wheels
  • engine-location
  • wheel-base
  • length
  • width
  • height
  • curb-weight
  • engine-type
  • num-of-cylinders
  • engine-size
  • fuel-system
  • bore
  • stroke
  • compression-ratio
  • horsepower
  • peak-rpm
  • city-mpg
  • highway-mpg
  • price

学習用データはいつもどおりPandasを用いてDataFrameに展開する

df = pd.read_csv('cars.csv')

無効な行/列の削除

CSVのスクリーンショットを見てもらうとわかるが、全体的に値が”?”になっているセルが散らばっている。これは情報が無いという意味を表していると思うので、それらを取り除いていく。

まず、’normalized-losses'(2列目)は、列全体で”?”が多いので、思い切ってこの列自体を削除する。列についではdel文で削除できるので削除してしまう。

del df['normalized-losses']

次に、他の”?”についてだが、それらについては一部の行のみ”?”になっているだけなので、列全体の削除はせず、”?”が一つでも含まれている行を削除するという方針にする。

“?”が存在する列は’num-of-doors’, ‘price’, ‘bore’, ‘stroke’, ‘horsepower’, ‘peak-rpm’の6列なので、これらのいずれの列にも”?”が含まれていない行のみ取り出して再代入するという処理を行う。PandasのDataFrameでは、行の抽出に、列を対象とした条件式を指定することができるので、”?”が含まれている行を排除できる。

for col in ['num-of-doors', 'price', 'bore', 'stroke', 'horsepower', 'peak-rpm']:
 df = df[df[col] != '?']

おそらくPandasを使ってもっとスマートに同じことができると思うので、良い方法があったらご教授お願いします。

文字列を数値化する

今回も前回までと同様、scikit-learnの学習モデルのfitメソッドを用いて学習を行うが、fitメソッドはfloat型の値しか受け付けない。しかし、今回のCSVには文字列も多く含んでいるので、それを数値化する必要がある。

そこで今回は、scikit-learnのLabelEncoderクラスを用いて各文字列を数値に置き換えることで、学習可能のデータに変換する。変換が必要な列は、’make’, ‘fuel-type’, ‘aspiration’, ‘num-of-doors’, ‘body-style’, ‘drive-wheels’, ‘engine-location’, ‘engine-type’, ‘num-of-cylinders’, ‘fuel-system’の10列なので、それぞれに対して、以下のようにLabelEncoderオブジェクトのfit_transformメソッドを適用することで文字列を数値に変換できる。

le = preprocessing.LabelEncoder()
for col in ['make', 'fuel-type', 'aspiration', 'num-of-doors', 'body-style', 'drive-wheels', 'engine-location', 'engine-type', 'num-of-cylinders', 'fuel-system']:
  data[col] = le.fit_transform(data[col])

例として、body-style列の、変換前後を出力すると以下のようになり、文字列が数値に変換されていることがわかる

変換前

0      convertible
1      convertible
2        hatchback
3            sedan
4            sedan
5            sedan
6            sedan
7            wagon
8            sedan
(以下略)

変換後

0      0
1      0
2      2
3      3
4      3
5      3
6      3
7      4
8      3
(以下略)

わざわざ変換する列名を配列でツラツラ書かなくてももっとスマートに書ける方法がありそうです。こちらも良い方法ありましたらご教授お願いします。

学習用データとテストデータの分割

ここは前回一緒なのでコードだけ載せて割愛

train_data, test_data, train_label, test_label = train_test_split(data, label)

ランダムフォレストで学習させる

前回までは線形回帰アルゴリズムを用いて足し算の学習を行わせたが、今回は別の学習アルゴリズムを使うことにする。どのアルゴリズムをどのような場合に使えば良いかはまだ調査中だが、とりあえず線形回帰とは異なるアルゴリズムを使う場合の例として使ってみる。

ランダムフォレスト(英: random forest, randomized trees)は、2001年に Leo Breiman によって提案された[1]機械学習のアルゴリズムであり、分類、回帰、クラスタリングに用いられる。決定木を弱学習器とする集団学習アルゴリズムであり、この名称は、ランダムサンプリングされたトレーニングデータによって学習した多数の決定木を使用することによる。対象によっては、同じく集団学習を用いるブースティングよりも有効とされる。(Wikipedia引用)

といっても、scikit-learnではどの学習アルゴリズムを使おうと、その使い方が共通化されているので、線形回帰の時とほとんど同じコードで動かすことができる。
必要なモジュールをインポートした状態で以下のようにすることランダムフォレストで学習を実行できる。

clf = RandomForestClassifier()
clf.fit(train_data, train_label)

前回までとの違いは、clf変数にどの学習アルゴリズムのオブジェクトを代入するかの違いだけである。どの学習アルゴリズムについてもfitメソッドを使うことができるので、同じように利用することができる。

評価する

学習用データとテスト用データに分割した際の、テスト用データの方を用いて学習モデルの評価を行う。これまで通り、predictメソッドを用いて各自動車の価格を予想する。

predict = clf.predict(test_data)

とりあえず予測した結果と正解を並べて出力してみる。

for i in range(len(test_label)):
  p = int(predict[i])
  t = int(test_label.iloc[i])
  print(p, t)

どうだろうか、かなり近い価格を出せているのではないだろうか。(予測値 正解値)

7898 6918
5572 5572
12764 12964
10198 11694
7299 8249
8949 9549
17450 15250
7198 8358
23875 17710
10245 8495
8238 8058
6338 6488
31600 28248
11199 8449
10295 6785
34028 32528
15985 12940
7299 6849
(以下略)

ただこれだと、どのぐらいの精度が出てるかわかりづらいので、正解に何%近いかを計算して出力するように修正する

for i in range(len(test_label)):
  p = int(predict[i])
  t = int(test_label.iloc[i])
  rate = int(min(t, p) / max(t, p) * 100)
  print(rate)

それなりの精度であることがわかる

$ python cars.py
70
93
79
87
75
97
91
84
90
97
88
89
95
90
100

さらに、個々の精度の平均値を計算し、全体の精度を出力するようにする

for i in range(len(test_label)):
  t = int(test_label.iloc[i])
  p = int(predict[i])
  rate_sum += int(min(t, p) / max(t, p) * 100)
print(rate_sum / len(test_label))

学習データとテストデータの組み合わせによってムラがあるので、何度か実行して確認してみると、概ね87%前後の精度が得られた

$ python cars.py
86.6734693877551
$ python cars.py
87.46938775510205
$ python cars.py
88.59183673469387
$ python cars.py
86.55102040816327
$ python cars.py
86.04081632653062

さらに、アルゴリズムをランダムフォレストから、線形回帰に変更して再評価してみる。変数clfに代入する学習モデルのオブジェクトを、ランダムフォレストから線形回帰に変更するだけで良い

clf = linear_model.LinearRegression()

同じように何度か実行してみると、83%程度と、ランダムフォレストより微妙に精度が下がり、アルゴリズムによって結果が変わることを実感した。

$ python cars.py
82.40816326530613
$ python cars.py
83.79591836734694
$ python cars.py
83.53061224489795
$ python cars.py
85.65306122448979
$ python cars.py
82.46938775510205

なお、学習モデルの評価方法についても、scikit-learnに様々な機能があるが、今回は意図的に自前で計算するようにして感覚を掴んだ。

ソースコード

Github

#
# 車の製品情報と価格を学習し、価格を予測させる
#
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn import preprocessing

# CSVファイルを読み込む
df = pd.read_csv('cars.csv')

# 学習に不要な行、列を削除
del df['normalized-losses']
for col in ['num-of-doors', 'price', 'bore', 'stroke', 'horsepower', 'peak-rpm']:
 df = df[df[col] != '?']

# 説明変数列と目的変数列に分割
label = df['price']
data  = df.drop('price', axis=1)

# 文字列データを数値化
le = preprocessing.LabelEncoder()
for col in ['make', 'fuel-type', 'aspiration', 'num-of-doors', 'body-style', 'drive-wheels', 'engine-location', 'engine-type', 'num-of-cylinders', 'fuel-system']:
  data[col] = le.fit_transform(data[col])

# 学習用データとテストデータにわける
train_data, test_data, train_label, test_label = train_test_split(data, label)

# 学習する
clf = RandomForestClassifier()
clf.fit(train_data, train_label)

# 評価する
predict = clf.predict(test_data)
rate_sum = 0

for i in range(len(test_label)):
  t = int(test_label.iloc[i])
  p = int(predict[i])
  rate_sum += int(min(t, p) / max(t, p) * 100)
print(rate_sum / len(test_label))

[Python3] scikit-learnによる機械学習2: 足し算学習モデルを評価する

前提

以下の環境で実装、動作確認済み

要素 バージョン
debian 8.6
python 3.6.2

scikit-learnによる機械学習シリーズ

機械学習も数学もPythonもよくわからなくても機械学習はデキるシリーズ

概要

前回実装した、足し算を線形回帰学習するモデルがどのような学習をしたかを評価する

線形回帰学習で算出した式を確認する

線形回帰モデルは、目的変数Yを求めるために、説明変数X1,X2,X3…それぞれに偏回帰係数b1,b2,b3…を掛け、最後に切片(誤差)eを付与する。式にすると以下のようになる。

Y = X1*b1 + X2*b2 + X3*b3 … + e

今回の単純な足し算モデルは、結果Yに対して、入力値が2種類なのでX1とX2が存在する。
なので理論上は以下の式が求められれば正確といえる

Y = X1*1 + X2*1 + 0

では各b及びeがどのような値になったか確認する。

学習が完了したモデルのcoef_,intercept_にそれぞれ偏回帰係数と切片が格納されているのでそれを確認する。

print("偏回帰係数1: ", float(model.coef_[0][0]))
print("偏回帰係数2: ", float(model.coef_[0][0]))
print("切片: ", float(model.intercept_))

すると以下のような結果が得られたので

偏回帰係数1:  1.0000000000000002
偏回帰係数2:  1.0000000000000002
切片:  -4.440892098500626e-16

この学習モデルが予測した線形回帰は以下の式で表される

Y = X1*1.0000000000000002 + X2*1.0000000000000002 + -4.440892098500626e-16

小数計算ならともかく、今回は結果をint関数で整数化しているので、このレベルの誤差なら数値が極大出ない限りは誤った答えが出ないことが予想できる。

精度を確認する

前回は標準入力から式を入力して、解答を標準出力していたが、膨大なデータをテストする場合に一々手入力していたらキリがないので、今回は自動で100000件のテストデータを生成して、それぞれについて正しい答えを求められているかを評価する。

まずは以下のような、1~1000のランダムな数値を二種及びその和のセットを戻す関数を実装した

def get_rand_data():
    max = 1000
    a = random.randint(1, max)
    b = random.randint(1, max)
    return [a, b, a + b]

これを、以下のように内包記法を用いて呼び出すことで10000セット、PandasのDataFrame形式で取得する

rand_data = DataFrame([get_rand_data() for i in range(100000)])

ランダムデータの1,2列目が入力になるので、1,2列目を取り出して学習させる

pre = model.predict(rand_data[[0, 1]])

変数preには、1万件分のデータセットに対する和の予測値が格納されているので、それらを順に確認して、本来の解と一致しているかを比較検証する。一致件数と総数から、正解率を算出する。このあたりを勝手に評価してくれるモジュールがあったと思うが、今回は初見なので自前で実装する。

correct_num = 0
for i in range(len(rand_data)):
  if rand_data[2][i] == int(pre[i]):
    correct_num += 1

print("正解率 = ", correct_num / len(rand_data))

すると、以下のように正解率1.0と出力されるので、最低でも1~1000の整数数二種の和は正確に求められていることがわかる。

正解率 =  1.0

備考

  • もちろん計算の桁数が大きくなればなるほど誤差が影響して正解率は落ちる
  • そもそも明確な解のある足し算を機械学習させるのはお門違いだが今回は簡易的に理解できる例として使用した

[Python3] scikit-learnによる機械学習1: 足し算を学習させる

前提

以下の環境で実装、動作確認済み

要素 バージョン
debian 8.6
python 3.6.2

scikit-learnによる機械学習シリーズ

機械学習も数学もPythonもよくわからなくても機械学習はデキるシリーズ

機械学習とは

機械学習(きかいがくしゅう、英: machine learning)とは、人工知能における研究課題の一つで、人間が自然に行っている学習能力と同様の機能をコンピュータで実現しようとする技術・手法のことである。(wikipedia引用)

機械学習とは、言語やゲームなどをはじめとした人間の様々な知的活動の中で、人間が自然と行っているパターン認識や経験則を導き出したりするような活動を、コンピュータを使って実現するための技術や理論、またはソフトウェアなどの総称である。(IT用語時点引用)

まぁ人間っぽい学習をコンピュータにもやらせましょうというお話。
本シリーズでは機械学習の定義とかにあまり拘らずに、一般的に考えられる機械学習を自分でもやってみようという緩い方向で進める。

scikit-learnとは

Pythonにおける事実上標準と言っても過言ではない、機械学習のためのライブラリ。
機械学習と言えばTensorFlowというワードの知名度が高いが、TensorFlowは学習コストも高く、ディープラーニングまで幅広くできるので、本シリーズではお手軽に機械学習ができるsckit-learnを用いる

scikit-learnのインストール

sckit-learnはPythonモジュールとして公開されているので、pipなどを用いてインストールする。pipはPythonをインストールすると標準で入ってる可能性が高い。入っていない場合は別途導入する。あるいはAnacondaなどの別な手段を適用する。

$ pip install scikit-learn

Pandas/Numpyについて

Pythonで機械学習を行う上で切っても切れないライブラリに、以下の2つがある

Pandas
データ解析を支援するためのライブラリ。汎用的なデータ構造の構築や、データ内の演算などをサポートする。

Numpy
科学計算用のライブラリ。特に行列に対する計算は普通にPythonで行うより圧倒的に早い。

以上より、これらのライブラリも初めに導入しておく。

$ pip install numpy pandas

足し算の機械学習について

本記事では、機械学習の第一歩として、sckit-learnの線形回帰アルゴリズムを用いて、足し算を学習させる。

流れとしては以下のようになる

  • 足し算の式と、その答えの組み合わせをいくつか用意する
  • sckit-learnに式と答えの組み合わせを学習させ、学習モデルを生成
  • 標準入力から二種類の整数を学習モデルに与え、予測結果を標準出力する

なお、2数の足し算とは当然演算によって求められるものなので、機械学習の利用法としては適していない。
が、ここでは雰囲気を掴むためにこのテーマにした。

教師あり学習について

教師あり学習は、データの組み合わせと、それに対する答えのセットを複数セット学習させることで、データの組み合わせを与えただけでその答えを予測してもらおうという学習モデル。

今回は足し算を学習させるので、データの組み合わせが式で、答えが和と考えれば良い。

線形回帰について

Wikipediaをそのまま掲載すると以下の通り。なるほど。

何を言ってるかよくわからないが、足し算に置き換えれば非常にシンプルになる。

目的変数Yは、足し算における和である。
説明変数X1,X2は、足し算のそれぞれの数値である。

この場合、線形回帰直線は、Y = X1 + X2の形になるので、
この式を予測することが線形回帰学習である(あってるか極めて怪しい)

実装

モジュールのimport

今回は、Pandasによる学習用データの入力と、scikit-learnの線形回帰モデルを用いるので、以下のimport文を記述する。Numpyは今回出番なし。

from pandas import DataFrame
from sklearn import linear_model

学習データの用意

学習データは、PandasのDataFrameの形式で用意する。DataFrameはデータの集合を二次元で表現する。
sckit-learnでは、学習データとその答えを別々に渡す必要があるので、それぞれformulas,answersに代入する。

formulas = DataFrame([
    [0, 0],
    [0, 1],
    [0, 2],
    [1, 0],
    [1, 1],
    [1, 2],
    [2, 0],
    [2, 1],
    [2, 2]
])
answers = DataFrame([0, 1, 2, 1, 2, 3, 2, 3, 4])

コードを見ての通り、それぞれ、0 + 0 = 0, 0 + 1 = 1, 0 + 2 = 2 … のセットを用意している。
ちなみにこの段階でformulasを標準出力すると以下のようになり、DataFrameがどのようにデータ集合を持っているかのイメージが湧く

   0  1
0  0  0
1  0  1
2  0  2
3  1  0
4  1  1
5  1  2
6  2  0
7  2  1
8  2  2

学習データを学習させる

importしたlinear_modelを用いて、先程用意した学習データを予測する。
LinearRegressionメソッドで学習モデルを取得できるので、それに対してfitメソッドで学習データを割り当て、学習を開始する。

model = linear_model.LinearRegression()
model.fit(formulas, answers)

学習結果を確認する

学習が完了したmodelに対して、predictメソッドを用いることで、新たなデータに対する答えの予測値を取得することができる。
この場合、必ず入力、出力ともに配列である必要があるので注意。

例えば以下では、10 + 20の予測結果を取得する。

predected_answer = model.predict([[10, 20]])

これを用いて、標準入力から得られた2数の和の予測結果を出力し、それを繰り返すコードを以下に示す

while True:

    print('> ', end='')
    x, y = list(map(lambda x: int(x), input().split(' ')))

    predected_answer = model.predict([[x, y]])
    print("{0} + {1} = {2}".format(x, y, int(predected_answer[0][0])))

標準入力と標準出力で小難しいことをやってるように見えるが、とりあえず入力した2数の和を予測して出力していることはわかる。

動作確認

これまでのコードを以下のように整理して実行する
コード全文 (Github)

 #
 # 2つの整数の足し算を機械学習し、任意の足し算を回答させる
 #
 from pandas import DataFrame
 from sklearn import linear_model

 # 足し算の例とその答えを用意する
 formulas = DataFrame([
     [0, 0],
     [0, 1],
     [0, 2],
     [1, 0],
     [1, 1],
     [1, 2],
     [2, 0],
     [2, 1],
     [2, 2]
 ])
 answers = DataFrame([0, 1, 2, 1, 2, 3, 2, 3, 4])

 # 式と答えを線形回帰学習させる
 model = linear_model.LinearRegression()
 model.fit(formulas, answers)
 print('学習完了')

 while True:

     # 標準入力から計算式を取得
     print('> ', end='')
     x, y = list(map(lambda x: int(x), input().split(' ')))

     # 学習モデルを用いて回答を取得し、標準出力
     predected_answer = model.predict([[x, y]])
     print("{0} + {1} = {2}".format(x, y, int(predected_answer[0][0])))

実行後、2秒程度で学習が完了する。コレに対して標準入力で適当に2数を入力すると、その予測結果が出力される。

$ python addition.py
学習完了
> 1 1
1 + 1 = 2
> 10 20
10 + 20 = 30
> 777 333
777 + 333 = 1110

良い感じに計算できているように見える。

本モデルの評価について次回行う。

[Python3] GithubAPI/TwitterAPIを用いて、最新のコミットログをツイートする

前提

以下の環境で実装、動作確認

要素 バージョン
debian 8.6
python 3.6.2

概要

GithubAPIを用いて、自身がGithub上のリモートリポジトリに対してpushした内容からコミットログを取得し、それに関する情報をTwitterAPIを用いてツイートしたお話。

本記事ではTwitterAPIを利用するためのコンシューマキー、アクセストークンは取得済みであることを前提としている。

同じことはGithubのWebhookを使ってできるしむしろ自然だが、その場合サーバーを用意する必要があるので、今回はあえてpull型で非効率なやり方を採用

動作イメージ

リモートリポジトリがGithub上にあるリポジトリに対して適当にコミット/プッシュ

$ git commit -m "コミットテスト"
[master e6bb9e4] コミットテスト
 1 file changed, 1 insertion(+)
$ git push origin master
Counting objects: 3, done.
Delta compression using up to 2 threads.
Compressing objects: 100% (3/3), done.

今回実装したコマンドを実行する

$ python main.py

以下のように、リポジトリ名、コミットメッセージ、diffへのリンクがツイートされる

GithubAPIについて

GithubAPIは、名前の通りGithubの各種読み書きを行うためのAPI。
公開されているデータの取得に関しては認証無しで手軽に利用できる。

例えば、curlコマンドを用いて以下のURLに対してGETリクエストを送信すると

curl -i https://api.github.com/users/sa2knight/events

以下のように、該当ユーザのGithubでのイベントログを取得することができる

[
  {
    "id": "6516862394",
    "type": "PushEvent",
    "actor": {
      "id": 16274215,
      "login": "Sa2Knight",
      "display_login": "Sa2Knight",
      "gravatar_id": "",
      "url": "https://api.github.com/users/Sa2Knight",
      "avatar_url": "https://avatars.githubusercontent.com/u/16274215?"
    },
    "repo": {
      "id": 96615971,
      "name": "Sa2Knight/degulog2",
      "url": "https://api.github.com/repos/Sa2Knight/degulog2"
    },
    "payload": {
      "push_id": 1946817475,
      "size": 1,
      "distinct_size": 1,
      "ref": "refs/heads/master",
      "head": "4208370eb0cda0a1bab02fd3fbe5bf7e3e6f5f29",
      "before": "afd8b8647c2b810ceb731a74bad20c7388c19974",
      "commits": [
        {
          "sha": "4208370eb0cda0a1bab02fd3fbe5bf7e3e6f5f29",
          "author": {
            "email": "shingo.sasaki.0529@gmail.com",
            "name": "shingo sasaki"
          },
          "message": "バックアップ追加",
          "distinct": true,
          "url": "https://api.github.com/repos/Sa2Knight/degulog2/commits/4208370eb0cda0a1bab02fd3fbe5bf7e3e6f5f29"
        }
      ]
    },
    "public": true,
    "created_at": "2017-08-27T15:22:08Z"
  },
  {
    "id": "6516846556",
    "type": "PushEvent",
(以下省略)

ここで取得できるイベントは、Webで言う以下のような、そのユーザのリポジトリに対する操作全般の情報なので、今回はこのAPIを用いることにする

ライブラリの導入

今回はPython3を用いて実装するので、Pythonのパッケージ管理ツールであるpipを用いて、以下の二種類のライブラリを導入する。

  • requests
    — HTTPライブラリ
  • twitter
    — TwitterAPI用

TwitterAPIの利用には認証も絡んでくるので、専用のライブラリを利用する。GithubAPIは特定URLにGETするだけなのでHTTPライブラリで直接実行することに。

$ pip install requests
$ pip install twitter

ソースコード

最新版はこちら

import twitter
import requests
import json
import os

FILE_NAME      = "last_id"
EVENTS_URL     = "https://api.github.com/users/sa2knight/events"
REPOSITORY_URL = "https://api.github.com/repos"

def tweet(text):
  auth = twitter.OAuth(consumer_key=os.environ['TWITTER_CONSUMER_KEY'],
                       consumer_secret=os.environ['TWITTER_CONSUMER_SECRET'],
                       token=os.environ['TWITTER_ACCESS_TOKEN'],
                       token_secret=os.environ['TWITTER_ACCESS_SECRET'])
  t = twitter.Twitter(auth=auth)
  t.statuses.update(status=text)

def tweet_event(event):
  tweet_text = f"""
  @null
  Githubにコミットをプッシュしました。
  [{event['repository']}]
  「{event['commits'][0]['message']}」
  """.strip()
  if 1 < len(event['commits']):
    tweet_text += f"ほか{len(event['commits']) - 1}件"
  tweet_text += f"\n\n{event['commits'][0]['url']}"
  tweet(tweet_text)

def save_id(id):
  with open(FILE_NAME, 'w') as f:
    f.write(id)

def load_id():
  if os.path.exists(FILE_NAME):
    with open(FILE_NAME, 'r') as f:
      return f.readline()
  else:
    return ''

def is_new_id(id):
  return id != load_id()

def parse_commit_log(repo_name, commit):
  return {
    'url': f"https://github.com/{repo_name}/commit/{commit['sha']}",
    'message': commit['message']
  }

def get_repository_description(repo_name):
  url = f"{REPOSITORY_URL}/{repo_name}"
  response = requests.get(url)
  repository = json.loads(response.text)
  return repository['description']

def get_recent_push_event():
  response = requests.get(EVENTS_URL)
  events   = json.loads(response.text)
  recent_event = list(filter(lambda e: e['type'] == 'PushEvent', events))[0]
  repo_name    = recent_event['repo']['name']
  commits      = list(map(lambda c: parse_commit_log(repo_name, c), recent_event['payload']['commits']))
  return {
    'id':         recent_event['id'],
    'repository': get_repository_description(recent_event['repo']['name']),
    'commits':    commits,
  }

event = get_recent_push_event()
if is_new_id(event['id']):
  tweet_event(event)
  save_id(event['id'])
  print('ツイートを投稿しました')
else:
  print('ツイートは不要です')

スクリプトを実行すると概ね以下の手順でツイートされる
1. GithubAPIを叩いて、自身の最新のコミットを含んだイベントログを取得
2. 直近で取得したイベントIDをファイルに保存しておき、そのIDと比較、一致していた場合変更なしとして終了
3. イベントログを元にツイートするテキストを生成
4. TwitterAPIを用いてツイート

所感

  • 本スクリプトを15分おきぐらいにcronで実行すれば、概ねコミット内容がツイートされる
  • Webhookと違ってpull型になるので、リアルタイムにはできない
  • 単にGithubAPIが使いたくて遊んだだけで実用性はあんまり無い

Python3 + Selenium + Firefox でチャットワークの表示名を自動で切り替える

前提

以下の環境で実装、動作確認済み

要素 バージョン
debian 8.6
python 3.6.2

概要

非常にニッチな目的に感じる題だが、以下の流れでこうなった

  • 諸事情でチャットワークの表示名を自動で変更する手段を作りたい
  • チャットワークAPIを使えばいけそう
  • ユーザ情報を変更するAPIが存在しなかった(見落としているだけであったら教えてください)
  • 仕方ないのでスクレイピングで強引に変更しよう
  • チャットワークにログインする必要もあるのでSeleniumを使うのが良いのかな
  • せっかくだから最近勉強してるPython3を使おう

以上より、Seleniumを用いてFirefoxを自動操作し、チャットワークにログイン、表示名を任意のに変更するスクリプトを実装する。

なお、今回はGUIを持たない環境で実装するので、Xvfbを用いた仮想画面を使って動かす(この辺理解が怪しい)

下準備

まずFirefoxを入れる。画面無くて良いならPhantomJSとかヘッドレスブラウザ使えばいいじゃんと思い最初はそうしたのだが、どうしてもチャットワークが正しく表示できなかったので安定を取ってFirefoxにした。その分容量が大きいけど仕方ない。

$ apt-get install firefox

Firefoxのレンダリングエンジンであるgeckoをseleniumから利用するためのドライバーが別途必要。
こちらからダウンロードできるので、OSの種類に応じてダウンロードする。
今回は64ビット版のLinuxなので以下のように。パスが通ってるところで展開したほうが後々楽。

$ cd /usr/local/bin
$ wget https://github.com/mozilla/geckodriver/releases/download/v0.18.0/geckodriver-v0.18.0-linux64.tar.gz
$ tar -zxvf geckodriver-v0.18.0-linux64.tar.gz
$ rm geckodriver-v0.18.0-linux64.tar.gz

仮想モニタが必要になるのでインストール

$ apt-get install xvfb

必要なPythonモジュールをpipでインストール

$ pip install selenium
$ pip install PyVirtualDisplay

ログイン情報を定義

チャットワークにログインするためのメールアドレスをパスワードが必要になるが、ソースコード中にハードコーディングしてしまうとコードの共有ができなくなってしまうので、今回はJSONファイルに別途書き出すようにする。

以下のようなsecret.jsonを作成した。

{
  "email":    "hogehoge@fugafuga.com",
  "password": "qawsedrftgyhujikol"
}

実装

今回は一つのファイルで全て完結させるので、前項で作成したsecret.jsonと同じフォルダに、main.pyを作成する。

モジュールインポート

今回使うモジュールは以下の通りなのでimportする。

import time
import json
import sys
from selenium import webdriver
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions
from selenium.webdriver.common.by import By
from pyvirtualdisplay import Display

ログイン情報を取得する

secret.jsonを開いて、jsonモジュールでデシリアライズ。メールアドレスとパスワードを抜き出す

with open('secret.json', 'r') as file:
  secret   = json.load(file)
  email    = secret['email']
  password = secret['password']

Seleniumの初期設定

仮想モニタを立ち上げ、SeleniumDriverをFirefoxで初期化。画面サイズがやたらと大きいのはデバッグ時のスクリーンショットを見やすくするためなので最終的に意味はない。

display = Display(visible=0, size=(1920, 1080))
display.start()
driver = webdriver.Firefox()

チャットワークへのアクセス

SeleniumDriverのgetメソッドで簡単にWebサイトにアクセスできる。

driver.get('https://www.chatwork.com/login.php?lang=ja&args=')

メールアドレス/パスワードを入力

find_element_by_css_selectorメソッドで、CSSセレクターを用いてDOMを取得し、それに対してsend_keysメソッドを用いてキー入力を行うことができる。JSONファイルから取得しておいたメールアドレス/パスワードを入力する。今後他にも出てくるけど、SeleniumDriverって何かとメソッド名が冗長だなと思った。

driver.find_element_by_css_selector('form[name="login"] input[name="email"]').send_keys(email)
driver.find_element_by_css_selector('form[name="login"] input[name="password"]').send_keys(password)

ログインする

同じくfind_element_by_css_selectorメソッドを用いてログインボタンを取得し、clickメソッドでクリックすることでログインする。

driver.find_element_by_css_selector('form[name="login"] input[name="login"]').click()

ログイン処理には時間がかかる上、チャットワークはJavaScriptで画面を構築していくので、構築の完了まで待機する必要がある。
Seleniumでは、以下のようにWebDriverWaitを用いることで、「特定の何かが確認できるまで待機する」という記述を行うことができる。
以下では、IDが_myStatusNameのDOMが確認できるまで待機するという処理(10秒でタイムアウト)

wait = WebDriverWait(driver, 10)
wait.until(expected_conditions.visibility_of_element_located((By.ID, "_myStatusName")))

_myStatusNameは、この部分。これが表示されたら読み込み完了と捉える。

読み込み完了でようやくチャットワークの画面に到達する。

プロフィール画面を開く

ヘッダのプルダウンメニューを開いて、編集メニューをクリックしてプロフィール編集画面を表示する。
かなりまどろっこしい事してる気がする。可能であれば最初からプロフィール編集画面を表示するためのJavaScriptコードを実行させれば一発だと思うが、Seleniumの練習も含めて地道に行う。

0.5秒のスリープを毎回挟んでるのは、UIのアニメーションがあるのでスグに次のUIを操作できないため。これもっと良い方法あったら教えて下さい。

driver.find_element_by_css_selector('#_myStatusName').click()
time.sleep(0.5)
driver.find_element_by_css_selector('#_myProfile .myAccountMenu__anchor').click()
time.sleep(0.5)

表示名を編集する

編集ボタンをクリックし、テキストボックスの内容を書き換え、保存ボタンをクリックする。
変更後の表示名は、コマンドライン引数から取ることを想定し、sys.argvを参照する。

driver.find_element_by_css_selector('#_profileContent ._profileEdit').click()
time.sleep(0.5)
driver.find_element_by_id('_profileInputName').clear()
driver.find_element_by_id('_profileInputName').send_keys(sys.argv[1])
driver.find_element_by_css_selector("div[aria-label='保存する']").click()

実行

以下コマンドで実装できる。ネットワーク環境の影響を大きく受けるが、手元の環境で実行時間は10秒程度。APIがあれば1秒で終わるというのに。

$ python main.py "ふー ばーのすけ"

最新のソースコードはこちら

所感

  • Seleniumの入門としてはえらくニッチな使い方になったが、個人的にやりたかったことが実現できたので良かった
  • 全体的冗長なコードになりやすそう。ラッパーライブラリが多いのも頷ける
  • JavaScriptで動いてるページをうまく制御したければ関連ライブラリ/フレームワークを使うべきか
  • このやり方だとチャットワーク側がちょいとHTML変更したら動かなくなったりするのでやっぱりAPIが欲しい

RubyプログラマがPython3を勉強した話

概要

Rubyを使い始めて2年弱の私が、前々から気にはなっていたが使ったことのないPythonにようやく手を出したお話。
私がひと夏の間に行った、基本的な勉強についての備忘録を残す。

Pythonのバージョンについて

Pythonのバージョンは2.x系(Python2)と3.x系(Python3)が存在するが、2と3では互換性のない変更が多く含まれており、実質別言語として扱われることが多い。

既存システムの多くはPython2で書かれていること、Python3では動かない有用なライブラリが存在することなどから、単に新しいという理由でPython3を採用することはできないが、今回は以下の理由からPython3を採用する。

  • Python2は2010年以降更新されていないため
  • Python2は2020年で公式のサポートが打ち切られるため
  • Python3は現在も積極的なバージョンアップが行われており、今後は完全に3に以降する流れのため

Python3.6.2のインストール

今回は現在の最新のPythonである3.6.2を、Debianにインストールした。
インストールには、Pythonのバージョン管理ツールであるpyenvを用いることにした。インストール手順については、以下記事にまとめた

Python3.6.2をインストールして、Wikipediaをクロールするスクリプトを書く | QSのウェブ開発とか

Python3の基本の勉強

はじめに、Python3の基本的な使い方、文法などの勉強には以下ページを利用した。広く浅く、かつ丁寧にPython3の基本が書かれた良い記事だったので、自身を持ってオススメできる。

Python3基礎文法 – Qiita

Rubyなどのスクリプト言語をある程度経験している人なら、Python3の基本的な範囲については特に戸惑うこと無く馴染めると思う。

Python3のトレーニング

前項で基本的なPython3の書き方、文法は抑えられた。
しかし、まだまだPythonの感覚は掴めていないので、競技プログラミングの問題をPython3を用いて沢山解くことで、Pythonの感覚を掴むことにした。

今回は、私が個人的にもよく利用していたpaizaのスキルチェックを利用した。

paizaのスキルチェック問題は、以下の5段階の難易度がある

  • Dランク: ウォーミングアップレベル
  • Cランク: 初級レベル
  • Bランク: 中級レベル
  • Aランク: 上級レベル
  • Sランク: 超上級レベル

Aランク以上は、言語の使い方よりも、高度なアルゴリズム設計が求められる問題(そもそも解けないのが殆ど)となるので対象外とし、D,C,BランクをPython3を用いて解くことにした。

実際に問題を解いてみると、多言語ではすんなり書けることがPython3ではなかなか書けない。やりたいことをPython3で実現する方法が全然わらないのだ。そういった壁に直面するたびに、実現方法を調べては、Python節を身につけることを繰り返し、D,C,Bランク合わせて30問ぐらい解いたところである程度の理解は出来たと判断して終了した。

Python3によるWebスクレイピング

次に、Pythonと言えばWebスクレイピング/クローリングなので(偏見?)、HTTPライブラリと、XMLパーサライブラリを用いて色々遊んだ。
その中で作成した、Wikipediaをランダムにクロールするスクリプトについては、以下記事にまとめた

Python3.6.2をインストールして、Wikipediaをクロールするスクリプトを書く | QSのウェブ開発とか

Python3.6.2をインストールして、Wikipediaをクロールするスクリプトを書く

前提

本記事は以下の環境で動作確認済み

要素 バージョン
Debian 9.1
pyenv 1.1.3-7
python 3.6.2

概要

Pythonを使ったことのない私が、pyenvを用いて現在の最新版であるPython3.6.2をインストールし、Wikipediaの特定ページからランダムにリンクを辿り続けるスクリプトを書いたお話。

Python3.6.2のインストール

pyenvについて

pyenvは、複数のPythonのバージョンを容易にインストール、切り替えを行うツール。Rubyのrbenvや、nodeのnvm,nなどと同じようなもの。

pyenvのインストール

以下ページをそのまま参考にした。
PyenvによるPython3.x環境構築(CentOS, Ubuntu) – Qiita

3.6.2のインストール

前述のページだと若干古いPythonを使っているので、pyenvを用いて以下のように3.6.2をインストール

$ pyenv install -v 3.6.2
$ pyenv global 3.6.2

確認

$ python --version
Python 3.6.2

Python3入門

初Pythonだったのでまずは基本的な使い方を確認。以下のページが広く浅くしっかり丁寧だったので大変お世話になった。
Python3基礎文法 – Qiita

Wikipediaをクロールするスクリプト作成

概要

Pythonの基本を学んだので、手始めに、以下のような簡単なWebクロールスクリプトを書いてみる。

  • 特定のWikipediaのページを開始地点とする
  • 記事内の、他の単語へのリンク一覧を取得する
  • ノイズとなるリンクを排除し、関連する単語へのリンクのみを取り出す
  • 残ったリンク一覧から、ランダムに1件選出し、そのリンク先へ移動する
  • リンク先のページタイトルを標準出力し、同様の操作を繰り返す

Wikipediaを使った連想ゲームのような物?

使用ライブラリ

調べてみると、PythonにはWebクロールのためのフレームワーク/ライブラリも充実してるようだが、今回はPythonのお試しも含んでいるので、HTTPにrequests、HTML/XMLパーサにBeautifulSoupというライブラリを用いることにする。

実装

そんなに長くないので先に全文

BASEURL = 'https://ja.wikipedia.org'

# 指定したWikipediaページ内のaタグからランダムで一つ戻す
def get_wiki_a_tag(url):
  response = requests.get(url)
  soup = BeautifulSoup(response.text, 'lxml')
  print(soup.find('h1').text)
  a_tags = filter(
    lambda a: 'href' in a.attrs and
               a.text and
               a.attrs['href'].startswith('/wiki') and
               a.attrs['href'].find('Wikipedia') and
               a.text.find('年') == -1,
    soup.select('.mw-parser-output p a')
  )
  a_tags_list = list(a_tags)
  if not len(a_tags_list):
    print("end")
    sys.exit()
  return random.sample(a_tags_list, 1)[0]

# 開始地点を設定
word  = 'Python'
url   = f"{BASEURL}/wiki/{word}"
# ランダムにWikipediaのリンクを飛び続ける
while True:
  a_tag = get_wiki_a_tag(url)
  url  = f"{BASEURL}{a_tag.attrs['href']}"
  time.sleep(1)

まず、以下の部分で特定URLのHTTPレスポンスを取得する。BeautifulSoupを用いることで、レスポンスのXML/HTMLをparseできるようにする。

response = requests.get(url)
soup = BeautifulSoup(response.text, 'lxml')

関連する単語へのリンクは概ねdiv.mw-parser-output配下のp要素に含まれているので、その中のaタグの一覧を取得する。あくまで概ねなので正確ではない。

soup.select('.mw-parser-output p a')

ノイズとなるリンクはpythonのfilter関数で排除する。排除対象は以下の通り

  • href属性が設定されてないaタグ
  • テキストが設定されていないaタグ
  • リンク先が/wiki で始まらないaタグ
  • リンク先にWikipediaが含まれるaタグ(要出典、検証可能性など)
  • テキストに「年」が含まれるaタグ(‘2010年’などの記事が頻出するため)
lambda a: 'href' in a.attrs and
           a.text and
           a.attrs['href'].startswith('/wiki') and
           a.attrs['href'].find('Wikipedia') and
           a.text.find('年') == -1,

randomパッケージのsample関数を用いて、aタグの一覧からランダムに一つ戻す。RubyだとArrayにsampleメソッドがあるのでちょっと不便だと思った。

return random.sample(a_tags_list, 1)[0]

動作確認

Pythonスタートでやってみたところ、初めのうちは良い感じだったが、トレインチャンネルあたりからおかしくなった。

もう一度。Googleから何故サウナ風呂につながるのか‥‥。
※確認した所、社内にサウナがあるらしい

試しに開始地点を阿部寛にしたらやたらとグローバリゼーションになった。阿部寛だしそんなものか。

所感

  • とりあえず何かを作ることを通じてPythonに世界に飛び込んでみたが、まだまだ魅力は掴めてない。インデントブロックがPythonの特徴の一つだが、これもまだ見やすいとは思えない(インデント幅を4字にしたらまた違うかも)
  • Rubyに慣れてて、Rubyが一番好きな分、Pythonとはウマが合わない可能性もあるが、もっと使ってみて自分に合うとこを見つけていきたい。
  • 今回作ったスクリプト、ずっと眺めてられそう。