CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: Selenium + Headless Chrome で Web ページ全体のスクリーンショットを撮る

スクレイピングした Web サイトからページ全体のスクリーンショットを撮影したい機会があった。 そこで Selenium の Python バインディングと Headless Chrome を使ったところ実現できたのでメモしておく。 ちなみに、ページ全体でなければ Headless Chrome 単体でも撮れる。 その方法についても末尾に補足として記載しておいた。

使った環境は次の通り。

$ sw_vers             
ProductName:    Mac OS X
ProductVersion: 10.13.5
BuildVersion:   17F77
$ python -V   
Python 3.6.5
$ pip list --format=columns | grep -i selenium
selenium   3.13.0 
$ chromedriver --version
ChromeDriver 2.40.565386 (45a059dc425e08165f9a10324bd1380cc13ca363)

下準備

まずは Chrome Driver をインストールしておく。

$ brew cask install chromedriver

続いて Selenium の Python バインディングをインストールする。

$ pip install selenium

インストールできたら Python の REPL を起動する。

$ python

これで下準備ができた。

スクリーンショットを撮る

まずは必要なパッケージをインポートする。

>>> from selenium import webdriver
>>> from selenium.webdriver.chrome.options import Options

続いて Chrome Driver を Headless モードで起動する。

>>> options = Options()
>>> options.add_argument('--headless')
>>> driver = webdriver.Chrome(chrome_options=options)

スクリーンショットを撮りたいページの内容を取得する。

>>> driver.get('https://www.python.org')

続いてページ全体の横幅と高さを取得したら、それをウィンドウサイズとしてセットする。 これによって Web ページ全体のスクリーンショットを一つの画像として撮影できる。 ちなみに Headless モードにしていないと、ここで設定できる値がモニターの解像度に依存してしまう。

>>> page_width = driver.execute_script('return document.body.scrollWidth')
>>> page_height = driver.execute_script('return document.body.scrollHeight')
>>> driver.set_window_size(page_width, page_height)

ウィンドウサイズを変更したら、ページ内の要素が読み込まれるまで少し待った方が良い。 読み込みを待ちたい要素があらかじめ分かっているなら Wait をかけることもできそう。

5. Waits — Selenium Python Bindings 2 documentation

あとはスクリーンショットを撮るだけ。 上手くいけば返り値として True が返ってくる。

>>> driver.save_screenshot('screenshot.png')
True

上手くいったらドライバと REPL を終了する。

>>> driver.quit()
>>> exit()

撮影した画像を確認してみよう。

$ open screenshot.png

次のような結果が得られた。

f:id:momijiame:20180728183758p:plain

めでたしめでたし。

補足

ちなみに Headless Chrome 単体でもスクリーンショット自体は撮れる。 ただし、このやり方では残念ながらページ全体を撮ることはできない。

$ alias chrome="/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome"
$ chrome --headless --disable-gpu --screenshot https://www.python.org

こんな感じになっちゃう。

f:id:momijiame:20180728184056p:plain

参考

developers.google.com

Python: パラメータ選択を伴う機械学習モデルの交差検証について

今回は、ハイパーパラメータ選びを含む機械学習モデルの交差検証について書いてみる。 このとき、交差検証のやり方がまずいと汎化性能を本来よりも高く見積もってしまう恐れがある。 汎化性能というのは、未知のデータに対処する能力のことを指す。 ようするに、いざモデルを実環境に投入してみたら想定よりも性能が出ない (Underperform) ということが起こる。 これを防ぐには、交差検証の中でも Nested Cross Validation (Nested CV) あるいは Double Cross Validation と呼ばれる手法を使う。

ハイパーパラメータの選び方としては、色々な組み合わせをとにかく試すグリッドサーチという方法を例にする。 また、モデルのアルゴリズムにはサポートベクターマシンを使った。 これは、サポートベクターマシンはハイパーパラメータの変更に対して敏感な印象があるため。

その他、使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.13.5
BuildVersion:   17F77
$ python -V
Python 3.6.5

下準備

まずは、今回のサンプルコードで使うパッケージをあらかじめインストールしておく。

$ pip install scikit-learn numpy scipy tqdm matplotlib

続いて Python の REPL を起動する。

$ python

起動したら scikit-learn に組み込みで用意されている乳がんデータセットを読み込んでおこう。 これには、しこりに関する特徴量とそれが良性か悪性かの情報が含まれる。

>>> from sklearn import datasets
>>> 
>>> dataset = datasets.load_breast_cancer()
>>> X = dataset.data
>>> y = dataset.target

これで準備が整った。

ここからは、前提となる知識として交差検証に至る機械学習モデルを評価するやり方を一つずつ紹介していく。 おそらく、知っている内容も多いと思うので必要に応じて読み飛ばしてもらえると。

学習に用いたデータでモデルを評価する

まずは、最もダメなパターンから。 これは、モデルの学習に用いたデータを使って、そのモデルを評価するというもの。 これをやってしまうと、汎化性能は全く測れない。 なにせ全然未知ではなく、モデルが既に見たことのあるデータなのだから。

概念図はこんな感じ。

f:id:momijiame:20180723033816p:plain

とはいえ、ダメなパターンについても見ておくことは重要なので以下にサンプルコードを示す。 まずはサポートベクターマシンの分類器を用意して、データセットを全て使って学習させる。

>>> from sklearn.svm import SVC
>>> 
>>> svm = SVC(kernel='rbf')
>>> svm.fit(X, y)
SVC(C=1.0, cache_size=200, class_weight=None, coef0=0.0,
  decision_function_shape='ovr', degree=3, gamma='auto', kernel='rbf',
  max_iter=-1, probability=False, random_state=None, shrinking=True,
  tol=0.001, verbose=False)

続いて、学習したモデルを使ってデータセット全てに対して予測する。

>>> y_pred = svm.predict(X)

これは、学習に使ったデータを、そのまま予測しているということ。

予測結果の精度 (Accuracy) を計算してみよう。

>>> from sklearn.metrics import accuracy_score
>>> 
>>> accuracy_score(y, y_pred)
1.0

なんと 100% の精度が得られた!ヤッター! …といっても、これはモデルが一度見たことのあるデータを予測しているだけなので、何ら驚くには値しない。 もちろん、これではモデルの汎化性能は測れない。

ホールドアウト検証 (Hold-out Validation)

続いては、汎化性能をそれなりに評価するための方法としてホールドアウト検証を紹介する。 これは、データセットを学習用とテスト用に分割する。 そして、学習用のデータをモデルに学習させた上で、テスト用のデータを使ってモデルの性能を評価するというもの。 テスト用のデータはモデルにとって見たことのない未知のデータなので、これは汎化性能を示す指標となりうる。

概念図はこんな感じ。

f:id:momijiame:20180723032847p:plain

先ほどと同様に、サンプルコードを示す。 まずは、データセットを学習用とテスト用に分割する。

>>> from sklearn.model_selection import train_test_split
>>> 
>>> X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.33, shuffle=True, random_state=42)

分割方法に再現性を持たせたい場合には random_state オプションを指定した方が良い。 この数値を指定して、他のオプションについても値が同じである限りデータが同じように分割される。 また、データの分割に偏りを作らないためには shuffle オプションを有効にしてランダムにデータを選択した方が良い。 もちろん、ただランダムに分割するだけでは偏りを取り除けない場合には、それ以外のやり方で分割する必要がある。

データを分割したら、学習用データの方を使ってモデルを学習する。

>>> svm = SVC(kernel='rbf')
>>> svm.fit(X_train, y_train)
SVC(C=1.0, cache_size=200, class_weight=None, coef0=0.0,
  decision_function_shape='ovr', degree=3, gamma='auto', kernel='rbf',
  max_iter=-1, probability=False, random_state=None, shrinking=True,
  tol=0.001, verbose=False)

今度は 64.3% の精度が得られた。 こちらの方が、先ほどよりもモデルの性能を現実的に示している。

>>> y_pred = svm.predict(X_test)
>>> accuracy_score(y_test, y_pred)
0.6436170212765957

交差検証 (Cross Validation)

ホールドアウト検証法はデータを分割しているとはいっても一回だけの試行なので偏りが含まれる余地が比較的ある。 この偏りを減らすには交差検証というやり方を用いる。 これは、複数回に渡って異なる分割をしたデータに対し、それぞれでホールドアウト検証をして結果を合算するというもの。

概念図はこんな感じ。

f:id:momijiame:20180723032916p:plain

scikit-learn では KFold を使うと交差検証が楽にできる。 以下のサンプルコードでは分割数 (試行回数) として 4 を指定した。

>>> from sklearn.model_selection import KFold
>>> 
>>> kf = KFold(n_splits=4, shuffle=True, random_state=42)

交差検証のスコアは cross_val_score を使うと楽に計算できる。 といっても、これは先ほどのホールドアウト検証を KFold を使ってループしながら実行しているだけ。 自分で書いても全然問題はない。

>>> from sklearn.model_selection import cross_val_score
>>> 
>>> svm = SVC(kernel='rbf')
>>> scores = cross_val_score(svm, X=X, y=y, cv=kf)

結果としては、各ホールドアウト検証における性能が得られる。

>>> scores
array([0.62237762, 0.69014085, 0.61971831, 0.57746479])

一般的には、上記を単純に算術平均すると思う。

>>> average_score = scores.mean()
>>> average_score
0.6274253915098986

ちなみに分割数をデータ点数まで増やした場合は Leave-One-Out 検証法と呼ばれる。 機械学習系の文章の中では、よく LOO と省略されていることがある。

ハイパーパラメータの選択を含む交差検証

ここからが本題。 先ほどのサンプルコードでは、基本的にサポートベクターマシンのモデルをデフォルトのハイパーパラメータで扱っていた。 ただ、実際に使うときはハイパーパラメータの調整が必要になる。 このとき、ただ単純に交差検証をするだけだとモデルの性能を高く見積もってしまう恐れがある。

上記をサンプルコードと共に確認する。 まずは、先ほどと同じようにサポートベクターマシンのモデルと交差検証用のオブジェクトを用意する。

>>> svm = SVC(kernel='rbf')
>>> kf = KFold(n_splits=4, shuffle=True, random_state=42)

ハイパーパラメータの候補は、次のように辞書とリストを組み合わせて用意する。

>>> candidate_params = {
...     'C': [1, 10, 100],
...     'gamma': [0.01, 0.1, 1],
... }

GridSearchCV にモデルとハイパーパラメータの候補を渡して、データを学習させる。 GridSearchCV は名前に CV と入っている通り、内部的に交差検証を使いながら性能の良いハイパーパラメータの組み合わせを探してくれる。

>>> from sklearn.model_selection import GridSearchCV
>>> from multiprocessing import cpu_count
>>> 
>>> gs = GridSearchCV(estimator=svm, param_grid=candidate_params, cv=kf, n_jobs=cpu_count())
>>> gs.fit(X, y)
GridSearchCV(cv=KFold(n_splits=4, random_state=42, shuffle=True),
       error_score='raise',
       estimator=SVC(C=1.0, cache_size=200, class_weight=None, coef0=0.0,
  decision_function_shape='ovr', degree=3, gamma='auto', kernel='rbf',
  max_iter=-1, probability=False, random_state=None, shrinking=True,
  tol=0.001, verbose=False),
       fit_params=None, iid=True, n_jobs=4,
       param_grid={'C': [1, 10, 100], 'gamma': [0.01, 0.1, 1]},
       pre_dispatch='2*n_jobs', refit=True, return_train_score='warn',
       scoring=None, verbose=0)

学習が終わると、最も性能の良かったハイパーパラメータで学習したモデルが GridSearchCV#best_estimator_ で得られる。

>>> gs.best_estimator_
SVC(C=10, cache_size=200, class_weight=None, coef0=0.0,
  decision_function_shape='ovr', degree=3, gamma=0.01, kernel='rbf',
  max_iter=-1, probability=False, random_state=None, shrinking=True,
  tol=0.001, verbose=False)

同様に GridSearchCV#best_params_ で最も性能の良かったハイパーパラメータの組み合わせが得られる。

>>> gs.best_params_
{'C': 10, 'gamma': 0.01}

また、GridSearchCV#best_score_ から上記のモデルが記録したスコアも得られる。

>>> gs.best_score_
0.6344463971880492

では、上記のハイパーパラメータを使ったモデルなら未知のデータに対して 63.4% の精度が得られるはずかというと、そうでもないらしい。 これは、一回の交差検証だけだと精度が偏って得られることも考えられるため。 ようするに、大きく外してはいないはずだけど見積もりとしては楽観的なものになる。 この、一回の交差検証だけで評価するやり方を Non-nested Cross Validation (Non-nested CV) という。

概念図としてはこんな感じ。 さっきの単純な交差検証をハイパーパラメータの組み合わせごとにやっているだけ。

f:id:momijiame:20180723033334p:plain

Nested Cross Validation (Nested CV)

前述した問題をどうやって解決するかというと、交差検証を二重にする。 この方法は Nested CV と呼ばれる。

概念図はこんな感じ。

f:id:momijiame:20180723033015p:plain

Nested CV では、交差検証を内側 (Inner CV) と外側 (Outer CV) の二重に分けている。 内側ではハイパーパラメータの選択に注力し、外側はできたモデルの評価に注力する。 ポイントとしては、それぞれで重複するデータをモデルに触らせていないところ。 一度でもモデルに見せたデータはその時点で汚れてしまうため、評価する上で二度と使うことはできない。

サンプルコードで Nested CV を見ていこう。 まずは、先ほどと同じようにグリッドサーチ用のオブジェクトまで作っておく。

>>> svm = SVC(kernel='rbf')
>>> kf = KFold(n_splits=4, shuffle=True, random_state=42)
>>> gs = GridSearchCV(estimator=svm, param_grid=candidate_params, cv=kf, n_jobs=cpu_count())

続いて、上記の GridSearchCV のインスタンスをさらに cross_val_score() 関数に突っ込む。

>>> scores = cross_val_score(gs, X=X, y=y, cv=kf)

上記は正直なかなか分かりにくいので、順を追って解説する。 まず、cross_val_score() 関数が前述した外側の交差検証になっている。 外側で分割した学習用データのみが GridSearchCV のインスタンスに渡される。 GridSearchCV のインスタンスは、渡された学習用データをさらに分割して学習用データとハイパーパラメータ調整用データにする。 そして、そのデータを使ってハイパーパラメータを選択する。 これが前述した内側の交差検証になる。 ハイパーパラメータの選択が終わったら、できあがったモデルが外側の交差検証で評価される。 ようするに、外側の交差検証の分割数×内側の交差検証の分割数×ハイパーパラメータの組み合わせの数だけホールドアウト検証を繰り返すことになる。

上記で得られたスコアが以下の通り。 ようするに、これは各内側の交差検証で性能の良かったモデルたちが外側の交差検証で記録した性能ということになる。

>>> scores
array([0.62937063, 0.69014085, 0.61971831, 0.58450704])

上記の算術平均は次の通り。 先ほどの Non-nested CV よりも、ほんの少しではあるが下がっている。

>>> average_score = scores.mean()
>>> average_score
0.6309342066384319

Non-nested CV に比べると、この Nested CV で記録した値の方が現実に則した汎化性能を表している、とされる。

Non-nested CV と Nested CV が記録するスコアを比較する

先ほどの例では Non-nested CV よりも Nested CV の方が低めのスコアが出た。 一回だけならたまたまということも考えられるので、念のため何度か試行してグラフにプロットしてみる。

次のサンプルコードでは Non-nested CV と Nested CV を 50 回繰り返して、それぞれが記録するスコアをプロットする。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from multiprocessing import cpu_count

from sklearn import datasets
from sklearn.svm import SVC
from sklearn.model_selection import KFold
from sklearn.model_selection import cross_val_score
from sklearn.model_selection import GridSearchCV

import numpy as np

from matplotlib import pyplot as plt

from tqdm import tqdm


def main():
    NUM_TRIALS = 50

    # データセットを読み込む
    dataset = datasets.load_breast_cancer()
    X = dataset.data
    y = dataset.target

    # 候補となるハイパーパラメータ
    candidate_params = {
        'C': [1, 10, 100],
        'gamma': [0.01, 0.1, 1, 'auto'],
    }

    # 計測したスコアを保存するためのリスト
    scores_non_nested_cv = np.zeros(NUM_TRIALS)
    scores_nested_cv = np.zeros(NUM_TRIALS)

    # 何回か試してみる
    for i in tqdm(range(NUM_TRIALS)):

        # Non Nested CV
        svm = SVC(kernel='rbf')
        kf = KFold(n_splits=4, shuffle=True, random_state=i)
        gscv = GridSearchCV(estimator=svm, param_grid=candidate_params, cv=kf, n_jobs=cpu_count())
        gscv.fit(X, y)
        scores_non_nested_cv[i] = gscv.best_score_

        # Nested CV
        svm = SVC(kernel='rbf')
        kf = KFold(n_splits=4, shuffle=True, random_state=i)
        gs = GridSearchCV(estimator=svm, param_grid=candidate_params, cv=kf, n_jobs=cpu_count())
        scores = cross_val_score(gs, X=X, y=y, cv=kf)
        scores_nested_cv[i] = scores.mean()

    # スコア平均と標準偏差
    print('non nested cv: mean={:.5f} std={:.5f}'.format(scores_non_nested_cv.mean(), scores_non_nested_cv.std()))
    print('nested cv: mean={:.5f} std={:.5f}'.format(scores_nested_cv.mean(), scores_nested_cv.std()))

    # グラフを描画する
    plt.figure(figsize=(10, 6))
    plt.plot(scores_non_nested_cv, color='g', label='non nested cv')
    plt.plot(scores_nested_cv, color='b', label='nested cv')
    plt.legend()
    plt.show()


if __name__ == '__main__':
    main()

適当な名前をつけて上記を保存したら実行してみよう。

$ python cv.py 
 36%|█████████████████▎                              | 18/50 [00:47<01:24,  2.64s/it]

計算には時間が結構かかるので tqdm を使って進捗を表示させている。

tqdm については、以前に以下の記事で紹介している。

blog.amedama.jp

50 回試した上での精度の平均は次の通り。 Nested CV の方が 0.3% ほど精度を低く見積もっていることが分かる。

non nested cv: mean=0.63195 std=0.00261
nested cv: mean=0.62878 std=0.00255

得られたグラフは次の通り。 一部に例外はあるものの、基本的には Nested CV の方が Non-nested CV よりも精度を低く見積もっている。

f:id:momijiame:20180722140025p:plain

疑問と悩み

めでたしめでたし。 と、言いたいところなんだけど、いくつか自分でもまだ完全には腑に落ちていないところがある。

学習に使うデータが減る問題

Nested CV の方が Non-nested CV よりも精度の見積もりは低く出た。 とはいえ Nested CV では Non-nested CV よりもモデルの学習に使うデータ自体も減っている。 これは、データの分割が Non-nested CV では二つなのに対して Nested CV では三つになっているため。 具体的には Nested CV ではデータを学習用、ハイパーパラメータ調整用、検証用に分割することになる。 対して Non-nested CV では学習用と検証用にしか分割していない。

学習に使うデータが減れば、バイアス以外にもそれだけで精度が低くなる余地があるように感じる。 かといって、ハイパーパラメータ調整用や検証用のデータを減らすと、精度の分散が大きくなってモデル選択が難しくなるような気がする。 まあ、もちろんそれで Nested CV をやらない理由にはならないだろうけど。

一体どのハイパーパラメータを選べば良いのよ問題

Nested CV では、内側の交差検証で選ばれてくるモデルたちのハイパーパラメータがどれも同一とは限らないはず。 同一でない場合には、結局のところどのハイパーパラメータの組み合わせを選べば良いのよ?となる。 まあ、本当にバラバラならどれを使っても似たようなものなんだ、という理解にはつながるかもしれないけど。

もし結果をそのまま使いたいなら、内側の交差検証で選ばれた各モデルを使ってアンサンブル (Voting) すると良いのかな? 実際のところ、ハイパーパラメータの目星がついたからといって、改めてモデルに未分割の全データを学習させて同じ汎化性能が得られるとは限らない。 交差検証をしていないモデルからは、どんな結果が得られてもおかしくはないのだから。

参考

Nested versus non-nested cross-validation — scikit-learn 0.19.2 documentation

Nested Cross Validation: When Cross Validation Isn’t Enough

いじょう。

Python: tqdm で処理の進捗状況をプログレスバーとして表示する

最近は Python がデータ分析や機械学習の分野でも使われるようになってきた。 その影響もあって REPL や Jupyter Notebook 上でインタラクティブに作業することも増えたように感じる。 そんなとき、重い処理を走らせると一体いつ終わるのか分からず途方に暮れることもある。 今回紹介する tqdm は、走らせた処理の進捗状況をプログレスバーとして表示するためのパッケージ。 このパッケージ自体はかなり昔からあるんだけど、前述した通り利用環境の変化や連携するパッケージの増加によって便利さが増してきてる感じ。

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.13.5
BuildVersion:   17F77
$ python -V    
Python 3.6.5

下準備

まずは tqdm をインストールしておく。

$ pip install tqdm
$ pip list --format=columns | grep tqdm 
tqdm       4.23.4

終わったら Python の REPL を起動する。

$ python

基本的な使い方

ここからは tqdm の基本的な使い方を紹介する。

その前に、まずは tqdm がない場合から考えてみる。 次のサンプルコードでは 100 回のループを 100 ミリ秒の間隔を空けて実行している。 実行すると、何もレスポンスがない状態で 10 秒間待たされることになる。

>>> import time
>>> 
>>> for _ in range(100):
...     time.sleep(0.1)
... 

実際に実行してみると 10 秒間とはいえ長く感じる。

それでは、続いて上記の処理に tqdm を導入してみる。 変更点は一箇所だけで、上記の range() 関数の結果を tqdm() 関数に渡すだけ。 これだけで tqdm は渡された内容を読み取って全体の処理と現在の進捗をプログレスバーとして表示してくれる。

>>> from tqdm import tqdm
>>> 
>>> for _ in tqdm(range(100)):
...     time.sleep(0.1)
... 
 63%|█████████████████████████████▌                 | 63/100 [00:06<00:03,  9.69it/s]

プログレスバーがあるだけで同じ待ち時間でも感じ方はだいぶ変わるはず。

上記を見ると、どういう仕組みなのか結構気になる。 そもそも tqdm に渡せるオブジェクトは一体なんなのか? 答えから言ってしまうと tqdm にはイテラブルなオブジェクトなら何でも渡せる。 イテラブルなオブジェクトというのは、具体的には iter() 関数を使ってイテレータが返ってくるもの。

そもそもイテレータって何?っていう話については以下の記事に書いた。

blog.amedama.jp

なので、もちろんリストを渡すこともできるし。

>>> for _ in tqdm(list([1, 2, 3, 4])):
...     time.sleep(1)
... 
100%|██████████████████████████████████████████████████| 4/4 [00:04<00:00,  1.00s/it]

何なら文字列だって渡すことができる。

>>> for _ in tqdm('Hello, World!'):
...     time.sleep(0.1)
... 
100%|████████████████████████████████████████████████| 13/13 [00:01<00:00,  9.73it/s]

イテレータ自体には終わりを設ける必要はない。 なので、次のように無限に値を返し続けるイテレータを渡しても良い。 ただし、この場合はイテレータからオブジェクトを取り出した回数や、経過時間やスループットだけが表示される。

>>> from itertools import count
>>> 
>>> for _ in tqdm(count()):
...     time.sleep(0.01)
... 
417it [00:04, 85.10it/s]

ひとしきり満足したら Ctrl-C で止めよう。

pandas と連携させる

tqdm は pandas と連携させることもできる。

まずは pandas をインストールしよう。

$ pip install pandas
$ pip list --format=columns | grep pandas
pandas            0.23.3 

サンプルとなる DataFrame オブジェクトを用意しておく。

>>> import pandas as pd
>>> df = pd.DataFrame(list(range(10000)))

この状態では DataFrame には pregress_apply() というメソッドは存在しない。

>>> df.progress_apply
Traceback (most recent call last):
...(省略)...
AttributeError: 'DataFrame' object has no attribute 'progress_apply'

そこで、おもむろに tqdm をインポートしたら pandas() 関数を呼び出してみよう。

>>> from tqdm import tqdm
>>> tqdm.pandas()

すると DataFrame オブジェクトに progress_apply() メソッドが生えてきて使えるようになる。 これは単純に DataFrame#apply() メソッドの進捗表示ありバージョンと考えれば良い。

>>> df.progress_apply(lambda x: x ** 2, axis=1)
 96%|███████████████████████████████████████▎ | 9577/10000 [00:01<00:00, 5944.60it/s]

DataFrame#apply() 関数は結構重い処理をすることも多い (特に axis=1 のとき) ので、これは意外とありがたい。 ただし、それ以外のメソッドについてはこれまで通り何も表示されない。

Jupyter Notebook と連携させる

また、Jupyter Notebook と連携させることもできる。

まずは Jupyter Notebook 本体と ipyqidgets をインストールしておこう。

$ pip install notebook ipywidgets
$ pip list --format=columns | grep notebook
notebook            5.6.0

ノートブックのサーバを起動する。

$ jupyter notebook

適当なノートブックを新たに作ったら、次のコードをセルに入力して実行してみよう。 ターミナルとの違いはインポートするものが tqdm.tqdm から tqdm.tqdm_notebook に変わるだけ。

from tqdm import tqdm_notebook as tqdm
import time

for _ in tqdm(range(100)):
    time.sleep(0.1)

すると、次のようにプログレスバーが表示される。

f:id:momijiame:20180721130000p:plain

ちなみに、別に普通の tqdm.tqdm が使えないというわけではない。 試しに、最初に示した例を入力して実行してみよう。

from tqdm import tqdm
import time

for _ in tqdm(range(100)):
    time.sleep(0.1)

上記ほどしっかりとした表示ではないものの、次のようにちゃんと表示してくれる。

f:id:momijiame:20180721130420p:plain

めでたしめでたし。

Python: matplotlib で動的にグラフを生成する

今回は matplotlib を使って動的にグラフを生成する方法について。 ここでいう動的というのは、データを逐次的に作って、それを随時グラフに反映していくという意味を指す。 例えば機械学習のモデルを学習させるときに、その過程 (損失の減り方とか) を眺める用途で便利だと思う。

使った環境は次の通り。

$ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.13.5
BuildVersion:   17F77
$ python -V
Python 3.6.5
$ pip list --format=columns | egrep -i "(matplotlib|pillow)"
matplotlib      2.2.2  
Pillow          5.2.0  

下準備

まずは今回使うパッケージをインストールしておく。

$ pip install matplotlib pillow

静的にグラフを生成する

動的な生成について説明する前に、まずは静的なグラフの生成から説明する。 といっても、これは一般的な matplotlib のグラフの作り方そのもの。 あらかじめ必要なデータを全て用意しておいて、それをグラフとしてプロットする。

この場合、当たり前だけどプロットする前に全てのデータが揃っていないといけない。 例えば機械学習なら、モデルの学習を終えて各エポックなりラウンドごとの損失が出揃っている状態まで待つ必要がある。

次のサンプルコードではサイン波のデータをあらかじめ作った上で、それを折れ線グラフにしている。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import math

import numpy as np
from matplotlib import pyplot as plt


def main():
    # 描画領域
    fig = plt.figure(figsize=(10, 6))
    # 描画するデータ
    x = np.arange(0, 10, 0.1)
    y = [math.sin(i) for i in x]

    # グラフを描画する
    plt.plot(x, y) 

    # グラフを表示する
    plt.show()


if __name__ == '__main__':
    main()

上記を適当な名前でファイルに保存して実行してみよう。

$ python sin.py

すると、次のようなグラフが表示される。

f:id:momijiame:20180712235912p:plain

これが静的なグラフ生成の場合。

動的にグラフを生成する

続いて動的にグラフを生成する方法について。 これには matplotlib.animation パッケージを使う。 特に FuncAnimation を使うと作りやすい。

animation — Matplotlib 2.2.2 documentation

次のサンプルコードでは、先ほどの例と同じサイン波を動的に生成している。 ポイントは、グラフの再描画を担当する関数をコールバックとして FuncAnimation に登録すること。 そうすれば、あとは FuncAnimation が一定間隔でその関数を呼び出してくれる。 呼び出されるコールバック関数の中でデータを生成したりグラフを再描画する。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import math

import numpy as np
from matplotlib import pyplot as plt
from matplotlib import animation


def _update(frame, x, y):
    """グラフを更新するための関数"""
    # 現在のグラフを消去する
    plt.cla()
    # データを更新 (追加) する
    x.append(frame)
    y.append(math.sin(frame))
    # 折れ線グラフを再描画する
    plt.plot(x, y)


def main():
    # 描画領域
    fig = plt.figure(figsize=(10, 6))
    # 描画するデータ (最初は空っぽ)
    x = []
    y = []

    params = {
        'fig': fig,
        'func': _update,  # グラフを更新する関数
        'fargs': (x, y),  # 関数の引数 (フレーム番号を除く)
        'interval': 10,  # 更新間隔 (ミリ秒)
        'frames': np.arange(0, 10, 0.1),  # フレーム番号を生成するイテレータ
        'repeat': False,  # 繰り返さない
    }
    anime = animation.FuncAnimation(**params)

    # グラフを表示する
    plt.show()


if __name__ == '__main__':
    main()

先ほどと同じようにファイルに保存したら実行する。

$ python sin.py

すると、次のように動的にグラフが描画される。

f:id:momijiame:20180712235931g:plain

ちなみに、上記のような GIF 画像や動画は次のようにすると保存できる。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import math

import numpy as np
from matplotlib import pyplot as plt
from matplotlib import animation


def _update(frame, x, y):
    """グラフを更新するための関数"""
    # 現在のグラフを消去する
    plt.cla()
    # データを更新 (追加) する
    x.append(frame)
    y.append(math.sin(frame))
    # 折れ線グラフを再描画する
    plt.plot(x, y)


def main():
    # 描画領域
    fig = plt.figure(figsize=(10, 6))
    # 描画するデータ (最初は空っぽ)
    x = []
    y = []

    params = {
        'fig': fig,
        'func': _update,  # グラフを更新する関数
        'fargs': (x, y),  # 関数の引数 (フレーム番号を除く)
        'interval': 10,  # 更新間隔 (ミリ秒)
        'frames': np.arange(0, 10, 0.1),  # フレーム番号を生成するイテレータ
        'repeat': False,  # 繰り返さない
    }
    anime = animation.FuncAnimation(**params)

    # グラフを保存する
    anime.save('sin.gif', writer='pillow')


if __name__ == '__main__':
    main()

グラフを延々と描画し続ける

先ほどの例では frames オプションに渡すイテレータに終わりがあった。 具体的には 0 ~ 10 の範囲を 0.1 区切りで分割した 100 のデータに対してグラフを生成した。 また repeat オプションに False を指定することで繰り返し描画することも抑制している。

続いては、先ほどとは異なり frames オプションに終わりのないイテレータを渡してみよう。 こうすると、手動で止めるかメモリなどのリソースを食いつぶすまでは延々とデータを生成してグラフを描画することになる。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import itertools
import math

import numpy as np
from matplotlib import pyplot as plt
from matplotlib import animation


def _update(frame, x, y):
    """グラフを更新するための関数"""
    # 現在のグラフを消去する
    plt.cla()
    # データを更新 (追加) する
    x.append(frame)
    y.append(math.sin(frame))
    # 折れ線グラフを再描画する
    plt.plot(x, y)


def main():
    # 描画領域
    fig = plt.figure(figsize=(10, 6))
    # 描画するデータ
    x = []
    y = []

    params = {
        'fig': fig,
        'func': _update,  # グラフを更新する関数
        'fargs': (x, y),  # 関数の引数 (フレーム番号を除く)
        'interval': 10,  # 更新間隔 (ミリ秒)
        'frames': itertools.count(0, 0.1),  # フレーム番号を無限に生成するイテレータ
    }
    anime = animation.FuncAnimation(**params)

    # グラフを表示する
    plt.show()


if __name__ == '__main__':
    main()

上記をファイルに保存して実行してみよう。

$ python sin.py

ずーーーっとグラフが生成され続けるはず。

f:id:momijiame:20180715153531p:plain

Jupyter Notebook 上で動的にグラフを生成する

Jupyter Notebook 上で動的なグラフ生成をするときは、次のように %matplotlib nbagg マジックコマンドを使う。 また、意図的に pyplot.show() を呼び出す必要はない。

%matplotlib nbagg

import itertools
import math

import numpy as np
from matplotlib import pyplot as plt
from matplotlib import animation


def _update(frame, x, y):
    """グラフを更新するための関数"""
    # 現在のグラフを消去する
    plt.cla()
    # データを更新 (追加) する
    x.append(frame)
    y.append(math.sin(frame))
    # 折れ線グラフを再描画する
    plt.plot(x, y)


# 描画領域
fig = plt.figure(figsize=(10, 6))
# 描画するデータ
x = []
y = []

params = {
    'fig': fig,
    'func': _update,  # グラフを更新する関数
    'fargs': (x, y),  # 関数の引数 (フレーム番号を除く)
    'interval': 10,  # 更新間隔 (ミリ秒)
    'frames': np.arange(0, 10, 0.1),  # フレーム番号を生成するイテレータ
    'repeat': False,  # 繰り返さない
}
anime = animation.FuncAnimation(**params)

データの更新間隔とグラフの再描画間隔をずらす

グラフの再描画はそこまで軽い処理でもないし、データの更新間隔とずらしたいときもあるかも。 そんなときはデータを更新するスレッドと、グラフを再描画するスレッドを分ける。

以下のサンプルコードではデータの更新用に新しくスレッドを起動している。 データの更新間隔が 100ms 間隔なのに対してグラフの再描画は 250ms 間隔にしている。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import time
import threading
import math
import itertools

from matplotlib import pyplot as plt
from matplotlib import animation


def _redraw(_, x, y):
    """グラフを再描画するための関数"""
    # 現在のグラフを消去する
    plt.cla()
    # 折れ線グラフを再描画する
    plt.plot(x, y)


def main():
    # 描画領域
    fig = plt.figure(figsize=(10, 6))
    # 描画するデータ (最初は空っぽ)
    x = []
    y = []

    def _update():
        """データを一定間隔で追加するスレッドの処理"""
        for frame in itertools.count(0, 0.1):
            x.append(frame)
            y.append(math.sin(frame))
            # データを追加する間隔 (100ms)
            time.sleep(0.1)

    def _init():
        """データを一定間隔で追加するためのスレッドを起動する"""
        t = threading.Thread(target=_update)
        t.daemon = True
        t.start()

    params = {
        'fig': fig,
        'func': _redraw,  # グラフを更新する関数
        'init_func': _init,  # グラフ初期化用の関数 (今回はデータ更新用スレッドの起動)
        'fargs': (x, y),  # 関数の引数 (フレーム番号を除く)
        'interval': 250,  # グラフを更新する間隔 (ミリ秒)
    }
    anime = animation.FuncAnimation(**params)

    # グラフを表示する
    plt.show()


if __name__ == '__main__':
    main()

上記をファイルに保存して実行してみよう。

$ python sin.py

これまでに比べるとグラフの再描画間隔が長いので、ちょっとカクカクした感じでグラフが更新される。

めでたしめでたし。

Python: pandas の永続化フォーマットについて調べた

以前、このブログでは pandas の DataFrame を Pickle として保存することで読み込み速度を上げる、というテクニックを紹介した。

blog.amedama.jp

実は pandas がサポートしている永続化方式は Pickle 以外にもある。 今回は、その中でも代表的な以下の永続化フォーマットについて特性を調べると共に簡単なベンチマークを取ってみることにした。

  • Pickle
  • Feather
  • Parquet

使った環境とパッケージのバージョンは次の通り。

$ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.13.5
BuildVersion:   17F77
$ python -V
Python 3.6.5
$ pip list --format=columns | egrep "(pandas|feather-format|pyarrow)"
feather-format   0.4.0      
pandas           0.23.3     
pyarrow          0.9.0.post1

pandas の永続化フォーマットについて

本格的な説明に入る前に、まずはこの記事で扱う各フォーマットの概要について解説する。

Pickle Format

Python が標準でサポートしている直列化方式。 直列化というのは、プログラミングにおいてオブジェクトをバイト列に変換・逆変換する機能のことをいう。 バイト列に変換した内容は、ディスクにファイルなどの形で保存 (永続化) できる。

基本的に、直列化した内容は Python でしか扱うことができない。 つまり、言い換えると他のプログラミング言語や分析ツール (例えば R など) へのポータビリティはない。

その他、詳しくは以下のブログ記事で解説している。

blog.amedama.jp

Feather Format

主に Python と R の間でデータのポータビリティがあることを目指して策定されたファイルフォーマット。 行単位ではなく列単位でデータを格納する形式 (列指向・カラムナ) になっている。 今のところ pandas では試験的な導入という位置づけらしい。

Parquet Format

Apache Hadoop エコシステムでよく使われているファイルフォーマット。 こちらも Feather フォーマットと同様に、異なるプログラミング言語や分析ツールの間でポータビリティがある。

以前、このブログでも Python で Parquet フォーマットを実装した二つのパッケージについて調べたことがある。

blog.amedama.jp

下準備

それぞれの永続化フォーマットについての解説が済んだところで、次は実際に試してみる準備に入る。

まずは、今回使うパッケージ一式をインストールしておく。

$ pip install pandas ipython feather-format memory_profiler

動作確認のためのデータセットは、ベンチマークを取りたいのである程度サイズがほしい。 そこで前述した Pickle の記事と同様に Kaggle の大気汚染データセットを使うことにした。

Hazardous Air Pollutants | Kaggle

上記のページか、あるいは kaggle コマンドを使ってデータセットをダウンロードしてくる。

$ kaggle datasets download -d epa/hazardous-air-pollutants

ダウンロードしたらデータセットを展開する。

$ mv ~/.kaggle/datasets/epa/hazardous-air-pollutants/hazardous-air-pollutants.zip .
$ unzip hazardous-air-pollutants.zip 

すると、次の通り 2GB 強の CSV ファイルが手に入る。

$ du -m epa_hap_daily_summary.csv 
2349   epa_hap_daily_summary.csv

続いて IPython の REPL を起動する。

$ ipython

Python 純正の REPL を使わないのは理由は、実行時間やメモリ使用量の計測が IPython を使った方が楽ちんなので。

REPL を起動したら pandas をインポートした上で上記の CSV を読み込んでおく。

>>> import pandas as pd
>>> df = pd.read_csv('epa_hap_daily_summary.csv')

次の通りデータセットが読み込まれれば準備完了。

>>> df.head()
   state_code         ...           date_of_last_change
0          42         ...                    2015-07-22
1          48         ...                    2014-03-25
2          22         ...                    2015-07-22
3          18         ...                    2017-02-20
4           6         ...                    2015-07-22

[5 rows x 29 columns]

各永続化方式のベンチマーク

今回は、各永続化方式について以下の観点でベンチマークを取ってみる。

  • 保存にかかる時間
  • 保存したときのファイルサイズ
  • 読み込みにかかる時間
  • 読み込みに必要なメモリサイズ

先に断っておくと、今回のベンチマークはあくまで簡易的なものになっている。 本来であれば、扱うデータ型などによる特性の違いや、試行回数についても複数回の平均を取りたい。 とはいえ、そこまでやると大変なので、今回は大気汚染データセットだけを使って、かつ実行回数も 1 回だけに絞っている。 もしかすると環境や使うデータセットによっては異なる結果が出る場合もあるかもしれない。

ちなみに、あとの方に結果をまとめた表を作ってあるので読むのがめんどくさい人はそこまで読み飛ばしてもらえると。

保存にかかる時間

まずは各永続化フォーマットでファイルに保存するのにかかる時間を測ってみる。

Pickle Format

>>> %time df.to_pickle('hazardous-air-pollutants.pickle')
CPU times: user 9.31 s, sys: 9.17 s, total: 18.5 s
Wall time: 20.8 s

Feather Format

>>> %time df.to_feather('hazardous-air-pollutants.feather')
CPU times: user 1min 38s, sys: 6.76 s, total: 1min 45s
Wall time: 1min 46s

Parquet Format

>>> %time df.to_parquet('hazardous-air-pollutants.snappy.parquet')
CPU times: user 2min 2s, sys: 7.52 s, total: 2min 9s
Wall time: 2min 15s

pandas.to_parquet() 関数はデフォルトで snappy による圧縮がかかる。 念のため、圧縮をかけない場合についても確認しておこう。

>>> %time df.to_parquet('hazardous-air-pollutants.none.parquet', compression=None)
CPU times: user 2min 3s, sys: 9.19 s, total: 2min 12s
Wall time: 2min 30s

あまり変わらないか、むしろ遅くなっている。

ちなみに Pickle フォーマットも GZip で圧縮することはできる。 ただし、試してみたところ保存に時間がかかりすぎて実用に耐えないという判断で検証するのを止めた。

上記から、保存に関しては Pickle フォーマットが最も早いことが分かった。

終わったら、一旦 IPython の REPL から抜けておこう。

>>> exit()

保存したときのファイルサイズ

続いては各永続化フォーマットで保存したときのディスク上のサイズを確認する。

まず、元々の CSV は 2.3GB だった。

$ du -m epa_hap_daily_summary.csv 
2349   epa_hap_daily_summary.csv

各永続化フォーマットにおけるサイズは次の通り。 圧縮の有無に関わらず Parquet が抜きん出て小さいことが分かる。

$ du -m hazardous-air-pollutants.* | grep -v zip$
3116   hazardous-air-pollutants.feather
260    hazardous-air-pollutants.none.parquet
1501   hazardous-air-pollutants.pickle
228    hazardous-air-pollutants.snappy.parquet

それ以外だと Pickle はやや小さくなっているのに対し Feather は元の CSV よりもサイズが増えてしまっていることが分かる。

読み込みにかかる時間

もう一度 IPython の REPL を起動して pandas をインポートしておこう。

$ ipython
>>> import pandas as pd

準備ができたら各永続化フォーマットで保存したファイルを読み込むのにかかる時間を測ってみる。

Pickle Format

>>> %time df = pd.read_pickle('hazardous-air-pollutants.pickle')
CPU times: user 4.51 s, sys: 4.07 s, total: 8.58 s
Wall time: 9.03 s

Feather Format

>>> %time df = pd.read_feather('hazardous-air-pollutants.feather')
CPU times: user 18.2 s, sys: 17.3 s, total: 35.4 s
Wall time: 1min 8s

Parquet Format

%time df = pd.read_parquet('hazardous-air-pollutants.snappy.parquet')
CPU times: user 27.4 s, sys: 34.4 s, total: 1min 1s
Wall time: 1min 31s

念のため snappy 圧縮していないものについても。

%time df = pd.read_parquet('hazardous-air-pollutants.none.parquet')
CPU times: user 27.3 s, sys: 34.7 s, total: 1min 2s
Wall time: 1min 18s

わずかに読み込み時間が短くなっている。

終わったら一旦 IPython の REPL を終了しておこう。

>>> exit()

読み込みに必要なメモリサイズ

続いては永続化されたファイルを読み込む際に必要なメモリのサイズを確認しておく。 これが大きいと、読み込むときにスラッシングを起こしたり最悪の場合はメモリに乗り切らずにエラーになる恐れがある。

まずは IPython を起動して pandas をインポートする。

$ ipython
>>> import pandas as pd

それができたら、続いては %load_ext で memory_profiler を読み込む。 これで %memit マジックコマンドが使えるようになる。

>>> %load_ext memory_profiler

あとは各永続化フォーマットで保存されたファイルを読み込む際のメモリ使用量を %memit マジックコマンドで確認していく。

Pickle Format

>>> %memit df = pd.read_pickle('hazardous-air-pollutants.pickle')
peak memory: 2679.70 MiB, increment: 2617.24 MiB

読み込みが終わったら GC を呼んで読み込んだ DataFrame を開放しておく。

>>> del df; import gc; gc.collect()

仕様的には手動で呼び出しても本当にオブジェクトが回収される保証はないはず。 とはいえ、まあこの状況ならまず間違いなく回収されるでしょう。

Feather Format

>>> %memit df = pd.read_feather('hazardous-air-pollutants.feather')
peak memory: 3614.03 MiB, increment: 3549.74 MiB

終わったら GC を呼ぶ。

>>> del df; import gc; gc.collect()

Parquet Format

>>> %memit df = pd.read_parquet('hazardous-air-pollutants.snappy.parquet')
peak memory: 3741.76 MiB, increment: 3677.94 MiB

終わったら GC を呼ぶ。

>>> del df; import gc; gc.collect()

念のため snappy 圧縮なしのパターンについても。

%memit df = pd.read_parquet('hazardous-air-pollutants.none.parquet')
peak memory: 3771.86 MiB, increment: 3707.23 MiB

ベンチマーク結果

ベンチマークの結果を表にまとめてみる。

項目 Pickle Feather Parquet (snappy)
保存にかかる時間 20s 1m46s 2m15s
保存したときのサイズ 1501MB 3116MB 228MB
読み込みにかかる時間 9s 1m8s 1m18s
読み込みに必要なメモリサイズ 2679MB 3614MB 3741MB

保存したときのサイズは Parquet が最も小さかったけど、それ以外は全て Pickle の性能が優れていた。

各永続化フォーマットを使う際の注意点

Pickle Format

Pickle に関しては、保存する DataFrame に対する制約は特にない。 その以外で特に気をつける点としては Pickle フォーマットにはバージョンがあるところくらいかな。 これは、異なるバージョンの Python 間で同じ Pickle ファイルを扱う場合には、フォーマットのバージョンに気をつける必要がある。 あとは前述した通り Python 以外から扱えないので基本的に他の環境へのポータビリティがない。

その他、細かい点については以下のブログ記事にまとめてある。

blog.amedama.jp

Feather Format

pandas の DataFrame を Feather フォーマットで保存する際の注意点は以下のページに記載されている。

IO Tools (Text, CSV, HDF5, …) — pandas 0.23.3 documentation

以下に、要点をコードで解説する。

インデックスがあると保存できない

Feather フォーマットを使う場合、DataFrame にインデックスが設定されていると永続化できない。

物は試しということで、まずはインデックスを指定した DataFrame を用意しよう。

>>> import pandas as pd
>>> data = [(10, 'Alice'), (20, 'Bob'), (30, 'Carol')]
>>> df = pd.DataFrame(data, columns=['id', 'name'])
>>> df = df.set_index('id')

上記を Feather フォーマットで保存してみる、と以下のような例外になる。

>>> df.to_feather('example.feather')
Traceback (most recent call last):
...(省略)...
ValueError: feather does not support serializing a non-default index for the index; you can .reset_index() to make the index into column(s)

もし、インデックスの指定がある DataFrame を Feather で扱いたいとしたら次のようにする。 一旦インデックスをリセットして、カラムの形で持っておけば大丈夫。

>>> df = df.reset_index()
>>> df.to_feather('example.feather')

とはいえ、ちょっとめんどくさいね。

重複するカラム名があると保存できない

pandas の DataFrame には重複するカラム名を保存できる。 ただし Feather フォーマットでは重複するカラム名がある DataFrame を永続化できない。

実際に試してみよう。 同じカラム名がある DataFrame を作る。

>>> import pandas as pd
>>> data = [('a', 'b'), ('c', 'd'), ('e', 'f')]
>>> df = pd.DataFrame(data, columns=['feature_name', 'feature_name'])

この通り、ちゃんと作れる。

>>> df
  feature_name feature_name
0            a            b
1            c            d
2            e            f

しかし、永続化しようとすると、この通り例外になる。

>>> df.to_feather('example.feather')
Traceback (most recent call last):
...(省略)...
ValueError: cannot serialize duplicate column names
保存できない型がある

Feather フォーマットでは保存できない型が存在する。 例えば Period 型なんかは典型のようだ。

実際に試してみよう。

>>> import pandas as pd
>>> data = [
...     pd.Period('2018-01-01'),
...     pd.Period('2018-01-02'),
...     pd.Period('2018-01-03'),
... ]
>>> df = pd.DataFrame(data, columns=['periods'])

保存しようとすると、次の通り例外になる。

>>> df.to_feather('example.feather')
Traceback (most recent call last):
...(省略)...
pyarrow.lib.ArrowInvalid: Error inferring Arrow type for Python object array. Got Python object of type Period but can only handle these types: string, bool, float, int, date, time, decimal, list, array

上記のエラーメッセージには取り扱える型の一覧も表示されている。

Parquet Format

続いて Parquet フォーマットを扱う上での注意点について。 公式では以下のページにまとめられている。

IO Tools (Text, CSV, HDF5, …) — pandas 0.23.3 documentation

以下、要点をコードで確認していこう。

重複するカラム名があると保存できない

これは先ほど紹介した Feather フォーマットと同様。 そもそもファイルフォーマットとして、重複したカラム名を想定していないんだろう。

>>> import pandas as pd
>>> data = [('a', 'b'), ('c', 'd'), ('e', 'f')]
>>> df = pd.DataFrame(data, columns=['feature_name', 'feature_name'])

省略すると、先ほどと同じ例外になる。

>>> df.to_parquet('example.parquet')
Traceback (most recent call last):
...(省略)...
ValueError: Duplicate column names found: ['feature_name', 'feature_name']
カテゴリカル変数を保存して読み出すとオブジェクト型になる

pandas にはカテゴリカル変数が型として用意されている。 この型は Parquet フォーマットで保存することはできるんだけど、読み出すときにオブジェクト型になってしまう。

実際に試してみよう。 カテゴリカル変数が含まれるものを保存してもエラーにはならない。

>>> import pandas as pd
>>> data = [('a'), ('b'), ('c')]
>>> df = pd.DataFrame(data, columns=['categorical_name'], dtype='category')
>>> df.dtypes
categorical_name    category
dtype: object
>>> df.to_parquet('example.parquet')

ただし保存されたものを読み出してみると? なんとオブジェクト型になってしまっている。

>>> df = pd.read_parquet('example.parquet')
>>> df.dtypes
categorical_name    object
dtype: object

これは知らないとハマりそう。

保存できない型がある

これは先ほどの Feather フォーマットと同様。

例えば Period 型を保存しようとすると例外になる。

>>> import pandas as pd
>>> data = [
...     pd.Period('2018-01-01'),
...     pd.Period('2018-01-02'),
...     pd.Period('2018-01-03'),
... ]
>>> df = pd.DataFrame(data, columns=['periods'])
>>> df.to_parquet('example.parquet')
Traceback (most recent call last):
...(省略)...
pyarrow.lib.ArrowInvalid: Error inferring Arrow type for Python object array. Got Python object of type Period but can only handle these types: string, bool, float, int, date, time, decimal, list, array
マルチインデックスに使うカラム名は文字列型である必要がある

pandas には複数のカラムを用いたインデックスが作れる。 ただし、そのインデックスに使うカラム名の型は必ず文字列じゃないとだめ。

例えばマルチインデックスの一つとして名前が数値のカラムを指定してみよう。 以下でいう 1970 というカラム。

>>> import pandas as pd
>>> data = [
...     ('Alice', 47, 70),
...     ('Alice', 48, 80),
...     ('Bob', 47, 50),
...     ('Bob', 48, 45),
...     ('Carol', 47, 60),
...     ('Carol', 48, 65),
... ]
>>> df = pd.DataFrame(data, columns=['name', 1970, 'score'])
>>> df = df.set_index(['name', 1970])

この通り、ちゃんと数値の名前がついている。

>>> df.index
MultiIndex(levels=[['Alice', 'Bob', 'Carol'], [47, 48]],
           labels=[[0, 0, 1, 1, 2, 2], [0, 1, 0, 1, 0, 1]],
           names=['name', 1970])

これを Parquet フォーマットで保存しようとするとエラーになる。

>>> df.to_parquet('example.parquet')
Traceback (most recent call last):
...(省略)...
ValueError: Index level names must be strings

まあ文字列以外の名前をつけようとするなんて、そうそうないとは思うけど。

まとめ

今回は pandas の代表的な永続化フォーマットについて、その特性を調べると共に簡易なベンチマークを取ってみた。 簡易的かつ、今回使った環境では、という断りを入れた上でまとめると、次の通り。

  • ディスク上のファイルサイズという観点では Parquet フォーマットが優れていた
  • それ以外の観点 (読み書きの速度やメモリ消費量) では Pickle フォーマットが優れていた
  • pandas と Parquet / Feather フォーマットを組み合わせて使う場合には、いくつかの制限事項がある

以上から、使い分けについては次のことが言えると思う。

  • 基本的には Pickle フォーマットを使っていれば良さそう
  • ディスク上のファイルサイズに制約があれば Parquet フォーマットが良さそう
  • 他の環境とのポータビリティが必要なときは Parquet もしくは Feather フォーマットを使う

いじょう。

2018/07/25 追記

memory_profiler の結果だとメモリ使用量を上手く見積もれない場合がある、という話を耳にした。 そこで GNU time を使った測定もしてみた。 結果を以下に記載する。

下準備

まずは Homebrew を使って GNU time をインストールする。

$ brew install gnu-time

CSVからの 読み込み

とりあえずの基準として CSV を読み込むときのメモリ使用量は次の通り。

$ gtime -f "%M KB" python -c "import pandas as pd; df = pd.read_csv('epa_hap_daily_summary.csv')"
3390012 KB

各永続化フォーマットでの書き込み

各永続化フォーマットで書き込むときのメモリ使用量は次の通り。

$ gtime -f "%M KB" python -c "import pandas as pd; df = pd.read_csv('epa_hap_daily_summary.csv'); df.to_pickle('hazardous-air-pollutants.pickle')"
4381452 KB
$ gtime -f "%M KB" python -c "import pandas as pd; df = pd.read_csv('epa_hap_daily_summary.csv'); df.to_feather('hazardous-air-pollutants.feather')"
5802100 KB
$ gtime -f "%M KB" python -c "import pandas as pd; df = pd.read_csv('epa_hap_daily_summary.csv'); df.to_parquet('hazardous-air-pollutants.snappy.parquet')"
5715660 KB

Pickle フォーマットが最も少ないようだ。

各永続化フォーマットからの読み込み

各永続化フォーマットから読み込むときのメモリ使用量は次の通り。

$ gtime -f "%M KB" python -c "import pandas as pd; df = pd.read_pickle('hazardous-air-pollutants.pickle')"
3028756 KB
$ gtime -f "%M KB" python -c "import pandas as pd; df = pd.read_feather('hazardous-air-pollutants.feather')"
4540188 KB
$ gtime -f "%M KB" python -c "import pandas as pd; df = pd.read_parquet('hazardous-air-pollutants.snappy.parquet')"
5515928 KB

やはり、こちらも Pickle が最も消費が少なかった。

項目 Pickle Feather Parquet (snappy)
書き込みに必要なメモリサイズ (GNU time) 4381452KB 5802100KB 5715660KB
読み込みに必要なメモリサイズ (GNU time) 3028756KB 4540188KB 5515928KB

いじょう。

shellcheck でシェルスクリプトのコードの質をチェックする

正しく動作するシェルスクリプトを書くのは難しい。 できれば書きたくないけど、そうもいかない。 そんなとき心の支えになりそうなのが今回紹介する shellcheck というツール。 これはシェルスクリプトにおける Linter (リンター) で、まずい書き方をしているとそれを教えてくれる。

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.13.5
BuildVersion:   17F77

下準備

まずは shellcheck をインストールする。 macOS であれば Homebrew でインストールするのが楽ちん。

$ brew install shellcheck

インストールすると shellcheck コマンドが使えるようになる。

$ shellcheck -V
ShellCheck - shell script analysis tool
version: 0.5.0
license: GNU General Public License, version 3
website: https://www.shellcheck.net

使ってみる

試しに、意図的にまずいシェルスクリプトを書いてそれをチェックしてみよう。

例えば、こんなコードを用意してみる。 一見すると、特に問題はなさそうな気もする。

$ cat << 'EOF' > example.sh 
#!/bin/sh

echo Time: `date`
EOF

実行しても、ちゃんと動くし。

$ sh example.sh 
Time: 201878日 日曜日 185226秒 JST

とはいえ、これを shellcheck 先生にかけると、次のようにいくつか指摘を受ける。 例えば SC2046 はクオートが足りないという指摘で、SC2006 はインラインのコマンド実行には `` よりも $() を使うべきという指摘。

$ shellcheck example.sh

In example.sh line 3:
echo Time: `date`
           ^-- SC2046: Quote this to prevent word splitting.
           ^-- SC2006: Use $(..) instead of legacy `..`.

上記の指摘を直してみよう。

$ cat << 'EOF' > example.sh 
#!/bin/sh

echo "Time: $(date)"
EOF

再度実行すると、今度は何も言われない。

$ shellcheck example.sh 

他にも、例えばシングルクオートで囲んでいるので展開されない変数があるパターンとか。

$ cat << 'EOF' > example.sh 
#!/bin/sh

echo 'Can not expand with single quote: $PATH'
EOF

これも、次の通りちゃんと教えてくれる。

$ shellcheck example.sh

In example.sh line 3:
echo 'Can not expand with single quote: $PATH'
     ^-- SC2016: Expressions don't expand in single quotes, use double quotes for that.

あとは、地味にやりがちな lsfor 文に突っ込んじゃうやつとか。 これはファイル名やディレクトリ名にスペースが入っていると動かない。

$ cat << 'EOF' > example.sh 
#!/bin/sh

for f in $(ls); do
  echo "${f}"
done
EOF

これもちゃんと教えてくれる。

$ shellcheck example.sh

In example.sh line 3:
for f in $(ls); do
         ^-- SC2045: Iterating over ls output is fragile. Use globs.

ばっちり。

めでたしめでたし。

Python: scikit-learn の Pipeline を使ってみる

機械学習では、元のデータセットに対して前処理や推論フェーズが何段にも重なることがある。 scikit-learn には、そういった何段にも重なった処理を表現しやすくするために Pipeline という機能が備わっている。 今回は、その Pipeline を使ってみることにする。

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.13.5
BuildVersion:   17F77
$ python -V                           
Python 3.6.5

下準備

まずは scikit-learn と必要なパッケージをあらかじめインストールしておく。

$ pip install numpy scipy scikit-learn

続いて Python のインタプリタを起動しておく。

$ python

データセットについては Iris データセットを使う。

>>> from sklearn import datasets
>>> iris = datasets.load_iris()
>>> X, y = iris.data, iris.target

これで下準備はできた。

Pipeline 機能を使ってみる

Pipeline では、その名のごとく一連の処理をパイプラインで表現する。 具体的には、リストに scikit-learn のオブジェクトを適用する順番で格納しておく。

以下にサンプルコードのパイプラインを示す。 このパイプラインでは、まず前処理として主成分分析 (PCA) した上で、それをランダムフォレストで分類できる。 リストには処理の名前と、その処理を実行するオブジェクトを対にしたタプルを入れる。

>>> from sklearn.pipeline import Pipeline
>>> from sklearn.decomposition import PCA
>>> from sklearn.ensemble import RandomForestClassifier
>>> steps = [
...     ('pca', PCA()),
...     ('rf', RandomForestClassifier())
... ]
>>> pipeline = Pipeline(steps=steps)

PCA とランダムフォレストを選んだこと自体には特に意味はなくて、あくまでこんな風にできるよという例。

Pipeline オブジェクトは scikit-learn のオブジェクトが一般的に持っているインターフェースを備えている。 なので、以下のような感じで fit() メソッドで学習した上で predict() メソッドで推論、といういつもの流れが使える。

>>> from sklearn.model_selection import train_test_split
>>> X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.33, random_state=42)
>>> pipeline.fit(X_train, y_train)
>>> y_pred = pipeline.predict(X_test)

精度自体は今回の目的ではないけど、一応計算しておく。

>>> from sklearn.metrics import accuracy_score
>>> accuracy_score(y_test, y_pred)
0.98

GridSearchCV と組み合わせて使う

Pipeline は GridSearchCV と組み合わせることもできる。 GridSearchCV を使うと、ハイパーパラメータの中から性能の良い組み合わせを自動的に探してくれる。

Pipeline と GridSearchCV を組み合わせて使うときは、パラメータ名の指定に工夫が必要になる。 具体的には <処理の名前>__<パラメータ名> という形式で指定する。

>>> from sklearn.model_selection import GridSearchCV
>>> params = {
...     'pca__n_components': range(1, X.shape[1]),
...     'rf__n_estimators': [10, 100, 1000],
... }
>>> grid_search_cv = GridSearchCV(pipeline,  param_grid=params, cv=4)

ふつうの scikit-learn オブジェクトと GridSearchCV を組み合わせるときは単純にパラメータ名だけで良かった。 それに対して Pipeline には複数のオブジェクトが含まれるため、それを識別するための名前が追加で必要になる。

GridSearchCV#fit() メソッドでハイパーパラメータの探索処理が走る。

>>> grid_search_cv.fit(X, y)

処理が終わったら best_params_ を参照することで最適なパラメータの値が分かる。

>>> grid_search_cv.best_params_
{'pca__n_components': 2, 'rf__n_estimators': 1000}

Pipeline に組み込むオブジェクトを自作する

ここまでは scikit-learn オブジェクトをそのまま Pipeline に組み込むパターンを試した。 次は Pipeline に自作した前処理や推論フェーズの処理を組み込んでみよう。 とはいえ、これには自作したオブジェクトに scikit-learn のインターフェースをダックタイピングで用意するだけで良い。

まずは前処理をするオブジェクトから。 この処理では受け取った値を 2 で割ったものに変換する。 もちろん、この処理自体には特に意味はない。

>>> class MyPreprocessor(object):
...     """前処理 (最低限 fit, transform メソッドが必要)"""
...     def fit(self, X, y):
...         """学習フェーズ"""
...         return self
...     def transform(self, X):
...         """変換フェーズ"""
...         # 2 で割った余りを返す
...         return X % 2
...     def predict(self, X):
...         """分類フェーズ"""
...         # 何もせずに素通しする
...         return X
... 

続いては Pipeline の最後段で推論をするオブジェクト。 このオブジェクトは受け取った値が 0 か非 0 かで True/False を返す処理をする。 こちらも、もちろん処理自体には特に意味はない。

>>> class MyClassifier(object):
...     """回帰・分類処理 (fit メソッドが必要)"""
...     def fit(self, X, y):
...         """学習フェーズ"""
...         return self
...     def predict(self, X):
...         """分類フェーズ"""
...         # X が 0 か非 0 かで True/False を返す
...         return X == 0
... 

上記で用意した自作オブジェクトを組み込んだ Pipeline を作る。 このパイプラインでは値を 2 で割った余りに変換した上で、それが 0 なら True をそうでなければ False を返す。 これはようするに、一連の処理で引数が偶数か奇数かを判定することになる。

>>> steps = [
...     ('pre', MyPreprocessor()),
...     ('clf', MyClassifier()),
... ]
>>> pipeline = Pipeline(steps=steps)

データとしては 0 から 9 までのリストを使う。

>>> import numpy as np
>>> X = np.arange(10)

Pipeline にデータを渡してみよう。 今回に関しては実は fit() 関数は使ってないんだけど、とりあえず機械学習なら普通は使うので呼んでおく。

>>> pipeline.fit(X)
>>> pipeline.predict(X)
array([ True, False,  True, False,  True, False,  True, False,  True,
       False])

うまいこと受け取った引数が偶数か奇数かを判定できている。

めでたしめでたし。