CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: pandas-profiling でデータセットの概要を確認する

今回は pandas-profiling というパッケージを使ってみる。 このパッケージを使うと pandas の DataFrame に含まれる各次元の基本的な統計量や相関係数などを一度に確認できる。 最初にデータセットのサマリーを確認できると、その後の EDA (Exploratory Data Analysis: 探索的データ分析) の取っ掛かりにしやすいと思う。

使った環境は次の通り。

$ sw_vers 
ProductName:    macOS
ProductVersion: 12.4
BuildVersion:   21F79
$ python -V         
Python 3.9.13
$ pip3 list | grep pandas-profiling
pandas-profiling              3.2.0

下準備

まずは必要なパッケージをインストールしておく。

$ pip install pandas-profiling

サンプルのデータセットを用意する

サンプルとなるデータセットは Kaggle でおなじみの Titanic データセットにした。

Titanic - Machine Learning from Disaster | Kaggle

データセットは Web サイトか、あるいはコマンドラインツールからダウンロードしておく。

$ pip install kaggle
$ kaggle competitions download -c titanic
$ head train.csv 
PassengerId,Survived,Pclass,Name,Sex,Age,SibSp,Parch,Ticket,Fare,Cabin,Embarked
1,0,3,"Braund, Mr. Owen Harris",male,22,1,0,A/5 21171,7.25,,S
2,1,1,"Cumings, Mrs. John Bradley (Florence Briggs Thayer)",female,38,1,0,PC 17599,71.2833,C85,C
3,1,3,"Heikkinen, Miss. Laina",female,26,0,0,STON/O2. 3101282,7.925,,S
4,1,1,"Futrelle, Mrs. Jacques Heath (Lily May Peel)",female,35,1,0,113803,53.1,C123,S
5,0,3,"Allen, Mr. William Henry",male,35,0,0,373450,8.05,,S
6,0,3,"Moran, Mr. James",male,,0,0,330877,8.4583,,Q
7,0,1,"McCarthy, Mr. Timothy J",male,54,0,0,17463,51.8625,E46,S
8,0,3,"Palsson, Master. Gosta Leonard",male,2,3,1,349909,21.075,,S
9,1,3,"Johnson, Mrs. Oscar W (Elisabeth Vilhelmina Berg)",female,27,0,2,347742,11.1333,,S

pandas-profiling を使ってみる

それでは、実際に pandas-profiling を使ってみる。 まずは Python のインタプリタを起動する。

$ python

データセットの CSV を pandas で読み込む。

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

あとは読み込んだ DataFrame を pandas-profiling に渡すだけ。

>>> import pandas_profiling
>>> profile_report = pandas_profiling.ProfileReport(df)

解析結果は、次のように HTML で出力できる。

>>> profile_report.to_file('report.html')

一旦、インタプリタからは抜けておこう。

>>> exit()

あとは出力された HTML を確認する。

$ open report.html

このように、各次元の基本的な統計量や欠損値の有無、分布や相関係数などが一度に確認できる。

いじょう。

Python: scikit-learn の FeatureUnion を pandas の DataFrame と一緒に使う

今回は scikit-learnFeatureUnionpandasDataFrame を一緒に使うときの問題点とその解決策について。 scikit-learn の FeatureUnion は、典型的には Pipeline においてバラバラに作った複数の特徴量を一つにまとめるのに使われる機能。 この FeatureUnion を pandas の DataFrame と一緒に使おうとすると、ちょっとばかり予想外の挙動になる。 具体的には FeatureUnion の出力するデータが、本来なら DataFrame になってほしいところで numpy の ndarray 形式に変換されてしまう。 今回は、それをなんとか DataFrame に直す方法がないか調べたり模索してみた話。

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.13.6
BuildVersion:   17G65
$ python -V      
Python 3.7.0
$ pip list --format=columns | egrep -i "(scikit-learn|pandas)"
pandas          0.23.4 
scikit-learn    0.20.0 

下準備

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

$ pip install scikit-learn pandas

FeatureUnion について

まずは前提知識として FeatureUnion について紹介する。 これは Pipeline と一緒に使うことで真価を発揮する機能で、複数の特徴量を一つにまとめ上げるのに使われる。

次のサンプルコードでは Iris データセットを題材にして Pipeline と FeatureUnion を使っている。 この中では、元々 Iris データセットに含まれていた特徴量と、それらを標準化した特徴量を並列に準備して結合している。 動作確認のために Pipeline への入力データと出力データの情報を標準出力に書き出している。

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

from sklearn import datasets
from sklearn.pipeline import FeatureUnion
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.preprocessing import FunctionTransformer


def main():
    # Iris データセットを読み込む
    dataset = datasets.load_iris()
    X, y = dataset.data, dataset.target

    # パイプラインの入力データ
    print('input:')
    print(type(X), X.shape)
    print(X[:5])

    steps = [
        # 複数の特徴量を結合する
        ('union', FeatureUnion([
            # 生の特徴量をそのまま返す
            ('raw', FunctionTransformer(lambda x: x, validate=False)),
            # 標準化した特徴量を返す
            ('scaler', StandardScaler()),
        ])),
    ]
    pipeline = Pipeline(steps=steps)
    transformed_data = pipeline.fit_transform(X)

    # パイプラインの出力データ
    print('output:')
    print(type(transformed_data), transformed_data.shape)
    print(transformed_data[:5])


if __name__ == '__main__':
    main()

論より証拠ということで上記を実行してみよう。 すると、入力時点では 4 次元 150 行だったデータが、出力時点では 8 次元 150 行になっている。 これは、元々 Iris データセットに含まれていた 4 次元の特徴量に加えて、それらを標準化した特徴量が 4 次元加わっているため。

$ python featunion.py
input:
<class 'numpy.ndarray'> (150, 4)
[[5.1 3.5 1.4 0.2]
 [4.9 3.  1.4 0.2]
 [4.7 3.2 1.3 0.2]
 [4.6 3.1 1.5 0.2]
 [5.  3.6 1.4 0.2]]
output:
<class 'numpy.ndarray'> (150, 8)
[[ 5.1         3.5         1.4         0.2        -0.90068117  1.01900435
  -1.34022653 -1.3154443 ]
 [ 4.9         3.          1.4         0.2        -1.14301691 -0.13197948
  -1.34022653 -1.3154443 ]
 [ 4.7         3.2         1.3         0.2        -1.38535265  0.32841405
  -1.39706395 -1.3154443 ]
 [ 4.6         3.1         1.5         0.2        -1.50652052  0.09821729
  -1.2833891  -1.3154443 ]
 [ 5.          3.6         1.4         0.2        -1.02184904  1.24920112
  -1.34022653 -1.3154443 ]]

このように FeatureUnion を使うと特徴量エンジニアリングの作業が簡潔に表現できる。

pandas の DataFrame と一緒に使うときの問題点について

ただ、この FeatureUnion を pandas の DataFrame と一緒に使おうとすると問題が出てくる。 具体的には FeatureUnion を通すと、入力が DataFrame でも出力が numpy の ndarray の変換されてしまうのだ。

次のサンプルコードを見てほしい。 このコードでは Pipeline の入力データを pandas の DataFrame に変換している。

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

import pandas as pd

from sklearn import datasets
from sklearn.pipeline import Pipeline
from sklearn.pipeline import FeatureUnion
from sklearn.preprocessing import FunctionTransformer


def main():
    # Iris データセットを読み込む
    dataset = datasets.load_iris()
    X, y = dataset.data, dataset.target

    # データセットを pandas の DataFrame に変換する
    X_df = pd.DataFrame(X, columns=dataset.feature_names)

    # パイプラインの入力データ
    print('input:')
    print(type(X_df))
    print(X_df.head())

    steps = [
        ('union', FeatureUnion([
            ('raw', FunctionTransformer(lambda x: x, validate=False)),
        ])),
    ]
    pipeline = Pipeline(steps=steps)
    transformed_data = pipeline.fit_transform(X_df)

    # パイプラインの出力データ
    print('output:')
    print(type(transformed_data))
    print(transformed_data[:5])


if __name__ == '__main__':
    main()

上記を実行してみよう。 すると Pipeline の出力データが numpy の ndarray に変換されてしまっていることが分かる。

$ python dfissue.py
input:
<class 'pandas.core.frame.DataFrame'>
   sepal length (cm)        ...         petal width (cm)
0                5.1        ...                      0.2
1                4.9        ...                      0.2
2                4.7        ...                      0.2
3                4.6        ...                      0.2
4                5.0        ...                      0.2

[5 rows x 4 columns]
output:
<class 'numpy.ndarray'>
[[5.1 3.5 1.4 0.2]
 [4.9 3.  1.4 0.2]
 [4.7 3.2 1.3 0.2]
 [4.6 3.1 1.5 0.2]
 [5.  3.6 1.4 0.2]]

上記のような振る舞いになる原因は scikit-learn の以下のコードにある。 FeatureUnion では特徴量の結合に numpy.hstack() を使っているのだ。 この関数の返り値は numpy の ndarray になるので FeatureUnion の出力もそれになってしまう。

github.com

ColumnTransformer でも同じ問題が起こる

ちなみに FeatureUnion と似たような機能の ColumnTransformer を使っても同じ問題が起こる。 ColumnTransformer は処理対象のカラム (次元) を指定して特徴量が生成できる。

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

import pandas as pd

from sklearn import datasets
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import FunctionTransformer


def main():
    # Iris データセットを読み込む
    dataset = datasets.load_iris()
    X, y = dataset.data, dataset.target

    # データセットを pandas の DataFrame に変換する
    X_df = pd.DataFrame(X, columns=dataset.feature_names)

    # パイプラインの入力データ
    print('input:')
    print(type(X_df))
    print(X_df.head())

    steps = [
        ('union', ColumnTransformer([
            ('transparent', FunctionTransformer(lambda x: x, validate=False), [i for i, _ in enumerate(X_df)]),
        ])),
    ]
    pipeline = Pipeline(steps=steps)
    transformed_data = pipeline.fit_transform(X_df)

    # パイプラインの出力データ
    print('output:')
    print(type(transformed_data))
    print(transformed_data[:5])


if __name__ == '__main__':
    main()

上記を実行してみよう。 FeatureUnion のときと同じように出力データは numpy の ndarray 形式に変換されてしまった。

$ python columnissue.py 
input:
<class 'pandas.core.frame.DataFrame'>
   sepal length (cm)        ...         petal width (cm)
0                5.1        ...                      0.2
1                4.9        ...                      0.2
2                4.7        ...                      0.2
3                4.6        ...                      0.2
4                5.0        ...                      0.2

[5 rows x 4 columns]
output:
<class 'numpy.ndarray'>
[[5.1 3.5 1.4 0.2]
 [4.9 3.  1.4 0.2]
 [4.7 3.2 1.3 0.2]
 [4.6 3.1 1.5 0.2]
 [5.  3.6 1.4 0.2]]

解決方法について

この問題について調べると、同じように悩んでいる人たちがいる。 そして、例えば次のような解決策を提示している人がいた。

zablo.net

上記では、自分で Transformer と FeatureUnion を拡張することで対応している。 ただ、このやり方だと作業が大掛かりで scikit-learn の API 変更などに継続的に追随していく必要があると感じた。

そこで、別の解決策として「とてもダーティーなハック」を思いついてしまったので共有してみる。 この問題は、特徴量の結合に numpy の hstack() 関数を使っていることが原因だった。 だとすれば、この関数をモンキーパッチで一時的にすり替えてしまえば問題を回避できるのではないか?と考えた。

実際に試してみたのが次のサンプルコードになる。 このコードでは unittest.mock.patch() を使って、一時的に numpy.hstack()pd.concat() にすり替えている。 これなら出力データは pandas の DataFrame にできるのではないか?

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

from functools import partial
from unittest.mock import patch

import pandas as pd

from sklearn import datasets
from sklearn.pipeline import Pipeline
from sklearn.pipeline import FeatureUnion
from sklearn.preprocessing import FunctionTransformer


def main():
    # Iris データセットを読み込む
    dataset = datasets.load_iris()
    X, y = dataset.data, dataset.target

    # データセットを pandas の DataFrame に変換する
    X_df = pd.DataFrame(X, columns=dataset.feature_names)

    # パイプラインの入力データ
    print('input:')
    print(type(X_df))
    print(X_df.head())

    steps = [
        ('union', FeatureUnion([
            ('transparent', FunctionTransformer(lambda x: x, validate=False)),
        ])),
    ]
    pipeline = Pipeline(steps=steps)

    # numpy.hstack を一時的に pd.concat(axis=1) にパッチしてしまう
    horizontal_concat = partial(pd.concat, axis=1)
    with patch('numpy.hstack', side_effect=horizontal_concat):
        transformed_data = pipeline.fit_transform(X_df)

    print('output:')
    print(type(transformed_data))
    print(transformed_data.head())


if __name__ == '__main__':
    main()

上記を実行してみよう。 すると、見事に出力データが pandas の DataFrame になっていることが分かる。

$ python monkeypatch.py
input:
<class 'pandas.core.frame.DataFrame'>
   sepal length (cm)        ...         petal width (cm)
0                5.1        ...                      0.2
1                4.9        ...                      0.2
2                4.7        ...                      0.2
3                4.6        ...                      0.2
4                5.0        ...                      0.2

[5 rows x 4 columns]
output:
<class 'pandas.core.frame.DataFrame'>
   sepal length (cm)        ...         petal width (cm)
0                5.1        ...                      0.2
1                4.9        ...                      0.2
2                4.7        ...                      0.2
3                4.6        ...                      0.2
4                5.0        ...                      0.2

[5 rows x 4 columns]

この解決策の問題点としては、学習の過程において numpy.hstack() が使えなくなるところ。 pd.concat() は入力として pandas のオブジェクトしか渡すことができない。 もし numpy の ndarray を渡してしまうと、次のようにエラーになる。

>>> import numpy as n
>>> l1 = np.array([1, 2, 3])
>>> l2 = np.array([4, 5, 6])
>>> import pandas as pd
>>> pd.concat([l1, l2], axis=1)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/amedama/.virtualenvs/py37/lib/python3.7/site-packages/pandas/core/reshape/concat.py", line 225, in concat
    copy=copy, sort=sort)
  File "/Users/amedama/.virtualenvs/py37/lib/python3.7/site-packages/pandas/core/reshape/concat.py", line 286, in __init__
    raise TypeError(msg)
TypeError: cannot concatenate object of type "<class 'numpy.ndarray'>"; only pd.Series, pd.DataFrame, and pd.Panel (deprecated) objs are valid

巨大なパイプラインであれば numpy.hstack() をどこかで使っている可能性は十分に考えられる。 とはいえ、一旦試して駄目だったら前述した別の解決策に切り替える、というのもありかもしれない。

いじょう。

Pythonではじめる機械学習 ―scikit-learnで学ぶ特徴量エンジニアリングと機械学習の基礎

Pythonではじめる機械学習 ―scikit-learnで学ぶ特徴量エンジニアリングと機械学習の基礎

scikit-learnとTensorFlowによる実践機械学習

scikit-learnとTensorFlowによる実践機械学習

Pythonデータサイエンスハンドブック ―Jupyter、NumPy、pandas、Matplotlib、scikit-learnを使ったデータ分析、機械学習

Pythonデータサイエンスハンドブック ―Jupyter、NumPy、pandas、Matplotlib、scikit-learnを使ったデータ分析、機械学習

Python: scikit-learn の Pipeline 機能のキャッシュを試す

今回は scikit-learn の Pipeline に存在するキャッシュの機能を試してみる。 scikit-learn の Pipeline は、データセットの前処理・特徴量抽出からモデルの学習・推論までの一連の処理手順をひとまとめにして扱うことのできる機能。 以前に、このブログでも扱ったことがある。

blog.amedama.jp

機械学習のタスクにおいて、データセットの前処理や特徴量抽出には意外と時間がかかる。 大抵の場合、特徴量エンジニアリングを通してモデルで使う特徴量はどんどん増えていく。 そうした状況で、同じ特徴量を毎回ゼロから計算するのは時間がもったいない。 そこで、計算済みの特徴量についてはキャッシュしておきたくなる。 そのニーズを Pipeline で満たすのがキャッシュ機能になる。

今回使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.13.6
BuildVersion:   17G65
$ python -V
Python 3.7.0

下準備

まずは scikit-learn をインストールしておく。

$ pip install scikit-learn

キャッシュ機能を試す

早速だけど、以下が Pipeline でキャッシュを有効にしたサンプルコードになる。 今回の本質ではないけど、この中では Digits データセットを主成分分析して、それをランダムフォレストで識別している。 ポイントは Pipeline クラスのコンストラクタで memory オプションに sklearn.externals.joblib.memory.Memory オブジェクトを渡しているところ。 これで Pipeline のキャッシュが有効になる。 また、キャッシュの効果を確認するために学習と推論にかかった時間を計測している。

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

from contextlib import contextmanager
import time

from sklearn import datasets
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score
from sklearn.pipeline import Pipeline
from sklearn.decomposition import KernelPCA
from sklearn.model_selection import train_test_split
from sklearn.externals.joblib import memory


@contextmanager
def elapsed(section):
    """コードブロックの実行時間を測るためのコンテキストマネージャ"""
    start_time = time.time()
    yield
    end_time = time.time()
    print(f'{section} elapsed: {end_time - start_time} sec')


def main():
    # Digits データセットを読み込む
    dataset = datasets.load_digits()
    X, y = dataset.data, dataset.target

    # ホールドアウト検証用にデータを分割する
    X_train, X_test, y_train, y_test = train_test_split(X, y, shuffle=True, test_size=0.33, random_state=42)

    # 処理のパイプライン
    steps = [
        # 主成分分析
        ('pca', KernelPCA()),
        # ランダムフォレスト
        ('rf', RandomForestClassifier(n_estimators=100))
    ]

    # pipeline-cache ディレクトリにキャッシュデータを保存する
    cache = memory.Memory(location='pipeline-cache', verbose=3)
    pipeline = Pipeline(steps=steps, memory=cache)

    # 学習フェーズにかかる時間を計測する
    with elapsed('fit'):
        pipeline.fit(X_train, y_train)

    # 推論フェーズにかかる時間を計測する
    with elapsed('pred'):
        y_pred = pipeline.predict(X_test)
        acc = accuracy_score(y_pred, y_test)
        print(f'accuracy: {acc}')


if __name__ == '__main__':
    main()

では、上記のサンプルコードを実行してみよう。 最初の実行では、当然ながらキャッシュは効かない。

$ python pipecache.py 
________________________________________________________________________________
[Memory] Calling sklearn.pipeline._fit_transform_one...
_fit_transform_one(KernelPCA(alpha=1.0, coef0=1, copy_X=True, degree=3, eigen_solver='auto',
     fit_inverse_transform=False, gamma=None, kernel='linear',
     kernel_params=None, max_iter=None, n_components=None, n_jobs=None,
     random_state=None, remove_zero_eig=False, tol=0), 
array([[0., ..., 0.],
       ...,
       [0., ..., 0.]]), array([1, ..., 1]), None)
________________________________________________fit_transform_one - 0.4s, 0.0min
fit elapsed: 1.4524390697479248 sec
accuracy: 0.9478114478114478
pred elapsed: 0.05564618110656738 sec

学習には 1.45 秒かかった。 尚、これには前処理・特徴量抽出の時間も含まれている。 今回に関して言えばデータセットを主成分分析してランダムフォレストを学習させるのにかかった時間ということになる。

それでは、もう一度同じように実行してみよう。 二回目はキャッシュが効くので、出力も少し異なったものになる。

$ python pipecache.py
[Memory]0.0s, 0.0min    : Loading _fit_transform_one...
fit elapsed: 1.1166560649871826 sec
accuracy: 0.9562289562289562
pred elapsed: 0.055490970611572266 sec

今度は学習が 1.11 秒と、先ほどよりも短い時間で終わっている。 これがキャッシュの効果といえる。

ちなみに、キャッシュされるものは Pipeline の最終段に位置するモデル (Estimator) に渡される直前のデータになる。 つまり、前処理や特徴量抽出の部分がキャッシュされるのであって、モデルの学習結果自体はキャッシュされない。 これは、以下のソースコードを読むと確認できる。

github.com

もし、学習結果を含めて保存したいなら、学習済みの Pipeline オブジェクトそのものを Pickle で保存しておくのが良い。

blog.amedama.jp

ちなみに、キャッシュに使う joblib の Memory は、やろうと思えば自分で拡張もできそうな雰囲気がある。

github.com

例えば一次キャッシュをローカルのディスクにして、二次キャッシュをクラウド上のオブジェクトストレージにしたような実装もできそうだ。 そうしたものがあれば、どんな環境からでもキャッシュ済みのデータが利用できて時間の節約になりそうに感じた。 GCP の Preemptible VM なんか使ってるときは特に便利そう。

Pythonによるデータ分析入門 第2版 ―NumPy、pandasを使ったデータ処理

Pythonによるデータ分析入門 第2版 ―NumPy、pandasを使ったデータ処理

  • 作者: Wes McKinney,瀬戸山雅人,小林儀匡,滝口開資
  • 出版社/メーカー: オライリージャパン
  • 発売日: 2018/07/26
  • メディア: 単行本(ソフトカバー)
  • この商品を含むブログを見る

Pythonデータサイエンスハンドブック ―Jupyter、NumPy、pandas、Matplotlib、scikit-learnを使ったデータ分析、機械学習

Pythonデータサイエンスハンドブック ―Jupyter、NumPy、pandas、Matplotlib、scikit-learnを使ったデータ分析、機械学習

リモートサーバ上の Docker コンテナで Jupyter Notebook を使う

今回は、以下のエントリの続き。

blog.amedama.jp

上記の記事でやったことを Docker コンテナにしてみる。

使った環境は次の通り。 まずは Docker ホストとして使う Ubuntu 18.04 のマシンから。 こちらも前回と同じように Vagrant で構築している。

vagrant $ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.1 LTS"
vagrant $ uname -r
4.15.0-29-generic

そこに接続するクライアントは次の通り。

client $ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.13.6
BuildVersion:   17G65
client $ ssh -V
OpenSSH_7.6p1, LibreSSL 2.6.2

Docker をインストールする

まずは Docker ホストとして使う Ubuntu に Docker をインストールする。 やり方については以下の公式サイトに記載がある。

docs.docker.com

まずは、すでに Docker 関連のパッケージがインストールされているようであればアンインストールする。

vagrant $ sudo apt-get remove docker docker-engine docker.io

続いて作業に必要なパッケージをインストールしておく。

vagrant $ sudo apt-get update
vagrant $ sudo apt-get -y install \
    apt-transport-https \
    ca-certificates \
    curl \
    software-properties-common

Docker の公式リポジトリを検証するための GPG 鍵をインストールする。

vagrant $ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -

Docker の公式リポジトリを登録する。

vagrant $ sudo add-apt-repository \
    "deb [arch=amd64] https://download.docker.com/linux/ubuntu \
    $(lsb_release -cs) \
    stable"

あとは Docker 本体をインストールするだけ。

vagrant $ sudo apt-get update
vagrant $ sudo apt-get -y install docker-ce

これで Docker を使えるようになった。

vagrant $ sudo docker version
Client:
 Version:           18.06.1-ce
 API version:       1.38
 Go version:        go1.10.3
 Git commit:        e68fc7a
 Built:             Tue Aug 21 17:24:51 2018
 OS/Arch:           linux/amd64
 Experimental:      false

Server:
 Engine:
  Version:          18.06.1-ce
  API version:      1.38 (minimum version 1.12)
  Go version:       go1.10.3
  Git commit:       e68fc7a
  Built:            Tue Aug 21 17:23:15 2018
  OS/Arch:          linux/amd64
  Experimental:     false

Jupyter Notebook が使える Docker イメージを用意する

Jupyter Notebook は公式が Docker のリポジトリを持っている。

https://hub.docker.com/u/jupyter/

元になっている Dockerfile などは以下にあるようだ。

github.com

最初、上記を使ってやろうかと思ったんだけど、ちょっと不都合があってやめることにした。 具体的には Jupyter Notebook が Bind する IP アドレスがループバックアドレスになっている。 もしかすると環境変数なんかで変更できるのかもしれないけど、それだと単なる特定のイメージ紹介になってしまうので。

そこで、自分で Docker イメージを作ることにした。 準備した Dockerfile が以下の通り。

vagrant $ cat << 'EOF' > Dockerfile 
FROM ubuntu:18.04

# Install prerequisite packages
RUN apt-get update \
 && apt-get install -yq --no-install-recommends \
      jupyter-notebook

# User configuration
ARG USERNAME=jupyter
RUN useradd -m -s /bin/bash ${USERNAME}
USER ${USERNAME}

# Jupyter configuration
RUN jupyter notebook --generate-config \
 && mkdir -p /home/${USERNAME}/jupyter-working \
 && sed -i.back \
    -e "s:^#c.NotebookApp.token = .*$:c.NotebookApp.token = u'':" \
    -e "s:^#c.NotebookApp.ip = .*$:c.NotebookApp.ip = '*':" \
    -e "s:^#c.NotebookApp.open_browser = .*$:c.NotebookApp.open_browser = False:" \
    -e "s:^#c.NotebookApp.notebook_dir = .*$:c.NotebookApp.notebook_dir = '/home/${USERNAME}/jupyter-working':" \
    /home/${USERNAME}/.jupyter/jupyter_notebook_config.py

# Expose container ports
EXPOSE 8888

# Boot process
CMD jupyter notebook
EOF

基本的に実行しているコマンドは前回のエントリと同じようなものになっている。 一点違うとすれば、前述した通り Bind する IP アドレスをループバックアドレスではなく任意のもの (*: アスタリスク) になっている。

Docker イメージをビルドする。 タグには example/jupyter という名前をつけた。

vagrant $ sudo docker image build -t example/jupyter .
...
Step 8/8 : CMD jupyter notebook
 ---> Using cache
 ---> 0d2915a8d871
Successfully built 0d2915a8d871
Successfully tagged example/jupyter:latest

あとは上記を起動するだけ・・・の前にアクセス制御をかけておこう。 SSH に使うポート以外は閉じておく。

vagrant $ sudo ufw allow 22
vagrant $ sudo ufw default DENY
vagrant $ yes | sudo ufw enable
vagrant $ sudo ufw status
Status: active

To                         Action      From
--                         ------      ----
22                         ALLOW       Anywhere                  
22 (v6)                    ALLOW       Anywhere (v6)           

ビルドしたイメージをコンテナとして起動する コンテナの TCP/8888 を Docker ホストのループバックアドレスにマッピングする。 こうすることでインターネットからは疎通がない状態でコンテナとポートをマッピングできる。

vagrant $ sudo docker container run --rm -p 127.0.0.1:8888:8888 -it example/jupyter
[I 23:45:00.610 NotebookApp] Writing notebook server cookie secret to /home/jupyter/.local/share/jupyter/runtime/notebook_cookie_secret
[W 23:45:00.778 NotebookApp] WARNING: The notebook server is listening on all IP addresses and not using encryption. This is not recommended.
[W 23:45:00.781 NotebookApp] WARNING: The notebook server is listening on all IP addresses and not using authentication. This is highly insecure and not recommended.
[I 23:45:00.788 NotebookApp] Serving notebooks from local directory: /home/jupyter/jupyter-working
[I 23:45:00.789 NotebookApp] 0 active kernels
[I 23:45:00.790 NotebookApp] The Jupyter Notebook is running at:
[I 23:45:00.792 NotebookApp] http://[all ip addresses on your system]:8888/
[I 23:45:00.793 NotebookApp] Use Control-C to stop this server and shut down all kernels (twice to skip confirmation).

確認すると、たしかにループバックアドレスで Listen している。

vagrant $ ss -tlnp | grep 8888
LISTEN   0         128               127.0.0.1:8888             0.0.0.0:*            

あとは、前回のエントリと同じように SSH Port Forwarding するだけ。

client $ vagrant ssh-config > ssh.config
client $ ssh -L 8888:localhost:8888 -F ssh.config default

ブラウザでクライアントのループバックアドレスの 8888 ポートを開いてみよう。

client $ open http://localhost:8888

いつもの画面が見えるはず。

f:id:momijiame:20181014022447p:plain

Docker Compose を使う場合

一応 Docker Compose を使うパターンについても紹介しておく。 Docker Compose を使えばコンテナの起動オプションなんかを覚えておく必要がなくなる。

まずは Docker Compose をインストールする。

vagrant $ sudo apt-get -y install docker-compose
vagrant $ docker-compose version
docker-compose version 1.17.1, build unknown
docker-py version: 2.5.1
CPython version: 2.7.15rc1
OpenSSL version: OpenSSL 1.1.0g  2 Nov 2017

続いて Docker Compose の設定ファイルを用意する。

vagrant $ cat << 'EOF' > docker-compose.yml 
version: "3"
services:
  jupyter:
    build: .
    image: example/jupyter
    ports:
      - "127.0.0.1:8888:8888"
EOF

あとは、上記の設定ファイルを使ってイメージをビルドする。 さっきビルドしたキャッシュが残っていればすぐに終わるはず。

vagrant $ sudo docker-compose build

設定ファイルを元にコンテナを起動する。

vagrant $ sudo docker-compose up jupyter

起動したコンテナの Jupyter Notebook を使う方法については Docker をそのまま使うときと同じ。

めでたしめでたし。

Docker/Kubernetes 実践コンテナ開発入門

Docker/Kubernetes 実践コンテナ開発入門

リモートサーバの Jupyter Notebook を SSH Port Forwarding 経由で使う

一般的に Jupyter Notebook はローカルの環境にインストールして使うことが多い。 ただ、ローカルの環境は計算資源が乏しい場合もある。 そんなときは IaaS などリモートにあるサーバで Jupyter Notebook を使いたい場面が存在する。 ただ、セキュリティのことを考えると Jupyter Notebook の Web UI をインターネットに晒したくはない。

そこで、今回は SSH Port Forwarding を使って Web UI をインターネットに晒すことなく使う方法について書く。 このやり方ならリモートサーバに SSH でログインしたユーザだけが Jupyter Notebook を使えるようになる。 また、Web UI との通信も SSH 経由になるので HTTP over SSL/TLS (HTTPS) を使わなくても盗聴のリスクを下げられる。

リモートサーバを想定した環境は次の通り。 話を単純にするために環境は Vagrant で作ってある。

vagrant $ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.1 LTS"
vagrant $ uname -r
4.15.0-29-generic

そこに接続するクライアントの環境は次の通り。

client $ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.13.6
BuildVersion:   17G65
client $ ssh -V
OpenSSH_7.6p1, LibreSSL 2.6.2

必要なパッケージをインストールする

ここからは、すでにリモートの Ubuntu マシンに SSH でログインしている前提で話を進める。

まずは必要なパッケージをインストールする。 ログインするたびに Jupyter Notebook を起動するコマンドを入力するのも面倒なので、最終的に Supervisord でデーモン化することにした。

vagrant $ sudo apt-get update
vagrant $ sudo apt-get -y install jupyter-notebook supervisor

今回は OS のパッケージ管理システム経由でインストールしてるけど pip を使うとかはお好みで。

まずはサクッと試す

ひとまず手っ取り早く今回やることの本質を示す。

最初にリモートサーバ上で Jupyter Notebook を起動する。 これで TCP/8888 で Jupyter Notebook の Web UI が動く。

vagrant $ jupyter notebook

ターミナルに Web UI のアクセストークンが表示されるのでメモしておこう。

続いて、クライアントの別のターミナルを開いて、改めてリモートサーバに SSH でログインする。 このとき SSH Port Forwarding を使って、リモートサーバの TCP/8888 をローカルホストのポートにマッピングする。

client $ ssh -L 8888:localhost:8888 <username>@<remotehost>

今回は Vagrant の環境を使っているのでこんな感じ。 恒久的に設定を入れたいなら Vagrantfile を編集する。

client $ vagrant ssh-config > ssh.config
client $ ssh -L 8888:localhost:8888 -F ssh.config default

あとは、クライアントのブラウザでローカルホストにマッピングしたポート番号を開くだけ。

client $ open http://localhost:8888

すると、Jupyter Notebook の Web UI でアクセストークンを入力する画面が表示される。 先ほど Jupyter Notebook を起動するときにターミナルに表示されたトークンを入力しよう。

f:id:momijiame:20181015080902p:plain

これで、いつもの見慣れた Web UI が表示されるはず。 あとは使うだけ。

f:id:momijiame:20181014022447p:plain

以上で、今回やることの本質は示せた。

ただ、上記の操作は毎回やるには結構めんどくさいしセキュリティをあまり考慮していない。 そこで、ここからは運用をできるだけ楽に、そしてセキュアな環境を手に入れるべく手順を記載していく。

以降の手順を試すときは、一旦先ほど起動した Jupyter Notebook は停止しておこう。

アクセス制御をかける

リモートサーバを想定しているので、念のため必要なポート以外はファイアウォールを使って閉じておく。

SSH に使うポートだけを残して、それ以外は全て閉じる。 SSH に使うポート番号を 22 以外にしているときは、適宜読み替える感じで。

vagrant $ sudo ufw allow 22
vagrant $ sudo ufw default DENY
vagrant $ yes | sudo ufw enable
vagrant $ sudo ufw status
Status: active

To                         Action      From
--                         ------      ----
22                         ALLOW       Anywhere                  
22 (v6)                    ALLOW       Anywhere (v6)             

ファイアウォールの設定を変更するときはリモートサーバから追い出されないように注意しよう。

Jupyter Notebook を起動するユーザを追加する

若干好みの問題にも近いけど、念のため Jupyter Notebook を起動する専用のユーザを追加しておく。

vagrant $ sudo useradd -m -s $SHELL jupyter

Jupyter Notebook を設定する

ここからは Jupyter Notebook を設定していく。

まずは先ほど作ったユーザにログインする。

vagrant $ sudo su - jupyter

続いて、設定ファイルを生成する。

jupyter $ jupyter notebook --generate-config
Writing default config to: /home/jupyter/.jupyter/jupyter_notebook_config.py

Jupyter Notebook の作業ディレクトリを用意する。

jupyter $ mkdir -p /home/$(whoami)/jupyter-working

設定ファイルを編集する。

jupyter $ sed -i.back \
  -e "s:^#c.NotebookApp.token = .*$:c.NotebookApp.token = u'':" \
  -e "s:^#c.NotebookApp.ip = .*$:c.NotebookApp.ip = 'localhost':" \
  -e "s:^#c.NotebookApp.open_browser = .*$:c.NotebookApp.open_browser = False:" \
  -e "s:^#c.NotebookApp.notebook_dir = .*$:c.NotebookApp.notebook_dir = '/home/$(whoami)/jupyter-working':" \
  /home/$(whoami)/.jupyter/jupyter_notebook_config.py
jupyter $ cat ~/.jupyter/jupyter_notebook_config.py | sed -e "/^#/d" -e "/^$/d"
c.NotebookApp.ip = 'localhost'
c.NotebookApp.notebook_dir = '/home/jupyter/jupyter-working'
c.NotebookApp.open_browser = False
c.NotebookApp.token = u''

それぞれの設定の内容や意図としては以下のような感じ。

  • c.NotebookApp.ip = 'localhost'
    • Jupyter Notebook が Listen するアドレスをループバックアドレスにする
    • もしファイアウォールがなくてもインターネットからは Jupyter Notebook の WebUI に疎通がなくなる
  • c.NotebookApp.notebook_dir = '/home/jupyter/jupyter-working'
    • Jupyter Notebook の作業ディレクトリを専用ユーザのディレクトリにする
    • 仮に Web UI が不正アクセスを受けたときにも影響範囲を小さくとどめる (気休め程度)
  • c.NotebookApp.open_browser = False
    • 起動時にブラウザを開く動作を抑制する
    • ローカル環境ではないので起動するときにブラウザを起動する必要はない
  • c.NotebookApp.token = u''
    • Jupyter Notebook の Web UI にビルトインで備わっている認証を使わない
    • 認証は SSH によるログインで担保する場合の設定 (心配なときは後述する共通パスワードなどを設定する)

(オプション) Jupyter Notebook の Web UI に共通パスワードをかける

SSH のログイン以外にも認証をかけたいときは、例えばシンプルなものだと共通パスワードが設定できる。

Jupyter Notebook の Web UI に共通パスワードをかけるには jupyter notebook password コマンドを実行する。

jupyter $ jupyter notebook password
Enter password: 
Verify password: 
[NotebookPasswordApp] Wrote hashed password to /home/jupyter/.jupyter/jupyter_notebook_config.json

すると、ソルト付きの暗号化されたパスワードが設定ファイルとしてできる。

jupyter $ cat ~/.jupyter/jupyter_notebook_config.json 
{
  "NotebookApp": {
    "password": "sha1:217911554b0b:f2fa9cd9f336951c335bdaa06a6c16eb6286c192"
  }
}

上記のやり方だとハッシュのアルゴリズムが SHA1 固定っぽい。 もし、より頑丈なものが使いたいときは次のように Python のインタプリタ経由で生成する。

jupyter $ python3
Python 3.6.6 (default, Sep 12 2018, 18:26:19) 
[GCC 8.0.1 20180414 (experimental) [trunk revision 259383]] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from notebook.auth import passwd
>>> passwd('jupyter-server-password', algorithm='sha512')
'sha512:d197670d2987:19bb2eedfc6fde56f1a9fc04d403999c3f03a99af368e528f45ee9a68f01a7c5f07e375bd34ec176d1c66a0f2e8ef7615ebcf9e524a23ace5ab6dd5a930398d4'

生成した暗号化済みパスワードを、次のような形で Jupyter Notebook の設定ファイルに入力すれば良い。

c.NotebookApp.password_required = True
c.NotebookApp.password = u'sha512:d197670d2987:19bb2eedfc6fde56f1a9fc04d403999c3f03a99af368e528f45ee9a68f01a7c5f07e375bd34ec176d1c66a0f2e8ef7615ebcf9e524a23ace5ab6dd5a930398d4'

上記の共通パスワード方式を含む Jupyter Notebook の認証周りについては以下の公式ドキュメントを参照のこと。

Running a notebook server — Jupyter Notebook 5.7.0 documentation

Jupyter Notebook を Supervisord 経由で起動する

続いては Jupyter Notebook をデーモン化する設定に入る。

一旦、元の管理者権限をもったユーザに戻る。

jupyter $ exit
logout

Supervisord の設定ファイルを用意する。

vagrant $ cat << 'EOF' | sudo tee /etc/supervisor/conf.d/jupyter.conf > /dev/null
[program:jupyter]
command=jupyter notebook
user=jupyter
stdout_logfile=/var/log/supervisor/jupyter.log
redirect_stderr=true
autostart=true
autorestart=true
EOF

Supervisord を起動する。

vagrant $ sudo systemctl enable supervisor
vagrant $ sudo systemctl reload supervisor

ちゃんと Jupyter Notebook が起動しているかを確認する。

vagrant $ ps auxww | grep [j]upyter
jupyter   4689 27.0  5.4 183560 55088 ?        S    16:31   0:01 /usr/bin/python3 /usr/bin/jupyter-notebook
vagrant $ ss -tlnp | grep :8888
LISTEN   0         128               127.0.0.1:8888             0.0.0.0:*       
LISTEN   0         128                   [::1]:8888                [::]:*       

もし、上手く立ち上がっていないときはログから原因を調べよう。

vagrant $ sudo tail /var/log/supervisor/supervisord.log 
vagrant $ sudo tail /var/log/supervisor/jupyter.log 

(オプション) ログインシェルを無効化する

もし Jupyter Notebook 専用に作ったユーザをシェル経由で操作するつもりがなければ、ログインシェルを無効化しておく。

vagrant $ sudo usermod -s /usr/sbin/nologin jupyter

こうするとシェル経由でユーザにログインできなくなる。

vagrant $ grep jupyter /etc/passwd
jupyter:x:1001:1001::/home/jupyter:/usr/sbin/nologin
vagrant $ sudo su - jupyter
This account is currently not available.

デーモンプログラムを起動するユーザは、不正アクセスを受けた場合の影響を小さくする意図でこうすることが多い。

SSH Port Forwarding 経由で Jupyter Notebook の Web UI にアクセスする

ここまでで、リモートサーバ上の Jupyter Notebook の設定は終わった。

一旦リモートサーバから SSH でログアウトする。

vagrant $ exit

改めて SSH Port Forwarding を有効にしてリモートサーバにログインする。 このときリモートサーバの TCP/8888 ポートを、ローカルホストのポートにマッピングする。 ユーザ名やホスト名は適宜読み替える。

client $ ssh -L 8888:localhost:8888 <username>@<remotehost>

今回は Vagrant の環境を使っているので、こんな感じで。

client $ vagrant ssh-config > ssh.config
client $ ssh -L 8888:localhost:8888 -F ssh.config default

あとは、クライアントのブラウザでローカルホストにマッピングしたポート番号を開く。

client $ open http://localhost:8888

すると、見覚えのある Web UI が表示される。 オプションの共通パスワード認証を使っていないのであれば、いきなりいつもの画面になるはず。

f:id:momijiame:20181014022447p:plain

あとは、もしポータビリティとかを考えるのであればお好みで Docker イメージとかにする感じで。

めでたしめでたし。

Python: デコレータについて

Python の特徴的な構文の一つにデコレータがある。 便利な機能なんだけど、最初はとっつきにくいかもしれない。 そこで、今回はデコレータについて一通り色々と書いてみる。 先に断っておくと、とても長い。

これを読むと、以下が分かる。

  • デコレータの本質
    • デコレータはシンタックスシュガー (糖衣構文) に過ぎない
  • デコレータの作り方
    • 引数を取るデコレータと取らないデコレータ
  • デコレータの用途
    • 用途はラッピングとマーキングの二つに大別できる
  • デコレータの種類
    • デコレータは関数、メソッド、インスタンスで作れる
  • デコレータの対象
    • デコレートできるのは関数、メソッド以外にクラスもある

今回使った環境は次の通り。 尚、紹介するコードの中には、一部に Python 3 以降でないと動作しないものが含まれている。

$ python -V
Python 3.6.6

デコレータについて

まずはデコレータのおさらかいから。 デコレータは、その名の通りオブジェクトをデコレーション (装飾) するための機能。 構文としては、デコレートしたいオブジェクトの前で @ を先頭につけて使う。 デコレートできるオブジェクトの種類は、関数、メソッド、クラスがサポートされている。

標準モジュールにも、組み込みでいくつかのデコレータがある。 その中の一つを見てみよう。 以下のサンプルコードでは functools モジュールの lru_cache というデコレータを使っている。 このデコレータを使うと、デコレートした関数を簡単にメモ化できる。 メモ化というのは、ようするに関数の戻り値をキャッシュすること。 サンプルコードでは足し算をする add() という関数をメモ化している。

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

from functools import lru_cache


# 関数をメモ化するデコレータ
@lru_cache()
def add(a, b):
    # 実際に関数が処理された場合を区別するための出力
    print('calculate')
    return a + b


def main():
    # 同じ引数で 2 回呼び出す
    print(add(1, 2))
    print(add(1, 2))


if __name__ == '__main__':
    main()

ポイントは add() 関数の中で calculate という文字列を出力しているところ。 これで、実際に関数が呼び出されたのか、それともキャッシュされた値が返ったのか区別できる。

それでは、上記を保存して実行してみよう。 サンプルコードでは同じ引数 (1, 2) を使って add() 関数を 2 回呼び出している。

$ python cache.py
calculate
3
3

実行しても calculate が 1 回しか出力されない。 つまり 2 回目の呼び出しではキャッシュされた値が返っていることが分かる。 見事に @lru_cache デコレータが機能しているようだ。

デコレータの本質

おさらいが終わったところで、早速本題に入る。 デコレータという機能は、実はシンタックスシュガー (糖衣構文) に過ぎない。 シンタックスシュガーというのは、プログラミング言語において、ある書き方に対して別の書き方ができるようにしたもの。 デコレータがシンタックスシュガーということは、つまり同じ内容はデコレータを使わなくても書けるということ。

先ほどのサンプルコードを、デコレータを使わない形に直してみよう。 つまり、足し算をする add() 関数をデコレータを使わずに functools.lru_cache でメモ化している。

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

from functools import lru_cache


# デコレータ構文は使っていない
def add(a, b):
    print('calculate')
    return a + b


# デコレータの代わりになる書き方
# デコレータ構文は、以下を書きやすくしたシンタックスシュガーにすぎない
add = lru_cache()(add)


def main():
    # 同じ引数で 2 回呼び出す
    print(add(1, 2))
    print(add(1, 2))


if __name__ == '__main__':
    main()

上記を保存して実行してみよう。

$ python cache.py
calculate
3
3

ちゃんとメモ化が動作していることが分かる。

先ほどのサンプルコードでは functools.lru_cache をデコレータとして使っていない。 代わりに、次のようなコードが登場している。 これは lru_cache() を通して add() 関数を代入し直している。 ようするに add() 関数の内容が lru_cache() の返り値で上書きされることになる。

add = lru_cache()(add)

つまり、最初のコードで登場した以下と上記は本質的に等価ということ。

@lru_cache()
def add(a, b):

これは理解する上で重要なポイントで、デコレータを使って書かれたコードは、必ず使わずに書くこともできる。

デコレータの作り方

続いてはデコレータの作り方を見ていく。 前述したように、デコレータは単なるシンタックスシュガーで、やっていることは単なる返り値を使った上書きだった。 それさえ分かっていればデコレータの作り方は理解しやすい。

例えば、関数をデコレートするデコレータについて考えてみよう。 これまで理解した内容から考えれば「関数を受け取って、代わりとなる関数を返す」ものを作れば良い。

以下のサンプルコードでは deco という名前でデコレータを作っている。 見て分かる通り、普通の関数と見た目は何ら変わらない。 つまり deco はデコレータとして動作する関数、ということになる。 デコレータとして動作するために、引数 func という名前で関数を受け取って、代わりとなる wrapper() という関数の参照を返している。

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


def deco(func):
    """デコレートした関数の前後に処理を挟み込む自作デコレータ"""

    def wrapper(*args, **kwargs):
        """本来の関数の代わりに返される関数"""
        print('before')  # 本来の関数が呼び出される前に実行される処理
        result = func(*args, **kwargs)  # 本来の関数の呼び出し
        print('after')  # 本来の関数が呼び出された後に実行される処理
        return result  # 本来の関数の返り値を返す

    # 引数で関数を受け取って、代わりに別の関数を返す
    return wrapper


# @deco デコレータで greet() 関数をデコレートしている
@deco
def greet():
    """文字列を書き出すだけの関数"""
    print('Hello, World!')


def main():
    # デコレータでデコレートされた関数を呼び出す
    greet()


if __name__ == '__main__':
    main()

このデコレータは、本来の関数の呼び出しの前後に文字列の出力を挟み込むものになっている。 @deco を使ってデコレートする対象は greet() という関数で、内容は文字列を出力するだけ。

上記を保存して実行してみよう。 greet() 関数が出力する文字列の前後に @deco で追加した処理が挟み込まれていることが分かる。

$ python deco.py
before
Hello, World!
after

念のため、デコレータを使わないパターンも見ておこう。 繰り返しになるけど、デコレータはただのシンタックスシュガーなので、必ず使わない形にも直せる。 デコレータを使わない形にすれば、やっていることがよく分かる。

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


def deco(func):
    """デコレートした関数の前後に処理を挟み込む自作デコレータ"""

    def wrapper(*args, **kwargs):
        """本来の関数の代わりに呼び出される関数"""
        print('before')  # 本来の関数が呼び出される前に実行される処理
        result = func(*args, **kwargs)  # 本来の関数の呼び出し
        print('after')  # 本来の関数が呼び出された後に実行される処理
        return result  # 本来の関すが返した結果を返す

    # 引数で関数を受け取って、別の関数を返している
    return wrapper


def greet():
    """文字列を書き出すだけの関数"""
    print('Hello, World!')


# デコレータは単なるシンタックスシュガーに過ぎないため、
# 必ず以下のような代入文に置き換えることができる
greet = deco(greet)


def main():
    # デコレータでデコレートされた関数を呼び出す
    greet()


if __name__ == '__main__':
    main()

ようするに deco() 関数が greet() 関数の参照を受け取って、代わりに wrapper() 関数の参照を返しているだけ。

上記を保存して実行してみよう。

$ python deco.py  
before
Hello, World!
after

ちゃんと動作している。

引数を受け取るデコレータ

先ほどのサンプルコードで登場した deco デコレータは lru_cache デコレータと違うところが一つあった。 それは、デコレータとして使うとき後ろにカッコがあるかないか。

lru_cache の例を思い出すと、後ろにカッコがついていた。

@lru_cache()
def add(a, b):

それに対して deco の例では、後ろにカッコがない。

@deco
def greet():

上記の違いは、デコレータが引数を受け取るか受け取らないか。 例えば lru_cache であれば、キャッシュする数の上限を設定するために maxsize というオプションがあったりするため。 つまり、こんな感じで書ける。

@lru_cache(maxsize=32)
def add(a, b):

先ほどの deco を引数を受け取れるように書き換えてみよう。 次のサンプルコードでは deco デコレータが本来の処理の前後に挿入するメッセージを引数で指定できるようにしている。 コード上の変化としては、先ほどよりも deco のネストが増していることが分かる。 引数の受け取らないパターンで deco という名前だった関数が今度は wrapper という名前になって、新しい deco がそれを返している。

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


def deco(before_msg='before', after_msg='after'):
    """引数を受け取るデコレータ (ネストが一段増える)"""

    def wrapper(func):
        def _wrapper(*args, **kwargs):
            print(before_msg)
            result = func(*args, **kwargs)
            print(after_msg)
            return result
        return _wrapper

    return wrapper


# デコレータにカッコがあって引数を受け取っている
@deco('mae', 'ato')
def greet():
    """文字列を書き出すだけの関数"""
    print('Hello, World!')


def main():
    # デコレータでデコレートされた関数を呼び出す
    greet()


if __name__ == '__main__':
    main()

上記を保存して実行してみよう。 今度はデコレータを使うときに指定した引数にもとづいて前後の出力が変化している。

$ python decoargs.py 
mae
Hello, World!
ato

引数を取るパターンでは、取らないパターンよりも何をやっているのかが分かりにくいかもしれない。 これも、デコレータを使わない形に書き直すと理解しやすくなる。 以下のサンプルコードは、同じ内容をデコレータを使わない形に直してある。

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


def deco(before_msg='before', after_msg='after'):
    """引数を受け取るデコレータ (ネストが一段増える)"""

    def wrapper(func):
        def _wrapper(*args, **kwargs):
            print(before_msg)
            result = func(*args, **kwargs)
            print(after_msg)
            return result
        return _wrapper

    return wrapper


def greet():
    """文字列を書き出すだけの関数"""
    print('Hello, World!')


# デコレータ構文を使わずに書いたパターン
greet = deco('mae', 'ato')(greet)
# より冗長に、分かりやすく書くと以下のようになる
# wrap_func = deco('mae', 'ato')
# greet = wrap_func(greet)


def main():
    greet()


if __name__ == '__main__':
    main()

上記を見ると、関数を上書きする工程が二段階に分かれていることが見て取れる。 冗長に分かりやすく書いたパターンでは、まず deco() が関数を上書きするのに使う関数 (変数 wrap_func) を返している。 そして、その関数を使って対象の関数 greet() を上書きしている。 これが引数を受け取るデコレータの動作原理ということ。

以降は、デコレータを使わずに書いたパターンを示すことは基本的には省略する。 しかし、デコレータが単なるシンタックスシュガーで、使わないパターンに必ず書き直せるという点は意識しながら読むと理解が深まると思う。

デコレータの用途

デコレータの基本が分かったところで、次は用途について考えてみる。 デコレータの用途は、大きく分けて「ラッピング」と「マーキング」の二つがある。 これまで紹介してきた内容は、用途が全て前者の「ラッピング」だった。

ラッピング

それでは、まずラッピングの用途から見ていこう。 これは、これまでにも紹介してきた通り元の関数などをデコレータを通して上書きするというもの。 以下のサンプルコードでは関数の返り値に 2 倍をかけて返すデコレータ double を定義している。

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


def double(func):
    """デコレートした関数の返り値を 2 倍にするデコレータ"""
    def wrapper(*args, **kwargs):
        # 本来の関数の返り値に 2 をかけて返す
        return func(*args, **kwargs) * 2
    return wrapper


# 返り値を倍にするデコレータをつける
@double
def add(a, b):
    """足し算をする関数"""
    return a + b


def main():
    # 1 + 2 を計算すると...?
    print('1 + 2 =', add(1, 2))  # 1 + 2 = 3 ... 6!!


if __name__ == '__main__':
    main()

上記を保存して実行してみよう。 @double デコレータによってデコレートされた add() 関数は、計算結果を倍にして返すように上書きされる。

$ python wrapping.py 
1 + 2 = 6

ラッピング用途での注意点

ちなみに、ラッピング用途でデコレータを使うときは一つ注意点がある。 それは、ラッピング用途のデコレータが、デコレートしたオブジェクトを代わりの何かで上書きするという性質に由来している。

以下のサンプルコードを見てほしい。 このコードでは、デコレートされた関数 add() の名前を __name__ プロパティから取得して出力している。 もちろん、本来の意図としては add という文字列が出力されてほしいはず。

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


def double(func):
    """デコレートした関数の返り値を 2 倍にするデコレータ"""
    def wrapper(*args, **kwargs):
        # 本来の関数の返り値に 2 をかけて返す
        return func(*args, **kwargs) * 2
    return wrapper


# 返り値を倍にするデコレータをつける
@double
def add(a, b):
    """足し算をする関数"""
    return a + b


def main():
    # add() 関数の名前は?
    print('add()\'s name:', add.__name__)


if __name__ == '__main__':
    main()

上記を保存して実行してみよう。

$ python name.py 
add()'s name: wrapper

なんと、残念ながら wrapper という出力になってしまった。

ここまで読んできていれば、理由は何となく想像がつくと思う。 ようするにデコレータを通して add() 関数は wrapper() 関数に置き換えられてしまっている。 そのため add() 関数のつもりで扱うと、実際には置き換えられた関数だった、ということが起こる。

この問題は functools.wraps を使うと解決できる。 以下のサンプルコードでは、デコレータが返す代わりの関数を functools.wraps でデコレートしている。

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

from functools import wraps


def double(func):
    """デコレートした関数の返り値を 2 倍にするデコレータ"""
    # デコレータが返す関数を functools.wraps でデコレートする
    @wraps(func)
    def wrapper(*args, **kwargs):
        # 本来の関数の返り値に 2 をかけて返す
        return func(*args, **kwargs) * 2
    return wrapper


# 返り値を倍にするデコレータをつける
@double
def add(a, b):
    """足し算をする関数"""
    return a + b


def main():
    # add() 関数の名前は?
    print('add()\'s name:', add.__name__)


if __name__ == '__main__':
    main()

上記を保存して実行してみよう。 今度はちゃんと add という名前が出力された。

$ python name.py 
add()'s name: add

このように functools.wraps を使うと、置き換える関数が元の関数の性質を引き継げる。

マーキング

もう一つの用途としてマーキングを見てみよう。 この用途では、デコレータは受け取ったオブジェクトをそのまま返す。 ただし、受け取ったオブジェクトを何処かに記録しておいて、それを後から利用することになる。 以下のサンプルコードでは @register デコレータでデコレートした関数は _MARKED_FUNCTIONS というリストに保存される。 そして、保存されたリストから関数を呼び出している。

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


# デコレータでデコレートした関数を入れるリスト
_MARKED_FUNCTIONS = []


def register(func):
    """関数を登録するデコレータ"""

    # デコレートした関数をリストに追加する
    _MARKED_FUNCTIONS.append(func)

    # 受け取った関数をそのまま返す
    return func


# デコレータを使って、それぞれの関数をマーキングしていく
@register
def greet_morning():
    print('Good morning!')


@register
def greet_afternoon():
    print('Good afternoon!')


@register
def greet_evening():
    print('Good evening!')


def main():
    # リストに追加された関数を確認する
    print(_MARKED_FUNCTIONS)
    # 先頭の一つを呼び出してみる
    _MARKED_FUNCTIONS[0]()


if __name__ == '__main__':
    main()

上記を保存して実行してみよう。 デコレートした関数がリストに保存されて、それを後から呼び出すことができている。

$ python marking.py 
[<function greet_morning at 0x10b4d1598>, <function greet_afternoon at 0x10b59b378>, <function greet_evening at 0x10b59b400>]
Good morning!

マーキング用途のデコレータは、典型的にはイベントハンドラで用いられる。 例えば Web アプリケーションフレームワークの Flask は、マーキングした関数がクライアントからのアクセスを捌くハンドラになる。

もちろん、上記のコードもデコレータを使わない形に直せる。

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


# デコレータでデコレートした関数を入れるリスト
_MARKED_FUNCTIONS = []


def register(func):
    """関数を登録するデコレータ"""

    # デコレートした関数をリストに追加する
    _MARKED_FUNCTIONS.append(func)

    # 受け取った関数をそのまま返す
    return func


# デコレータを使って、それぞれの関数をマーキングしていく
def greet_morning():
    print('Good morning!')


def greet_afternoon():
    print('Good afternoon!')


def greet_evening():
    print('Good evening!')


# デコレータを使わずに書き換えたパターン
greet_morning = register(greet_morning)
greet_afternoon = register(greet_afternoon)
greet_evening = register(greet_evening)


def main():
    # リストに追加された関数を確認する
    print(_MARKED_FUNCTIONS)
    # 先頭の一つを呼び出してみる
    _MARKED_FUNCTIONS[0]()


if __name__ == '__main__':
    main()

上記を見て分かる通り、デコレータはモジュールが読み込まれるタイミングで解釈される。 そのため、あらかじめデコレートされた関数の情報を収集するようなこともできるというわけ。

関数以外で作るデコレータ

ここまで紹介してきたデコレータは、全て関数を使って実装されていた。 しかし、デコレータはそれ以外を使った作り方もある。

メソッドで作るデコレータ

例えば、以下のサンプルコードを見てほしい。 ここでは Decorator クラスの deco() というインスタンスメソッドでデコレータを実装している。 内容は最初に自作した処理の前後に出力を挿入するものだ。

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


class Decorator(object):

    def deco(self, func):
        """デコレータとして機能するメソッド"""

        def wrapper(*args, **kwargs):
            print('before')
            result = func(*args, **kwargs)
            print('after')
            return result

        return wrapper


# クラスをインスタンス化する
instance = Decorator()


# インスタンスメソッドで作ったデコレータ
@instance.deco
def greet():
    print('Hello, World!')


def main():
    greet()


if __name__ == '__main__':
    main()

上記を保存して実行してみよう。 ちゃんとデコレータとして機能していることが分かる。

$ python instance.py 
before
Hello, World!
after

インスタンスメソッドとしてデコレータを実装すると嬉しいのは、インスタンスごとにコンテキストを持たせられるところ。 以下のサンプルコードにおいて japaneseenglish という二つのインスタンスは、それぞれ異なる引数で初期化されている。 そして、それぞれが別の関数をデコレートしている。

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


class Decorator(object):

    def __init__(self, before_msg='before', after_msg='after'):
        # 前後に挿入するメッセージ
        self.before_msg = before_msg
        self.after_msg = after_msg

    def deco(self, func):
        """デコレータとして機能するメソッド"""

        def wrapper(*args, **kwargs):
            print(self.before_msg)
            result = func(*args, **kwargs)
            print(self.after_msg)
            return result

        return wrapper


# インスタンスごとにコンテキストが持てるのがポイント
japanese = Decorator('mae', 'ato')
english = Decorator('before', 'after')


@japanese.deco
def greet_morning():
    print('Good morning')


@english.deco
def greet_afternoon():
    print('Good afternoon')


def main():
    greet_morning()
    greet_afternoon()


if __name__ == '__main__':
    main()

上記を保存して実行してみよう。 すると、初期化したときの引数によってデコレートされた結果が異なっていることが分かる。

$ python context.py 
mae
Good morning
ato
before
Good afternoon
after

このようにインスタンスメソッドでデコレータを作ると、インスタンスにコンテキストをもたせられるというメリットがある。

呼び出し可能オブジェクトで作るデコレータ

メソッドで作るデコレータの変わり種として、呼び出し可能オブジェクトを使うパターンも考えられる。 これはクラスに特殊メソッド __call__() を実装するというもの。 この特殊メソッドを実装すると、インスタンス自体を関数みたいに実行できるようになる。 で、その特殊メソッド __call__() がデコレータとして動作するとしたら?という。

以下のサンプルコードでは特殊メソッド __call__() がデコレータとして動作する。 そのためインスタンス化したオブジェクトの instance が、そのままデコレータとして使えている。

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


class Decorator(object):

    def __call__(self, func):
        """呼び出し可能オブジェクトを作るための特殊メソッド

        デコレータとして動作する"""

        def wrapper(*args, **kwargs):
            print('before')
            result = func(*args, **kwargs)
            print('after')
            return result

        return wrapper


# クラスをインスタンス化する
instance = Decorator()


# 呼び出し可能オブジェクトなので
# インスタンスそのものがデコレータとして使える
@instance
def greet():
    print('Hello, World!')


def main():
    greet()


if __name__ == '__main__':
    main()

実行結果はこれまでと変わらないので省略する。

デコレートする対象

ここまでの例では、デコレートする対象は全て関数だった。 しかし、デコレータは関数以外もデコレートすることができる。

メソッドをデコレートする

以下のサンプルコードでは、おなじみの @deco デコレータがインスタンスメソッドをデコレートしている。

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


def deco(func):
    """デコレートした関数の前後に処理を挟み込む自作デコレータ"""

    def wrapper(*args, **kwargs):
        """本来の関数の代わりに呼び出される関数"""
        print('before')  # 本来の関数が呼び出される前に実行される処理
        result = func(*args, **kwargs)  # 本来の関数の呼び出し
        print('after')  # 本来の関数が呼び出された後に実行される処理
        return result  # 本来の関すが返した結果を返す

    # 引数で関数を受け取って、別の関数を返している
    return wrapper


class MyClass(object):

    # インスタンスメソッドをデコレートする
    @deco
    def greet(self):
        print('Hello, World!')


def main():
    # クラスをインスタンス化する
    obj = MyClass()
    # デコレートされたメソッドを呼び出す
    obj.greet()


if __name__ == '__main__':
    main()

上記を保存して実行してみよう。 ちゃんと動くことが分かる。

$ python method.py 
before
Hello, World!
after

ちなみにメソッドをデコレートするときの注意点が一つある。 それは、置き換える関数の第一引数にインスタンスオブジェクトを受け取れるようにすること。 Python のメソッドは、典型的には self という名前で第一引数にインスタンスを受け取る。 置き換える関数が、この一つ余分な引数を受け取れるようになっていないと動作しない。 先ほどのサンプルコードでは引数を (*args, **kwargs) という任意の形で受け取れるようにしていたので、特に気にすることはなかった。

クラスをデコレートする

デコレータはクラスをデコレートすることもできる。

以下のサンプルコードでは @deco デコレータが MyClass をデコレートしている。 @deco デコレータでは、クラスが持っているメソッドを上書きして回っている。 上書きされたメソッドは、呼び出されたタイミングでその旨が出力されるようになる。

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

import inspect


def deco(cls):
    """クラスオブジェクトを引数に取るデコレータ

    XXX: Python 3 でしか動作しない"""

    # クラスからメソッド一覧を取得する
    methods = inspect.getmembers(cls, predicate=inspect.isfunction)

    # クラスのメソッドを上書きして回る
    for method_name, method_object in methods:
        wrapped_method = logging_wrapper(method_object)
        setattr(cls, method_name, wrapped_method)

    # 受け取ったクラスはそのまま返す
    return cls


def logging_wrapper(func):
    """関数の呼び出しを記録するラッパー"""
    def _wrapper(*args, **kwargs):
        # 本当は logging モジュールを使うべき
        print('call:', func.__name__)
        result = func(*args, **kwargs)
        return result
    return _wrapper


# クラスをデコレータでデコレートする
@deco
class MyClass(object):

    def greet_morning(self):
        print('Good morning!')

    def greet_afternoon(self):
        print('Good afternoon!')

    def greet_evening(self):
        print('Good evening!')


def main():
    # デコレータでデコレートされたクラスをインスタンス化する
    o = MyClass()
    # いくつかのメソッドを呼び出す (実はデコレータによって上書き済み)
    o.greet_morning()
    o.greet_afternoon()
    o.greet_evening()


if __name__ == '__main__':
    main()

上記を保存して実行してみよう。 上書きされたメソッドによって、呼び出しが記録されていることが分かる。

$ python clsdeco.py 
call: greet_morning
Good morning!
call: greet_afternoon
Good afternoon!
call: greet_evening
Good evening!

まとめ

今回扱った内容は以下の通り。

  • デコレータの本質
    • デコレータはシンタックスシュガー (糖衣構文) に過ぎない
  • デコレータの作り方
    • 引数を取るデコレータと取らないデコレータ
  • デコレータの用途
    • 用途はラッピングとマーキングの二つに大別できる
  • デコレータの種類
    • デコレータは関数、メソッド、インスタンスで作れる
  • デコレータの対象
    • デコレートできるのは関数、メソッド以外にクラスもある

上記さえ理解していれば、あとは目的に応じてどのようなデコレータを作れば良いかが自動的に決まる。

参考

www.python.org

www.python.org

Python: メモ化した内容を percache で永続化する

プログラムを高速化する手法の一つとしてメモ化がある。 これは、関数の返り値をキャッシュしておくことで、同じ呼び出しがあったときにそれを使い回すというもの。

今回は、メモ化でキャッシュした内容を補助記憶装置に永続化できる Python のパッケージとして percache を使ってみる。 キャッシュを補助記憶装置に永続化すると、その分だけ読み書きにはオーバーヘッドがかかる。 しかしながら、計算に多量の時間がかかる場合にはそれでもメリットがありそう。

ただし、先に断っておくと世間的にはほとんど使われていないパッケージなので実際に使うときは十分に検討した方が良い。 キャッシュの機構は、慎重にならないと不具合や脆弱性を生みやすいところなので、特に気をつけた方が良いと思う。 今回の動機としては、元々は似たようなパッケージを自分で書こうか悩んでいて、探したら API がいけてたので試してみたという感じ。

使った環境は次の通り。

$ sw_vers             
ProductName:    Mac OS X
ProductVersion: 10.13.6
BuildVersion:   17G65
$ python -V
Python 3.6.6

下準備

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

$ pip install percache

標準モジュールの functools.lru_cache を使ったメモ化

percache の説明に入る前に、一般的なメモ化について扱っておく。 まず、補助記憶装置への永続化のないメモ化については Python の標準モジュールに実装がある。 具体的には functools.lru_cache を使うと簡単に実現できる。

サンプルコードを以下に示す。 このコードの中では add() 関数を @lru_cache() デコレータを使ってメモ化している。 add() 関数では ab という二つの引数を足し算をして、その結果を返す。 それをメモ化しているということは、つまり引数の ab が以前に呼び出した値と同じだったら、そのときの戻り値を使い回す。 また、関数の中では、実際に処理されたときと返り値が使い回されたときを区別できるように、デバッグ用の文字列を出力している。

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

from functools import lru_cache


# add() 関数をメモ化する
@lru_cache()
def add(a, b):
    print('calculate')  # 実際に処理されたことを確認するための出力
    return a + b


def main():
    # メモ化された関数を二回呼び出す
    print(add(1, 2))
    print(add(1, 2))


if __name__ == '__main__':
    main()

上記を保存して実行してみよう。 すると、関数は二回呼び出されているにも関わらず calculate という文字列は一回しか出力されていない。 これは、二回目の呼び出しは引数が同じなので戻り値が使い回されたことを示している。

$ python memoize.py
calculate
3
3

ただし functools.lru_cache を使ったメモ化では、キャッシュが補助記憶装置に永続化されない。 そのため Python のプロセスが終了すると、主記憶装置にキャッシュしていた内容も揮発してしまう。 その証拠に、先ほどのプログラムを再度実行すると、同じ出力になる。 このとき、もしキャッシュしていた内容が補助記憶装置に永続化されているなら calculate という文字列は出力されないはず。

$ python memoize.py
calculate
3
3

percache でキャッシュを永続化してみる

続いては今回の本題として percache を使ってキャッシュを補助記憶装置に永続化してみる。 使い方は percache.Cache のインスタンスを作って、そのインスタンスをデコレータとして使うというもの。 Flask 的な API になっている。

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

import percache

# my-cache というファイル名でキャッシュを永続化する
cache = percache.Cache('my-cache')


# add() 関数をメモ化する
@cache
def add(a, b):
    print('calculate')
    return a + b


def main():
    import sys
    # 引数を整数として解釈して add() 関数を呼び出す
    result = add(int(sys.argv[1]), int(sys.argv[2]))
    print(result)


if __name__ == '__main__':
    main()

上記を保存したら、同じ引数を使ってプログラムを何度か実行してみよう。 すると、同じ引数を使うと二度目以降は calculate という文字列が表示されない。 つまり、補助記憶装置に永続化されたキャッシュが使われていることが分かる。

$ python pmemoize.py 1 2
calculate
3
$ python pmemoize.py 1 2
3
$ python pmemoize.py 1 3
calculate
4
$ python pmemoize.py 1 3
4

percache の実装

基本的な使い方が分かったところで、続いては percache の実装について見ていこう。 このパッケージは一つのモジュールに収まるほどシンプルな作りになっている。

github.com

どのようにキャッシュの永続化が実現されているか確認しよう。 まず、先ほどの例を実行すると my-cache というファイルがカレントディレクトリにできているはず。 これが、永続化されたキャッシュ内容を保存するためのファイルになる。

$ ls my-cache 
my-cache
$ file my-cache 
my-cache: GNU dbm 1.x or ndbm database, little endian, 64-bit

中身を確認するために Python の REPL を起動しよう。

$ python

キャッシュの永続化は標準モジュールの shelve を使って実装されている。 次のコードを実行すると、キャッシュされた内容が確認できる。

>>> import shelve
>>> with shelve.open('my-cache') as s:
...     for key, value in s.items():
...         print(key, value)
... 
936247610f625403ba55b32ab4dddfc6abd7c2ee 4
de71ece6a221c54c692400a6294839b2c02fd4f2:atime 1535584568.42677
936247610f625403ba55b32ab4dddfc6abd7c2ee:atime 1535584570.2073479
de71ece6a221c54c692400a6294839b2c02fd4f2 3

shelve というモジュールは Python の辞書ライクなオブジェクトを補助記憶装置に永続化するための機能になっている。

12.3. shelve --- Python オブジェクトの永続化 — Python 3.6.6 ドキュメント

先ほど確認した内容から、いくつか分かることがある。 まず、辞書のキーとしてはハッシュと思われる値が使われており、それに対応する値にはメモ化した関数の計算結果が保存されている。 そして、それとは別に永続化した時刻と思われる内容についても保存されているようだ。

ちなみに辞書のキーとなる値については、ソースコードを確認したところ次のようなアルゴリズムで生成されていた。 まず、関数名と repr() した引数の内容を文字列として連結して UTF-8 でバイト列にエンコードする。 そして、その内容を SHA1 でハッシュ化する。

試しに add() 関数に 12 を渡した際のハッシュを手作業で生成してみよう。

>>> args = ''.join(['add', repr(1), repr(2)]).encode('utf-8')
>>> hashlib.sha1(args).hexdigest()
'de71ece6a221c54c692400a6294839b2c02fd4f2'

上記が、先ほど確認した辞書のキーで、結果が 3 になっているものと一致していることが分かる。

バックエンドをオリジナルの実装に入れ替える

percache はキャッシュを永続化する部分をオリジナルの実装に入れ替えることもできる。 ちなみに、キャッシュを永続化する部分の実装を percache ではバックエンドと呼んでいる。 例えば、やろうと思えばクラウド上のストレージに永続化するバックエンドを書くこともできるはず。

以下にサンプルコードとして、保存されるキャッシュの件数を制限できるバックエンドを書いてみた。

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

from collections import OrderedDict
import shelve

import percache


class LimitedSizeBackend(OrderedDict):
    """永続化する結果の数を制限したバックエンド"""

    def __init__(self, filename, limit_size=-1):
        self._filename = filename
        self._limit_size = limit_size
        self._shelve_dict = shelve.open(self._filename)

        self._load()
        self._check_size()

    def _load(self):
        """shelve から永続化されているデータを読み込む"""
        self.update(self._shelve_dict)

    def _check_size(self):
        if self._limit_size < 0:
            # サイズ上限が負なら何もしない
            return

        # サイズ上限に収まるように一番古い要素を削除する
        # NOTE: percache は 1 つのメモ化に 2 つ要素を使う
        while len(self) > self._limit_size * 2:
            # FIFO (Queue)
            self.popitem(last=False)

    def __setitem__(self, key, value):
        """要素の追加があったとき呼ばれる特殊メソッド"""
        super(LimitedSizeBackend, self).__setitem__(key, value)
        self._check_size()

    def _save(self):
        """shelve にデータを書き込んで永続化する"""
        # 一旦既存のデータをクリアする
        self._shelve_dict.clear()
        # 現在のデータを書き戻す
        self._shelve_dict.update(self)
        # 永続化する
        self._shelve_dict.sync()

    def sync(self):
        """shelve として振る舞う (ダックタイピング) ために必要"""
        self._save()

    def close(self):
        """shelve として振る舞う (ダックタイピング) ために必要"""
        self._save()


# サイズ上限が 1 のバックエンドを用意する
backend = LimitedSizeBackend('limited-cache', 1)
# バックエンドを設定してキャッシュオブジェクトを作る
cache = percache.Cache(backend)


# add() 関数をメモ化する
@cache
def add(a, b):
    print('calculate')
    return a + b


def main():
    import sys
    # プログラムの第一引数と第二引数を整数として add() 関数に渡す
    result = add(int(sys.argv[1]), int(sys.argv[2]))
    print(result)


if __name__ == '__main__':
    main()

サンプルコードではキャッシュのサイズを 1 に制限している。 これはつまり、直近一件の返り値だけがキャッシュされるということ。

上記を保存したら色々な値を入れて動作を確認してみよう。 同じ値を入れる限りはキャッシュの結果が使われるものの、別の値を入力すれば忘却することが分かる。

$ python limited.py 1 2
calculate
3
$ python limited.py 1 2
3
$ python limited.py 1 3
calculate
4
$ python limited.py 1 3
4
$ python limited.py 1 2
calculate
3
$ python limited.py 1 2
3

ちなみに、キャッシュのアルゴリズムでは、際限なくサイズが膨れ上がらないような仕組みを入れることが非常に重要となる。 具体的には、キャッシュする件数を制限したり、あるいは一定時間使われないものを消去するといった内容が考えられる。 そういった仕組みがないと、キャッシュによってシステムのリソースを使い尽くす可能性がある。

また、ユーザからの入力を元にキャッシュしているときにそうした仕組みがないと、意図的にリソースを枯渇させることも可能になってしまう。 これは DoS (Denial of Service) 攻撃への脆弱性になる。 ここらへんの制約は、主記憶装置よりも補助記憶装置の方がゆるい。 ただし、キャッシュの保存先が補助記憶装置だとしても、実際に使うときは主記憶装置の上に展開されることを忘れてはいけない。

読み書きがある毎に永続化する

percache は、デフォルトだと明示的に Cache#sync() メソッドや Cache#close() メソッドを呼ばないとバックエンドへの読み書きが発生しない。 これは、コストの高い補助記憶装置へのアクセスを最小限に留めるためと考えられる。 ただし、オプションの livesyncTrue を指定すれば、値の更新が生じた時点でバックエンドに読み書きが生じる。

以下のサンプルコードでは、デバッグ用に sync() メソッドと close() メソッドが呼ばれると文字列を出力するバックエンドを定義している。 それを用いた上で livesync オプションに True を指定している。

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

import percache


class DebugBackend(dict):
    """デバッグ用のバックエンド (ディスクへの永続化はしない)"""

    def sync(self):
        """sync() メソッドが呼ばれたときに標準出力に書く"""
        print('sync')

    def close(self):
        """close() メソッドが呼ばれたときに標準出力に書く"""
        print('close')

# 値の更新がある毎に永続化する
debug_backend = DebugBackend()
cache = percache.Cache(debug_backend, livesync=True)


@cache
def add(a, b):
    print('calculate')
    return a + b


def main():
    print(add(1, 2))
    print(add(1, 2))


if __name__ == '__main__':
    main()

上記を保存して実行してみよう。 計算自体は一回しか実施されていないが、戻り値が返るごとにバックエンドへの読み書きが生じていることが分かる。

$ python livesync.py
calculate
sync
3
sync
3
close

オブジェクトの文字列表現をカスタマイズする

前述した通り percache では関数の引数を組み込み関数の repr() に渡して、その結果を元に辞書のキーに使うハッシュを作る。 つまり、関数の引数に渡すオブジェクトは repr() で適切な文字列を返すようになっていないといけない。

ここで補足しておくと 、組み込み関数の repr() というのは渡されたオブジェクトの文字列表現を取り出すために用いられる。 この repr() 関数にオブジェクトが渡されたときの振る舞いは、特殊メソッドの __repr__() を定義することでオーバーライドできる。

実際に試してみよう。 まずは User という自作クラスを用意する。 このクラスはインスタンス化するときに名前と年齢をメンバ変数に格納する。 そして、このクラスには特殊メソッドの __repr__() が定義されていない。

>>> class User(object):
...     def __init__(self, name, age):
...         self._name = name
...         self._age = age
... 

このクラスをインスタンス化して repr() に渡すと、デフォルトの文字列表現が返される。 これは、インスタンスの元となったクラス名やメモリ上の配置位置を示している。

>>> o = User('alice', 20)
>>> repr(o)
'<__main__.User object at 0x103519b00>'

上記のメモリ上の配置位置はオブジェクトを作る毎に変化する。 本来であれば、同じ名前と年齢を持ったオブジェクトからは同じ repr() がほしい。 そうなっていないと、同じ値を持っているにも関わらず生成したハッシュが異なってしまう。 それではメモ化するときのキーとしては使えない。

そこで、次のように特殊メソッドの __repr__() を定義する。 ここにはクラス名と名前と年齢が文字列に埋め込まれている。

>>> class User(object):
...     def __init__(self, name, age):
...         self._name = name
...         self._age = age
...     def __repr__(self):
...         """repr() で呼ばれる特殊メソッド"""
...         params = {
...             'name': self._name,
...             'age': self._age,
...         }
...         repr_msg = '<User name:{name} age:{age}>'.format(**params)
...         return repr_msg
... 

実際にインスタンス化したオブジェクトを repr() 関数に渡してみよう。

>>> o = User('alice', 20)
>>> repr(o)
'<User name:alice age:20>'

ちゃんとクラス名と名前と年齢を使ってオブジェクトの文字列表現が返るようになった。 これならオブジェクトが異なっても、同じ値さえ持っていれば同じハッシュが生成できる。

このように、自作のクラスについては上記のように特殊メソッドの __repr__() を実装してやればいい。 ただ、実際には自分で作っていないオブジェクトをメモ化した関数に渡すことも考えられる。

percache では、この問題の解決方法も用意してある。 具体的には組み込み関数 repr() の代わりにオブジェクトの文字列表現を取り出すための関数が登録できる。

以下のサンプルコードでは _repr() という関数でオブジェクトの文字列表現を取り出すための関数を定義している。 そして、それを Cache クラスのコンストラクタに渡している。 _repr() 関数の中では、全てのオブジェクトの文字列表現を生成する。 ただし、カスタマイズしたいオブジェクト以外については単純に repr() 関数の出力を返すだけで良い。 このサンプルコードでは User クラスのときだけ特別扱いして、名前と年齢を元に文字列表現を作っている。

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

import percache


class User(object):
    def __init__(self, name, age):
        self._name = name
        self._age = age


def _repr(args):
    if isinstance(args, User):
        # 引数に User のインスタンスが渡されたときの処理
        params = {
            'name': args._name,
            'age': args._age,
        }
        repr_msg = '<User name:{name} age:{age}>'.format(**params)
        return repr_msg

    # それ以外のオブジェクト
    return repr(args)


cache = percache.Cache('user-cache', repr=_repr)


@cache
def process(user):
    print('calculate')
    return user._name, user._age


def main():
    o1 = User('alice', 20)
    print(process(o1))
    o2 = User('alice', 20)
    print(process(o2))


if __name__ == '__main__':
    main()

変数の o1o2 は異なるオブジェクトだけど、持っているメンバ変数の内容は同じなので等価と見なせる。

上記を実行してみよう。 calculate という文字列は一度しか出力されていないことから、ちゃんと戻り値が使い回されていることが分かる。

$ python myrepr.py
calculate
('alice', 20)
('alice', 20)

めでたしめでたし。