CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: mlflow.start_run(nested=True) は使い方に注意しよう

今回は MLflow Tracking のすごーく細かい話。 ソースコードを読んでいて、ハマる人もいるかもなと思ったので書いておく。 結論から先に書くと、MLflow Tracking には次のような注意点がある。

  • MLflow Tracking で標準的に使う API はマルチスレッドで Run が同時並行に作られることを想定していない
  • 同時並行に作れそうなmlflow.start_run(nested=True) は、あくまで Run を入れ子にするときだけ使える
  • この点に気をつけないと MLflow Tracking で記録されるデータが壊れる

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G5033
$ python -V
Python 3.7.7
$ pip list | grep -i mlflow              
mlflow                    1.8.0

もくじ

下準備

あらかじめ MLflow をインストールして Python のインタプリタを起動しておく。

$ pip install mlflow
$ python

そして MLflow をインポートしておく。

>>> import mlflow

注意点の解説

MLflow Tracking では、新しく Run を作るときに mlflow.start_run() という関数を使う。

>>> mlflow.start_run()
<ActiveRun: >

この関数は、すでに同じ Python プロセスで呼ばれていると、再度呼び出したときに例外となるよう作られている。

>>> mlflow.start_run()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/amedama/.virtualenvs/py37/lib/python3.7/site-packages/mlflow/tracking/fluent.py", line 112, in start_run
    _active_run_stack[0].info.run_id))
Exception: Run with UUID a14a8f9bf213473aa4a7a437ac811077 is already active. To start a new run, first end the current run with mlflow.end_run(). To start a nested run, call start_run with nested=True

ただし、オプションに nested=True を指定すると、エラーにならず新たに Run を作ることができる。

>>> mlflow.start_run(nested=True)
<ActiveRun: >

ただ、上記で作られた Run は MLflow のモジュールの中でグローバル変数として用意されたスタック構造で管理されているに過ぎない。 スタック構造は隠しオブジェクトだけど、あえて中身を見せるとこんな感じ。

>>> from mlflow.tracking import fluent
>>> fluent._active_run_stack
[<ActiveRun: >, <ActiveRun: >]

この状況で mlflow.end_run() を呼び出すと、スタック構造からひとつずつ Run を表すオブジェクトが POP されるという寸法。

>>> mlflow.end_run()
>>> fluent._active_run_stack
[<ActiveRun: >]
>>> mlflow.end_run()
>>> fluent._active_run_stack
[]

つまり何が言いたいかというと、mlflow.start_run(nested=True) は、あくまで Run の中で別の Run を「入れ子」にすることだけを想定している。 要するに、以下のようなコード。

>>> # この使い方はセーフ (Run を入れ子にする)
>>> with mlflow.start_run():
...     with mlflow.start_run(nested=True):
...         ...  # 何か時間のかかる実験
... 

「入れ子」ではなく、同時並行に Run を作る用途で使ってしまうとスタック構造が壊れる。 たとえば、次のようなコードを書くとレースコンディションを生む。 具体的には、今実行しているのとは関係ない Run にメトリックやパラメータが記録される。

>>> # この使い方はアウト (スタックが壊れる)
>>> import threading
>>> def f():
...     with mlflow.start_run(nested=True):
...         ...  # 何か時間のかかる実験
... 
>>> for _ in range(10):
...     t = threading.Thread(target=f, daemon=True)
...     t.start()
... 

それでも同時に Run を実行したい!というときはプロセスを分けよう。 プロセスが違えばグローバル変数のいるメモリ空間も分かれるので問題ないはず。 そもそも、一般的な Python の処理系には GIL (Global Interpreter Lock) があるので、I/O を並行処理するときしかマルチスレッドが意味を成さない。

ちなみに、ここらへんの実装は以下にある。

github.com

上記を読むと mlflow.tracking.client.MlflowClient を直接使えばマルチスレッドで複数の Run を同時に使うこともできそう。 ただ、得られる嬉しさが手間に見合わないだろうなという感じがする。

いじょう。

Python: MLflow Tracking を使ってみる

MLflow は MLOps に関連した OSS のひとつ。 いくつかのコンポーネントに分かれていて、それぞれを必要に応じて独立して使うことができる。 今回は、その中でも実験の管理と可視化を司る MLflow Tracking を試してみることにした。

機械学習のプロジェクトでは試行錯誤することが多い。 その際には、パラメータやモデルの構成などを変えながら何度も実験を繰り返すことになる。 すると、回数が増えるごとに使ったパラメータや得られた結果、モデルなどの管理が煩雑になってくる。 MLflow Tracking を使うことで、その煩雑さが軽減できる可能性がある。

使った環境は次のとおり。

$ sw_vers          
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G5033
$ python -V       
Python 3.7.7
$ mlflow --version
mlflow, version 1.8.0

もくじ

登場する概念について

まず、MLflow Tracking では ExperimentRun というモノを作っていく。 これらは、特定の目的を持った実験と、それに 1:N で紐付いた各試行を表している。 つまり、試行錯誤の度に Run が増えることになる。 そして、それぞれの Run には次のような情報が、またもや 1:N で紐づく。

  • Parameter

    • データの前処理やモデルの学習に使ったパラメータ
  • Metric

    • 各ラウンドの損失や、交差検証のスコアといったメトリック
  • Artifact

    • 学習済みモデルや、特徴量の重要度など実験した結果として得られる成果物
  • Tag

    • 後から実験を探しやすくしたりするためのメタな情報

要するに Experiment > Run > Parameter, Metric, ... ということ。 これらの情報は、典型的には Python のスクリプトから記録される。 そして、記録された情報は後からスクリプトや WebUI 経由で確認できる。

データを保存する場所について

具体的な使い方を紹介する前に、前述した情報がどのように管理されるのか解説しておく。 まず、MLflow Tracking はクライアントとサーバに分かれてる。 そして、データは基本的にサーバに保存される。 クライアントは、データを保存するサーバの場所を Tracking URI と Artifact URI という 2 つの URI で指定する。

ただし、クライアントとサーバは 1 台のマシンで兼ねることもできる。 また、データを記録する方法も、追加でソフトウェアなどを必要としないローカルファイルで完結させることができる。 そのため、クライアント・サーバ方式といっても 1 人で使い始める分には環境構築などの作業は全く必要ない。 あくまで、複数人のチームで記録されたデータを共用・共有したいときに専用のサーバが必要となる。

Tracking URI について

Tracking URI は、概ね Run の中で Artifact 以外の情報を記録するところ。 記録される情報は、基本的には Key-Value 形式になっている。

利用できるバックエンドには次のようなものがある。

  • ローカルファイル

    • マウントされているブロックストレージにファイルとして記録される
      • 手っ取り早く使うならこれ (デフォルト)
    • 形式: file:<path>
  • リレーショナルデータベース

    • SQLAlchemy 経由で RDB にテーブルのエントリとして記録される
    • 形式: <dialect>+<driver>://<username>:<password>@<host>:<port>/<database>
  • REST API

    • MLflow Server というサーバを立ち上げて、そこに記録される
      • 後述するが、記録先の実体はローカルファイルだったりリレーショナルデータベースだったり選べる
    • 形式: http(s)://<host>:<port>

その他、詳細は以下に記載されている。

www.mlflow.org

Artifact URI について

Artifact URI は、その名の通り Artifact を記録するところ。 記録される情報はファイル (バイト列) になる。

利用できるバックエンドには次のようなものがある。

  • ローカルファイル

    • マウントされているブロックストレージにファイルとして保存される
      • 手っ取り早く使うならこれ (デフォルト)
    • 形式: file:<path>
  • 各種クラウド (オブジェクト) ストレージ

    • Amazon S3, Azure Blob Storage, Google Cloud Storage などにファイルとして保存される
    • 形式: s3://<bucket> など
  • (S)FTP サーバ

    • (S)FTP サーバにファイルとして保存される
    • 形式: (s)ftp://<user>@<host>/<path>
  • HDFS

    • HDFS (Hadoop Distributed File System) にファイルとして保存される
    • 形式: hdfs://<path>

その他、詳細は以下に記載されている。

www.mlflow.org

色々とあるけど、結局のところ 1 人で使い始めるなら両方ともローカルファイルにすれば良い。 デフォルトでは、どちらもクライアントを実行した場所の mlruns というディレクトリが使われる。 これは、Traking URI と Artifact URI の両方に file:./mlruns を指定した状態ということ。

ちなみに、チームで使いたいけど自分で構築とか運用したくないってときは、開発の中心となっている Databricks がサーバ部分のマネージドサービスを提供している。

下準備

前置きが長くなったけど、ここからやっと実際に試していく。

はじめに、必要なパッケージをインストールしておこう。 なお、mlflow 以外は、後ほど登場するサンプルコードを動かすためだけに必要なもの。

$ pip install mlflow scikit-learn lightgbm matplotlib

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

$ mlflow --version
mlflow, version 1.8.0

基本的な使い方

とりあえず Python の REPL を使って、基本的な使い方を紹介する。 まずは REPL を起動しておく。

$ python

MLflow のパッケージをインポートする。

>>> import mlflow

実験の試行を開始する。 これには start_run() 関数を使う。 なお、デフォルトでは Experiment として Default という領域が使われる。

>>> mlflow.start_run()
<ActiveRun: >

実験に使ったパラメータを log_param() 関数で記録する。

>>> mlflow.log_param(key='foo', value='bar')

得られたメトリックなどの情報は log_metric() 関数で記録する。

>>> mlflow.log_metric(key='logloss', value=1.0)

実験には set_tag() 関数でタグが付与できる。

>>> mlflow.set_tag(key='hoge', value='fuga')

尚、これらは関数名を複数形にすると辞書型で複数の Key-Value を一度に記録できる。

アーティファクトについては、ちょっと面倒くさい。 アーティファクトはバイト列のファイルなので、まずはローカルにファイルを用意して、それをコピー (転送) することになる。 典型的には、一時ディレクトリを tempfile モジュールで作って、中身のファイルを作ったらそれをコピーすれば良い。 一時ディレクトリ以下のファイルは、処理が終わったら自動的に削除される。

>>> import tempfile
>>> import pathlib
>>> with tempfile.TemporaryDirectory() as d:
...     filename = 'test-artifact'
...     artifact_path = pathlib.Path(d) / filename
...     with open(artifact_path, 'w') as fp:
...         print('Hello, World!', file=fp)
...     mlflow.log_artifact(artifact_path)
... 

あとは実験の試行を終了するだけ。

>>> mlflow.end_run()

Python の REPL を終了して、カレントディレクトリを確認してみよう。 デフォルトの Tracking URI / Artifact URI として file:./mlruns が使われるため mlruns というディレクトリが作られている。

$ find mlruns 
mlruns
mlruns/0
mlruns/0/3de33a8f39294c4f8bd404a5d5bccf39
mlruns/0/3de33a8f39294c4f8bd404a5d5bccf39/metrics
mlruns/0/3de33a8f39294c4f8bd404a5d5bccf39/metrics/logloss
mlruns/0/3de33a8f39294c4f8bd404a5d5bccf39/artifacts
mlruns/0/3de33a8f39294c4f8bd404a5d5bccf39/artifacts/test-artifact
mlruns/0/3de33a8f39294c4f8bd404a5d5bccf39/tags
mlruns/0/3de33a8f39294c4f8bd404a5d5bccf39/tags/hoge
mlruns/0/3de33a8f39294c4f8bd404a5d5bccf39/tags/mlflow.user
mlruns/0/3de33a8f39294c4f8bd404a5d5bccf39/tags/mlflow.source.name
mlruns/0/3de33a8f39294c4f8bd404a5d5bccf39/tags/mlflow.source.type
mlruns/0/3de33a8f39294c4f8bd404a5d5bccf39/params
mlruns/0/3de33a8f39294c4f8bd404a5d5bccf39/params/foo
mlruns/0/3de33a8f39294c4f8bd404a5d5bccf39/meta.yaml
mlruns/0/meta.yaml
mlruns/.trash

最初の階層は、それぞれの Experiment を表している。 DefaultExperiment には ID として 0 が付与されていることがわかる。

$ cat mlruns/0/meta.yaml 
artifact_location: file:///Users/amedama/Documents/temporary/helloworld/mlruns/0
experiment_id: '0'
lifecycle_stage: active
name: Default

その下の階層は、それぞれの Run を表している。

$ head mlruns/0/3de33a8f39294c4f8bd404a5d5bccf39/meta.yaml
artifact_uri: file:///Users/amedama/Documents/temporary/helloworld/mlruns/0/3de33a8f39294c4f8bd404a5d5bccf39/artifacts
end_time: 1591267604170
entry_point_name: ''
experiment_id: '0'
lifecycle_stage: active
name: ''
run_id: 3de33a8f39294c4f8bd404a5d5bccf39
run_uuid: 3de33a8f39294c4f8bd404a5d5bccf39
source_name: ''
source_type: 4

ここにパラメータやメトリックなどが記録される。 メトリックに記録されている各列は、実行時刻、値、ステップ数を表している。 ステップ数というのは、たとえばニューラルネットワークのエポックだったり、ブースティングマシンのラウンドだったりする。 先ほど実行した log_metric() 関数の引数として、実は指定できた。

$ head mlruns/0/3de33a8f39294c4f8bd404a5d5bccf39/params/foo 
bar
$ head mlruns/0/3de33a8f39294c4f8bd404a5d5bccf39/metrics/logloss 
1591267315737 1.0 0
$ cat mlruns/0/3de33a8f39294c4f8bd404a5d5bccf39/artifacts/test-artifact 
Hello, World!

時刻には 1,000 倍した UNIX time が記録されている。

$ date -r $((1591267315737 / 1000))
202064日 木曜日 194155秒 JST

記録された情報を WebUI で確認する

今のところ「ふーん」という感じだと思うので、記録された情報を WebUI からも確認してみよう。 確認用の WebUI を立ち上げるために、mlruns ディレクトリのある場所で mlflow ui コマンドを実行する。

$ mlflow ui

そして、ブラウザで localhost:5000 を閲覧する。

$ open http://localhost:5000

すると、こんな感じで過去に記録された情報が確認できる。 メトリックで試行を並べ替えたり、ステップ毎の値を可視化する機能も備わっている。

f:id:momijiame:20200604200638p:plain
MLflow Tracking WebUI

ちなみに WebUI を使う以外にも、ちょっと面倒だけどスクリプトから確認することもできる。

$ python

たとえば、メトリックの logloss が最も良いものを絞り込んでみる。 まあ、まだ 1 回しか実行してないんだけどね。

>>> import mlflow
>>> tracking_uri = 'file:./mlruns'
>>> client = mlflow.tracking.MlflowClient(tracking_uri=tracking_uri)
>>> experiment = client.get_experiment_by_name('Default')
>>> run = client.search_runs(experiment.experiment_id,
...                          order_by=['metric.logloss asc'],
...                          max_results=1)[0]
>>> run.data.params
{'foo': 'bar'}
>>> run.data.metrics
{'logloss': 1.0}

複数人のチームで使いたいとき

複数人のチームで使うときは、いくつかの選択肢があるけど基本的には MLflow Server を用意して、そこに皆でアクセスする。 MLflow Server というのは Tracking URI として使える REST API と、先ほど確認した WebUI が一緒になったもの。

注意点として、MLflow Server はあくまで Tracking URI と WebUI のエンドポイントを提供するもの。 なので、Artifact URI の実体については別に用意しなければいけない。 たとえばクラウドストレージが使えるならそれを使っても良し、自分たちで FTP サーバを立ち上げたり HDFS のクラスタを組むことも考えられるだろう。

ここでは MLflow Server について軽く解説しておく。 まず、MLflow Server は mlflow server コマンドで起動できる。 起動するときに、Tracking URI (--backend-store-uri) と Artifact URI (--default-artifact-root) を指定する。

$ mlflow server \
    --backend-store-uri sqlite:///tracking.db \
    --default-artifact-root file:/tmp/artifacts \
    --host 0.0.0.0

--backend-store-uri は、MLflow サーバがクライアントから REST API 経由で受け取ったデータを記録する場所。 一方で、--default-artifact-root はサーバに接続してきたクライアントに「Artifact はここに保存してね」と伝えられる場所に過ぎない。 つまり、MLflow Server がプロキシしてくれるわけではないのでクライアントから接続性のある URI を指定する必要がある。 ここでは横着して /tmp 以下を指定してしまっている。 しかし、こんな風にするなら本来は NFS などでクライアントがすべて /tmp 以下に共有ディレクトリをマウントする必要がある。

起動したサーバを使って実際にデータを記録してみよう。 まずは別のターミナルで Python の REPL を起動する。

$ python

set_tracking_uri() 関数で Tracking URI として MLflow Server のエンドポイントを指定する。

>>> import mlflow
>>> tracking_uri = 'http://localhost:5000'
>>> mlflow.set_tracking_uri(tracking_uri)

すると、データの記録先が次のように設定される。

>>> mlflow.get_tracking_uri()
'http://localhost:5000'
>>> mlflow.get_artifact_uri()
'file:///tmp/artifacts/0/26d9e4204c20401eb7d2807a93be8b75/artifacts'

何か適当にデータを記録してみよう。

>>> mlflow.start_run()
>>> mlflow.log_param(key='foo', value='bar')
>>> mlflow.log_metric(key='logloss', value=0.5)
>>> import tempfile
>>> import pathlib
>>> with tempfile.TemporaryDirectory() as d:
...     filename = 'test-artifact'
...     artifact_path = pathlib.Path(d) / filename
...     with open(artifact_path, 'w') as fp:
...         print('Hello, World!', file=fp)
...     mlflow.log_artifact(artifact_path)
... 
>>> mlflow.end_run()

これで、アーティファクトについては前述したとおり /tmp 以下に保存される。

$ cat /tmp/artifacts/0/26d9e4204c20401eb7d2807a93be8b75/artifacts/test-artifact 
Hello, World!

アーティファクト以外の情報は MLflow Server の方に記録される。 今回であれば MLflow Server を実行したディレクトリにある SQLite3 のデータベースに入っている。

$ sqlite3 tracking.db 'SELECT * FROM experiments'
0|Default|file:///tmp/artifacts/0|active
$ sqlite3 tracking.db 'SELECT * FROM runs'       
26d9e4204c20401eb7d2807a93be8b75||UNKNOWN|||amedama|FINISHED|1591272053874|1591272141536||active|file:///tmp/artifacts/0/26d9e4204c20401eb7d2807a93be8b75/artifacts|0

ちなみに mlflow server コマンドの実装は mlflow.server:app にある Flask の WSGI アプリケーションを gunicorn でホストするコマンドをキックしてるだけ。 なので、自分で WSGI サーバを立ててアプリケーションをホストしても構わないだろう。 認証が必要なら前段にリバースプロキシを置いて好きにやれば良いと思う。 MLflow Server の解説については、ここまでで一旦おわり。

スクリプトに組み込んでみる

続いては、実際に MLflow Tracking を機械学習を扱うコードに組み込んでみよう。 そんなに良い例でもないけど乳がんデータセットを RandomForest で分類する過程を MLflow Tracking で記録してみる。 ここでは set_tracking_uri()set_experiment() していないのでデフォルト値 (mlruns / Default) が使われる。 ちなみに、コードに書かなくても、環境変数の MLFLOW_TRACKING_URIMLFLOW_EXPERIMENT_NAME を使う方法もある。

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

import pickle
import json
import tempfile
import pathlib

import mlflow
from sklearn import datasets
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import StratifiedKFold
from sklearn.model_selection import cross_validate


def main():
    # データセットの読み込み
    dataset = datasets.load_breast_cancer()
    X, y = dataset.data, dataset.target
    feature_names = dataset.feature_names

    # 5-Fold Stratified CV でスコアを確認する
    clf = RandomForestClassifier(n_estimators=100,
                                 random_state=42,
                                 verbose=1)
    folds = StratifiedKFold(shuffle=True, random_state=42)
    # モデルの性能を示すメトリック
    result = cross_validate(clf, X, y,
                            cv=folds,
                            return_estimator=True,
                            n_jobs=-1)
    # 学習済みモデル
    estimators = result.pop('estimator')

    # 結果を記録する Run を始める
    with mlflow.start_run():

        # 実験に使った Parameter を記録する
        mlflow.log_params({
            'n_estimators': clf.n_estimators,
            'random_state': clf.random_state,
        })

        # 実験で得られた Metric を記録する
        for key, value in result.items():
            # 数値なら int/float な必要があるので平均値に直して書き込む
            mlflow.log_metric(key=key,
                              value=value.mean())

        # 実験で得られた Artifact を記録する
        for index, estimator in enumerate(estimators):

            # 一時ディレクトリの中に成果物を書き出す
            with tempfile.TemporaryDirectory() as d:

                # 学習済みモデル
                clf_filename = f'sklearn.ensemble.RandomForestClassifier.{index}'
                clf_artifact_path = pathlib.Path(d) / clf_filename
                with open(clf_artifact_path, 'wb') as fp:
                    pickle.dump(estimator, fp)

                # 特徴量の重要度
                imp_filename = f'sklearn.ensemble.RandomForestClassifier.{index}.feature_importances_.json'  # noqa
                imp_artifact_path = pathlib.Path(d) / imp_filename
                with open(imp_artifact_path, 'w') as fp:
                    importances = dict(zip(feature_names, estimator.feature_importances_))
                    json.dump(importances, fp, indent=2)

                # ディレクトリにあるファイルを Artifact として登録する
                mlflow.log_artifacts(d)


if __name__ == '__main__':
    main()

上記を実行する。

$ python bcrf.py

これで、各モデルの学習に使ったパラメータやメトリック、特徴量の重要度などが記録される。

$ cat mlruns/0/15d6ba2c32e240a382ff1efab8814e47/params/n_estimators 
100
$ cat mlruns/0/15d6ba2c32e240a382ff1efab8814e47/metrics/test_score 
1591273430658 0.9560937742586555 0
$ head mlruns/0/15d6ba2c32e240a382ff1efab8814e47/artifacts/sklearn.ensemble.RandomForestClassifier.0.feature_importances_.json 
{
  "mean radius": 0.04924964271136709,
  "mean texture": 0.01737644949893605,
  "mean perimeter": 0.07531323166620862,
  "mean area": 0.05398223096565245,
  "mean smoothness": 0.007976941962365291,
  "mean compactness": 0.01205471060787445,
  "mean concavity": 0.04868260289112485,
  "mean concave points": 0.08748636077564236,
  "mean symmetry": 0.0037492071919759205,

各種フレームワークの自動ロギング

先ほどのコードを見て分かる通り、MLflow Tracking のコードを組み込むのは結構めんどくさい。 そこで、MLflow Tracking は各種フレームワークの学習を自動で記録するインテグレーションを提供している。 ただし、この機能は今のところ Experimental な点に注意が必要。

以下のサンプルコードは LightGBM の学習を自動で記録するもの。 MLflow Tracking を動作させている部分は mlflow.lightgbm.autolog() を呼び出している一行だけ。

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

from mlflow import lightgbm as mlflow_lgb
import numpy as np
import lightgbm as lgb
from sklearn import datasets
from sklearn.metrics import accuracy_score
from sklearn.model_selection import train_test_split


def main():
    # LightGBM の学習を自動でトラッキングする
    mlflow_lgb.autolog()

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

    # 訓練データと検証データに分割する
    X_train, X_test, y_train, y_test = train_test_split(X, y,
                                                        shuffle=True,
                                                        random_state=42)

    # データセットを生成する
    lgb_train = lgb.Dataset(X_train, y_train,
                            feature_name=feature_names)
    lgb_eval = lgb.Dataset(X_test, y_test,
                           reference=lgb_train,
                           feature_name=feature_names)

    lgbm_params = {
        'objective': 'binary',
        'metric': 'binary_logloss',
        'verbosity': -1,
    }
    # ここでは MLflow Tracking がパッチした train() 関数が呼ばれる
    booster = lgb.train(lgbm_params,
                        lgb_train,
                        valid_sets=lgb_eval,
                        num_boost_round=1000,
                        early_stopping_rounds=100,
                        verbose_eval=10,
                        )

    # 学習済みモデルを使って検証データを予測する
    y_pred_proba = booster.predict(X_test,
                                   num_iteration=booster.best_iteration)

    # 検証データのスコアを確認する
    y_pred = np.where(y_pred_proba > 0.5, 1, 0)
    test_score = accuracy_score(y_test, y_pred)
    print(test_score)


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python bclgb.py

...

[180]  valid_0's binary_logloss: 0.14104
[190] valid_0's binary_logloss: 0.143199
Early stopping, best iteration is:
[97]   valid_0's binary_logloss: 0.10515
0.958041958041958

すると、train() 関数を呼ぶときに使われたパラメータやメトリックが記録される。

$ find mlruns/0/aae1e01ffa04406db4eaa6031d1c1ffb 
mlruns/0/aae1e01ffa04406db4eaa6031d1c1ffb
mlruns/0/aae1e01ffa04406db4eaa6031d1c1ffb/metrics
mlruns/0/aae1e01ffa04406db4eaa6031d1c1ffb/metrics/valid_0-binary_logloss
mlruns/0/aae1e01ffa04406db4eaa6031d1c1ffb/metrics/stopped_iteration
mlruns/0/aae1e01ffa04406db4eaa6031d1c1ffb/metrics/best_iteration
mlruns/0/aae1e01ffa04406db4eaa6031d1c1ffb/artifacts
mlruns/0/aae1e01ffa04406db4eaa6031d1c1ffb/artifacts/feature_importance_gain.json
mlruns/0/aae1e01ffa04406db4eaa6031d1c1ffb/artifacts/feature_importance_gain.png
mlruns/0/aae1e01ffa04406db4eaa6031d1c1ffb/artifacts/feature_importance_split.json
mlruns/0/aae1e01ffa04406db4eaa6031d1c1ffb/artifacts/model
mlruns/0/aae1e01ffa04406db4eaa6031d1c1ffb/artifacts/model/MLmodel
mlruns/0/aae1e01ffa04406db4eaa6031d1c1ffb/artifacts/model/conda.yaml
mlruns/0/aae1e01ffa04406db4eaa6031d1c1ffb/artifacts/model/model.lgb
mlruns/0/aae1e01ffa04406db4eaa6031d1c1ffb/artifacts/feature_importance_split.png
mlruns/0/aae1e01ffa04406db4eaa6031d1c1ffb/tags
mlruns/0/aae1e01ffa04406db4eaa6031d1c1ffb/tags/mlflow.user
mlruns/0/aae1e01ffa04406db4eaa6031d1c1ffb/tags/mlflow.source.name
mlruns/0/aae1e01ffa04406db4eaa6031d1c1ffb/tags/mlflow.log-model.history
mlruns/0/aae1e01ffa04406db4eaa6031d1c1ffb/tags/mlflow.source.type
mlruns/0/aae1e01ffa04406db4eaa6031d1c1ffb/params
mlruns/0/aae1e01ffa04406db4eaa6031d1c1ffb/params/categorical_feature
mlruns/0/aae1e01ffa04406db4eaa6031d1c1ffb/params/feature_name
mlruns/0/aae1e01ffa04406db4eaa6031d1c1ffb/params/keep_training_booster
mlruns/0/aae1e01ffa04406db4eaa6031d1c1ffb/params/num_boost_round
mlruns/0/aae1e01ffa04406db4eaa6031d1c1ffb/params/early_stopping_rounds
mlruns/0/aae1e01ffa04406db4eaa6031d1c1ffb/params/objective
mlruns/0/aae1e01ffa04406db4eaa6031d1c1ffb/params/verbosity
mlruns/0/aae1e01ffa04406db4eaa6031d1c1ffb/params/verbose_eval
mlruns/0/aae1e01ffa04406db4eaa6031d1c1ffb/params/metric
mlruns/0/aae1e01ffa04406db4eaa6031d1c1ffb/meta.yaml

ただし、当たり前だけど自分で計算した内容については自分で記録しなければ残らない。 今回であれば自分でホールドアウト検証で計算している test_score は記録されていないことがわかる。

自分で計算したメトリックも記録するように修正してみよう。

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

import mlflow
from mlflow import lightgbm as mlflow_lgb
import numpy as np
import lightgbm as lgb
from sklearn import datasets
from sklearn.metrics import accuracy_score
from sklearn.model_selection import train_test_split


def main():
    # LightGBM の学習を自動でトラッキングする
    mlflow_lgb.autolog()

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

    # 訓練データと検証データに分割する
    X_train, X_test, y_train, y_test = train_test_split(X, y,
                                                        shuffle=True,
                                                        random_state=42)

    # データセットを生成する
    lgb_train = lgb.Dataset(X_train, y_train,
                            feature_name=feature_names)
    lgb_eval = lgb.Dataset(X_test, y_test,
                           reference=lgb_train,
                           feature_name=feature_names)

    lgbm_params = {
        'objective': 'binary',
        'metric': 'binary_logloss',
        'verbosity': -1,
    }

    # Run を開始する
    active_run = mlflow.start_run()

    # ここでは MLflow Tracking がパッチした train() 関数が呼ばれる
    booster = lgb.train(lgbm_params,
                        lgb_train,
                        valid_sets=lgb_eval,
                        num_boost_round=1000,
                        early_stopping_rounds=100,
                        verbose_eval=10,
                        )

    # 学習済みモデルを使って検証データを予測する
    y_pred_proba = booster.predict(X_test,
                                   num_iteration=booster.best_iteration)

    # 検証データのスコアを確認する
    y_pred = np.where(y_pred_proba > 0.5, 1, 0)
    test_score = accuracy_score(y_test, y_pred)
    print(test_score)

    # 自分で計算したメトリックを記録する
    mlflow.log_metric(key='test_score', value=test_score)

    # Run を終了する
    mlflow.end_run()


if __name__ == '__main__':
    main()

上記をもう一度実行する。

$ python bclgb.py

すると、今度は test_score も記録されていることがわかる。

$ cat mlruns/0//fd0e67c47819488089431813e2028986/metrics/test_score
1591274826005 0.958041958041958 0

自作の autolog() 相当を作ってみる

ところで、先ほどの autolog() 関数がどのように実現されているのか気にならないだろうか。 これは、モンキーパッチを使うことで対象モジュールのコードを動的に書きかえている。 先ほどの LightGBM であれば lightgbm.train() が MLflow Tracking を使うものに書きかえられた。

そこで、試しに自分でも autolog() 相当のものを書いてみることにした。 以下は scikit-learn の RandomForestClassifier#fit() を書きかえたもの。 MLflow Tracking のモンキーパッチには gorilla というフレームワークが使われている。

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

import json
import tempfile
import pathlib

import numpy as np
import gorilla
import mlflow
from sklearn import datasets
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score
from sklearn.model_selection import train_test_split


def autolog_sklearn_random_forest():
    """scikit-learn の RandomForestClassifier#fit() をモンキーパッチする

    モンキーパッチ後は、モデルの学習に関する情報が自動で MLflow Tracking に残る"""

    @gorilla.patch(RandomForestClassifier)
    def fit(self, *args, **kwargs):
        # 実行中の Run が存在するか確認する
        if not mlflow.active_run():
            # 無ければ新しく Run を作る
            mlflow.start_run()
            # 関数内で end_run() を呼ぶ必要があるか
            auto_end_run = True
        else:
            auto_end_run = False

        # 学習に使われたパラメータを記録する
        attr_names = ['n_estimators',
                      'max_depth',
                      'min_samples_split',
                      'min_samples_leaf',
                      'random_state',
                      ]
        for attr_name in attr_names:
            # インスタンスからアトリビュートの値を取り出して記録する
            attr_value = getattr(self, attr_name)
            mlflow.log_param(key=f'sklearn.ensemble.RandomForestClassifier.{attr_name}',
                             value=attr_value)

        # パッチ前のオブジェクトを取得する
        original = gorilla.get_original_attribute(RandomForestClassifier, 'fit')

        # パッチ前のオブジェクトの呼び出し
        result = original(self, *args, **kwargs)

        # メトリックを記録する (ここでは特に何もない)
        # NOTE: validation set の損失などを取得する手段があれば残す

        # アーティファクトを記録する
        # Gini Importance を記録する (XXX: feature names を渡す良い方法が思いつかない...)
        tmpdir = tempfile.mkdtemp()
        filename = 'sklearn.ensemble.RandomForestClassifier.feature_importances_.json'
        artifact_path = pathlib.Path(tmpdir) / filename
        with open(artifact_path, 'w') as fp:
            json.dump(list(self.feature_importances_), fp, indent=2)
        mlflow.log_artifact(artifact_path)

        # 関数内で Run を作っていたら終了する
        if not auto_end_run:
            mlflow.end_run()

        # 結果を返す
        return result

    # 既にパッチされているときは上書きしない
    settings = gorilla.Settings(allow_hit=True, store_hit=True)
    # RandomForestClassifier#fit() をモンキーパッチする
    monkey_patch = gorilla.Patch(RandomForestClassifier, 'fit', fit, settings=settings)
    gorilla.apply(monkey_patch)


def main():
    # RandomForestClassifier をパッチする
    autolog_sklearn_random_forest()

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

    # 訓練データと検証データに分割する
    X_train, X_test, y_train, y_test = train_test_split(X, y,
                                                        shuffle=True,
                                                        random_state=42)

    # RandomForest 分類器を用意する
    clf = RandomForestClassifier(n_estimators=100,
                                 random_state=42,
                                 verbose=1)
    # ここで呼び出されるのはパッチされたオブジェクトになる
    clf.fit(X_train, y_train)

    # 学習済みモデルを使って検証データを予測する
    y_pred_proba = clf.predict(X_test)

    # 検証データのスコアを確認する
    y_pred = np.where(y_pred_proba > 0.5, 1, 0)
    test_score = accuracy_score(y_test, y_pred)
    print(test_score)


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ myautolog.py

すると、次のようにパラメータなどが記録される。

$ find mlruns/0/6c2abd9a92344534a45965005f7dfcc6  
mlruns/0/6c2abd9a92344534a45965005f7dfcc6
mlruns/0/6c2abd9a92344534a45965005f7dfcc6/metrics
mlruns/0/6c2abd9a92344534a45965005f7dfcc6/artifacts
mlruns/0/6c2abd9a92344534a45965005f7dfcc6/artifacts/sklearn.ensemble.RandomForestClassifier.feature_importances_.json
mlruns/0/6c2abd9a92344534a45965005f7dfcc6/tags
mlruns/0/6c2abd9a92344534a45965005f7dfcc6/tags/mlflow.user
mlruns/0/6c2abd9a92344534a45965005f7dfcc6/tags/mlflow.source.name
mlruns/0/6c2abd9a92344534a45965005f7dfcc6/tags/mlflow.source.type
mlruns/0/6c2abd9a92344534a45965005f7dfcc6/params
mlruns/0/6c2abd9a92344534a45965005f7dfcc6/params/sklearn.ensemble.RandomForestClassifier.max_depth
mlruns/0/6c2abd9a92344534a45965005f7dfcc6/params/sklearn.ensemble.RandomForestClassifier.random_state
mlruns/0/6c2abd9a92344534a45965005f7dfcc6/params/sklearn.ensemble.RandomForestClassifier.n_estimators
mlruns/0/6c2abd9a92344534a45965005f7dfcc6/params/sklearn.ensemble.RandomForestClassifier.min_samples_split
mlruns/0/6c2abd9a92344534a45965005f7dfcc6/params/sklearn.ensemble.RandomForestClassifier.min_samples_leaf
mlruns/0/6c2abd9a92344534a45965005f7dfcc6/meta.yaml
$ cat mlruns/0/6c2abd9a92344534a45965005f7dfcc6/params/sklearn.ensemble.RandomForestClassifier.n_estimators 
100

モンキーパッチを使うと、共通で何度も使われるようなコードをトラッキングする手間がだいぶ減りそうだ。 ただ、対象のモジュールのインターフェースに追従しなければならない副作用もあるため、使い所は吟味しなければいけないだろう。

まとめ

今回は MLflow の Tracking というコンポーネントを試してみた。

総じて、使い勝手や設計などに筋の良さを感じた。 一人でもチームでも同じコードが使える点や、インテグレーション以外で他に何らかの機械学習フレームワークへの依存がないのはとても良い。 一方で、多少は仕方ないにせよ自分のコードに組み込むときに、モンキーパッチを書かない限りは似たようなコードを何度も書くハメになってつらそう。 あと、チームで使うときにサーバの運用とか Artifact の置き場所どうしよってところは悩みそうだと思った。

そんな感じで。

Python: Optuna の LightGBMTunerCV から学習済みモデルを取り出す

Optuna v1.5.0 では、LightGBM インテグレーションの一環として LightGBMTunerCV という API が追加された。 これは LightGBM の cv() 関数を Step-wise algorithm で最適化するラッパーになっている。 つまり、重要ないくつかのパラメータを Step-wise で調整することで、最も高い交差検証スコアが得られるパラメータを探索できる。 今回は、追加された LightGBMTunerCV の使い方を紹介すると共に学習済みモデルを取り出す方法について書いてみる。 ただし、今のところ Experimental な機能という位置づけなので、今後インターフェースなどが変わる可能性もある。

尚、LightGBM の素の cv() 関数から学習済みモデルを取り出す方法は次のエントリに書いた。

blog.amedama.jp

使った環境は次のとおり。

$ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G5033
$ python -V
Python 3.7.7
$ pip list | egrep -i "(optuna|lightgbm)"
lightgbm        2.3.1
optuna          1.5.0

もくじ

下準備

あらかじめ、使用するパッケージをインストールしておく。

$ brew install libomp
$ pip install lightgbm optuna scikit-learn

乳がんデータセットを交差検証するサンプルコード

早速だけど、以下に LightGBMTunerCV の基本的な使い方を紹介するサンプルコードを示す。 これまでと同様、optuna.integration.lightgbm パッケージをインポートすることで Optuna 経由で LightGBM を使うことになる。 LightGBMTunerCV はクラスとして実装されていて、インスタンス化するときに cv() 関数を実行するときに使うオプションを渡す。 そして、LightGBMTunerCV#run() メソッドを実行すると重要なパラメータが順番に探索されていって、終了するとインスタンスから最も優れたスコアやパラメータが得られる。

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

from pprint import pprint

from optuna.integration import lightgbm as lgb
from sklearn import datasets
from sklearn.model_selection import StratifiedKFold


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

    # LightGBM のデータセット表現にする
    lgb_train = lgb.Dataset(X, y)

    # データセットの分割方法
    folds = StratifiedKFold(n_splits=3, shuffle=True, random_state=42)

    # 最適化するときの条件
    lgbm_params = {
        'objective': 'binary',
        'metric': 'binary_logloss',
        'verbosity': -1,
    }
    # 基本的には cv() 関数のオプションがそのまま渡せる
    tuner_cv = lgb.LightGBMTunerCV(
        lgbm_params, lgb_train,
        num_boost_round=1000,
        early_stopping_rounds=100,
        verbose_eval=20,
        folds=folds,
    )

    # 最適なパラメータを探索する
    tuner_cv.run()

    # 最も良かったスコアとパラメータを書き出す
    print(f'Best score: {tuner_cv.best_score}')
    print('Best params:')
    pprint(tuner_cv.best_params)


if __name__ == '__main__':
    main()

それでは、上記をファイルに保存して実行してみよう。

$ python tunercv.py
/Users/amedama/.virtualenvs/py37/lib/python3.7/site-packages/optuna/_experimental.py:83: ExperimentalWarning: LightGBMTunerCV is experimental (supported from v1.5.0). The interface can change in the future.
  ExperimentalWarning,
feature_fraction, val_score: inf:   0%|                   | 0/7 [00:00<?, ?it/s][20] cv_agg's binary_logloss: 0.194777 + 0.0385719
[40]  cv_agg's binary_logloss: 0.141999 + 0.056194
[60]   cv_agg's binary_logloss: 0.140204 + 0.0759544

...

min_data_in_leaf, val_score: 0.094602: 100%|######| 5/5 [00:01<00:00,  3.55it/s]
Best score: 0.09460200054481203
Best params:
{'bagging_fraction': 0.5636995994183087,
 'bagging_freq': 4,
 'feature_fraction': 0.44800000000000006,
 'lambda_l1': 0.015234706690217153,
 'lambda_l2': 2.2227989818062668e-07,
 'metric': 'binary_logloss',
 'min_child_samples': 20,
 'num_leaves': 3,
 'objective': 'binary',
 'verbosity': -1}

探索した中で、最も優れた交差検証のスコアを出したパラメータが確認できた。

LightGBMTunerCV から学習済みモデルを取り出す

基本的な使い方が分かったところで今回の本題に入る。 別に LightGBM に限った話ではないけど、交差検証するときに学習させたモデル群を使って Averaging などをするのは一般的な手法となっている。 とはいえ、交差検証をして得られたパラメータを使って、もう一度同じデータを学習させるのは効率が悪い。 そこで、LightGBMTunerCV を使ってパラメータを探索すると同時に、最適なパラメータで学習したモデルを取り出してみた。

以下がそのサンプルコードになる。 学習済みモデルの取り出しは TunerCVCheckpointCallback というコールバックで実装している。 探索の対象となったパラメータごとにモデルへの参照をインスタンスに保持しておいて、後から取り出せるように作った。

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

import numpy as np
from optuna.integration import lightgbm as lgb
from sklearn import datasets
from sklearn.metrics import accuracy_score
from sklearn.model_selection import StratifiedKFold, train_test_split


class TunerCVCheckpointCallback(object):
    """Optuna の LightGBMTunerCV から学習済みモデルを取り出すためのコールバック"""

    def __init__(self):
        # オンメモリでモデルを記録しておく辞書
        self.cv_boosters = {}

    @staticmethod
    def params_to_hash(params):
        """パラメータを元に辞書のキーとなるハッシュを計算する"""
        params_hash = hash(frozenset(params.items()))
        return params_hash

    def get_trained_model(self, params):
        """パラメータをキーとして学習済みモデルを取り出す"""
        params_hash = self.params_to_hash(params)
        return self.cv_boosters[params_hash]

    def __call__(self, env):
        """LightGBM の各ラウンドで呼ばれるコールバック"""
        # 学習に使うパラメータをハッシュ値に変換する
        params_hash = self.params_to_hash(env.params)
        # 初登場のパラメータならモデルへの参照を保持する
        if params_hash not in self.cv_boosters:
            self.cv_boosters[params_hash] = env.model


def main():
    dataset = datasets.load_breast_cancer()
    X, y = dataset.data, dataset.target

    # デモ用にデータセットを分割する
    X_train, X_test, y_train, y_test = train_test_split(X, y,
                                                        shuffle=True,
                                                        random_state=42)

    lgb_train = lgb.Dataset(X_train, y_train)

    folds = StratifiedKFold(n_splits=3, shuffle=True, random_state=42)

    # 学習済みモデルへの参照を保持するためのコールバック
    checkpoint_cb = TunerCVCheckpointCallback()
    callbacks = [
        checkpoint_cb,
    ]

    lgbm_params = {
        'objective': 'binary',
        'metric': 'binary_logloss',
        'verbosity': -1,
    }
    tuner_cv = lgb.LightGBMTunerCV(
        lgbm_params, lgb_train,
        num_boost_round=1000,
        early_stopping_rounds=100,
        verbose_eval=20,
        folds=folds,
        callbacks=callbacks,
    )

    tuner_cv.run()

    # NOTE: 念のためハッシュの衝突に備えて Trial の数と学習済みモデルの数を比較しておく
    assert len(checkpoint_cb.cv_boosters) == len(tuner_cv.study.trials) - 1

    # 最も良かったパラメータをキーにして学習済みモデルを取り出す
    cv_booster = checkpoint_cb.get_trained_model(tuner_cv.best_params)
    # Averaging でホールドアウト検証データを予測する
    y_pred_proba_list = cv_booster.predict(X_test,
                                           num_iteration=cv_booster.best_iteration)
    y_pred_proba_avg = np.array(y_pred_proba_list).mean(axis=0)
    y_pred = np.where(y_pred_proba_avg > 0.5, 1, 0)
    accuracy = accuracy_score(y_test, y_pred)
    print('Averaging accuracy:', accuracy)


if __name__ == '__main__':
    main()

ちなみに、学習に使ったパラメータをハッシュにしてモデルへの参照のキーにしている。 そのため、非常に低い確率だけどハッシュが衝突する可能性を考えて assert 文を入れた。

上記をファイルに保存して実行してみよう。 動作確認のために、ホールドアウトしておいたデータを、取り出したモデル (実体は _CVBooster) を使って推論させている。

$ python tunercv2.py 
/Users/amedama/.virtualenvs/py37/lib/python3.7/site-packages/optuna/_experimental.py:83: ExperimentalWarning: LightGBMTunerCV is experimental (supported from v1.5.0). The interface can change in the future.
  ExperimentalWarning,
feature_fraction, val_score: inf:   0%|                   | 0/7 [00:00<?, ?it/s][20] cv_agg's binary_logloss: 0.191389 + 0.0261761
[40]  cv_agg's binary_logloss: 0.133308 + 0.0423785
[60]   cv_agg's binary_logloss: 0.119175 + 0.0400074

...

min_data_in_leaf, val_score: 0.092142: 100%|######| 5/5 [00:01<00:00,  2.51it/s]
Averaging accuracy: 0.972027972027972

交差検証で最も良いスコアを出したモデルたちの推論結果を Averaging した結果として 0.972 という Accuracy が得られた。

いじょう。

kind (Kubernetes IN Docker) を使ってみる

今回は Kubernetes の開発で使われている公式ツールの kind を使ってみる。 このツールを使うと Docker のコンテナを使って Kubernetes のクラスタが素早く簡単に構築できる。

使った環境は次のとおり。

$ sw_vers                        
ProductName:    macOS
ProductVersion: 11.6
BuildVersion:   20G165
$ uname -m
arm64
$ kind version                             
kind v0.11.1 go1.16.4 darwin/arm64
$ kubectl version
Client Version: version.Info{Major:"1", Minor:"21", GitVersion:"v1.21.5", GitCommit:"aea7bbadd2fc0cd689de94a54e5b7b758869d691", GitTreeState:"clean", BuildDate:"2021-09-15T21:10:45Z", GoVersion:"go1.16.8", Compiler:"gc", Platform:"darwin/arm64"}
Server Version: version.Info{Major:"1", Minor:"21", GitVersion:"v1.21.1", GitCommit:"5e58841cce77d4bc13713ad2b91fa0d961e69192", GitTreeState:"clean", BuildDate:"2021-05-21T23:06:30Z", GoVersion:"go1.16.4", Compiler:"gc", Platform:"linux/arm64"}
$ docker version
Client:
 Cloud integration: 1.0.17
 Version:           20.10.8
 API version:       1.41
 Go version:        go1.16.6
 Git commit:        3967b7d
 Built:             Fri Jul 30 19:55:20 2021
 OS/Arch:           darwin/arm64
 Context:           default
 Experimental:      true

Server: Docker Engine - Community
 Engine:
  Version:          20.10.8
  API version:      1.41 (minimum version 1.12)
  Go version:       go1.16.6
  Git commit:       75249d8
  Built:            Fri Jul 30 19:53:48 2021
  OS/Arch:          linux/arm64
  Experimental:     false
 containerd:
  Version:          1.4.9
  GitCommit:        e25210fe30a0a703442421b0f60afac609f950a3
 runc:
  Version:          1.0.1
  GitCommit:        v1.0.1-0-g4144b63
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0

もくじ

下準備

はじめに Docker Desktop をインストールしておく。

$ brew install --cask docker

Docker を起動する。

$ open /Applications/Docker.app

docker version コマンドを実行する。 クライアントとサーバのバージョンが正しく表示されることを確認する。

$ docker version
Client:
 Cloud integration: 1.0.17
 Version:           20.10.8
 API version:       1.41
 Go version:        go1.16.6
 Git commit:        3967b7d
 Built:             Fri Jul 30 19:55:20 2021
 OS/Arch:           darwin/arm64
 Context:           default
 Experimental:      true

Server: Docker Engine - Community
 Engine:
  Version:          20.10.8
  API version:      1.41 (minimum version 1.12)
  Go version:       go1.16.6
  Git commit:       75249d8
  Built:            Fri Jul 30 19:53:48 2021
  OS/Arch:          linux/arm64
  Experimental:     false
 containerd:
  Version:          1.4.9
  GitCommit:        e25210fe30a0a703442421b0f60afac609f950a3
 runc:
  Version:          1.0.1
  GitCommit:        v1.0.1-0-g4144b63
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0

続いて kind をインストールする。 macOS なら Homebrew で入る。

$ brew install kind

これで kind コマンドが使えるようになる。

$ kind version   
kind v0.11.1 go1.16.4 darwin/arm64

Kubernetes クラスタを作る

kind を使って Kubernetes のクラスタを作るには kind create cluster コマンドを使う。 最初に実行したときは Kubernetes のノード用のイメージファイルをダウンロードするので時間がかかる。

$ kind create cluster
Creating cluster "kind" ...
 ✓ Ensuring node image (kindest/node:v1.21.1) 🖼 
 ✓ Preparing nodes 📦  
 ✓ Writing configuration 📜 
 ✓ Starting control-plane 🕹️ 
 ✓ Installing CNI 🔌 
 ✓ Installing StorageClass 💾 
Set kubectl context to "kind-kind"
You can now use your cluster with:

kubectl cluster-info --context kind-kind

Not sure what to do next? 😅  Check out https://kind.sigs.k8s.io/docs/user/quick-start/

コマンドの実行が完了すると、デフォルトの名前 (kind) でクラスタができる。

$ kind get clusters
kind

kubectl コマンドの設定ファイルも自動で作られるので、すぐ使える状態になってる。

$  kubectl config get-contexts
CURRENT   NAME        CLUSTER     AUTHINFO    NAMESPACE
*         kind-kind   kind-kind   kind-kind   
$ kubectl config view --minify
apiVersion: v1
clusters:
- cluster:
    certificate-authority-data: DATA+OMITTED
    server: https://127.0.0.1:49921
  name: kind-kind
contexts:
- context:
    cluster: kind-kind
    user: kind-kind
  name: kind-kind
current-context: kind-kind
kind: Config
preferences: {}
users:
- name: kind-kind
  user:
    client-certificate-data: REDACTED
    client-key-data: REDACTED

デフォルトではシングルノード構成でクラスタができる。

$ kubectl get nodes
NAME                 STATUS   ROLES                  AGE    VERSION
kind-control-plane   Ready    control-plane,master   103s   v1.21.1

docker container list コマンドを使って稼働しているコンテナを見ると Kubernetes のコントロールプレーンのコンテナが見える。

$ docker container list
CONTAINER ID   IMAGE                  COMMAND                  CREATED         STATUS         PORTS                       NAMES
eec5aa7fa35d   kindest/node:v1.21.1   "/usr/local/bin/entr…"   2 minutes ago   Up 2 minutes   127.0.0.1:49921->6443/tcp   kind-control-plane

コンテナの中身を覗くと、必要なサービスが色々と立ち上がっているようだ。

$ docker container exec -it eec5aa7fa35d ss -tlnp
State     Recv-Q    Send-Q       Local Address:Port        Peer Address:Port    Process                                                                         
LISTEN    0         4096            172.18.0.2:2379             0.0.0.0:*        users:(("etcd",pid=675,fd=6))                                                  
LISTEN    0         4096             127.0.0.1:2379             0.0.0.0:*        users:(("etcd",pid=675,fd=5))                                                  
LISTEN    0         4096            172.18.0.2:2380             0.0.0.0:*        users:(("etcd",pid=675,fd=3))                                                  
LISTEN    0         4096             127.0.0.1:2381             0.0.0.0:*        users:(("etcd",pid=675,fd=10))                                                 
LISTEN    0         4096             127.0.0.1:10257            0.0.0.0:*        users:(("kube-controller",pid=607,fd=7))                                       
LISTEN    0         4096             127.0.0.1:10259            0.0.0.0:*        users:(("kube-scheduler",pid=546,fd=7))                                        
LISTEN    0         4096             127.0.0.1:42015            0.0.0.0:*        users:(("containerd",pid=183,fd=13))                                           
LISTEN    0         4096             127.0.0.1:10248            0.0.0.0:*        users:(("kubelet",pid=722,fd=23))                                              
LISTEN    0         4096             127.0.0.1:10249            0.0.0.0:*        users:(("kube-proxy",pid=957,fd=16))                                           
LISTEN    0         1024            127.0.0.11:33609            0.0.0.0:*                                                                                       
LISTEN    0         4096                     *:6443                   *:*        users:(("kube-apiserver",pid=593,fd=7))                                        
LISTEN    0         4096                     *:10256                  *:*        users:(("kube-proxy",pid=957,fd=18))                                           
LISTEN    0         4096                     *:10250                  *:*        users:(("kubelet",pid=722,fd=27))

ちなみに、推奨される環境としては Docker のランタイムに 6 ~ 8GB 以上のメモリを割り当てることが望ましいらしい。

以下のコマンドで、今のランタイムがどれくらい使えるか確認できる。 デフォルトでは 2GB になっている可能性があるので必要に応じて割り当てを増やそう。

$ docker stats --no-stream
CONTAINER ID   NAME                 CPU %     MEM USAGE / LIMIT   MEM %     NET I/O           BLOCK I/O         PIDS
eec5aa7fa35d   kind-control-plane   132.22%   358.5MiB / 5.8GiB   6.04%     2.98kB / 1.34kB   18.1MB / 65.5kB   125

複数のクラスタを作る・壊す

特定の名前でクラスタを作りたいときは --name オプションを指定する。

$ kind create cluster --name kind-2

これで、2 つのクラスタができた。

$ kind get clusters
kind
kind-2

kubectl にも複数のクラスタが登録されている。

$ kubectl config get-contexts
CURRENT   NAME          CLUSTER       AUTHINFO      NAMESPACE
          kind-kind     kind-kind     kind-kind     
*         kind-kind-2   kind-kind-2   kind-kind-2   

クラスタを消すときは kind delete cluster コマンドを使う。

$ kind delete cluster --name kind-2

消したら、コンテキストを元のクラスタに切り替えておく。

$ kubectl config use-context kind-kind
Switched to context "kind-kind".
$ kubectl config current-context      
kind-kind

自前のコンテナイメージを使って Pod を立ち上げてみる

次は、試しに自前のコンテナイメージを使って Pod を立ち上げてみよう。

とりあえず、適当に WSGI でホスト名を返すアプリケーションを用意する。

$ cat << 'EOF' > server.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import os
from wsgiref.simple_server import make_server


def application(environ, start_response):
    start_response('200 OK', [('Content-Type', 'text/plain')])
    uname = os.uname()
    nodename = uname.nodename
    msg = f'Hello, World! by {nodename=}\n'
    return [msg.encode('ascii')]


def main():
    with make_server('', 8080, application) as httpd:
        print("Serving on port 8080...")
        httpd.serve_forever()

if __name__ == '__main__':
    main()
EOF

上記のアプリケーションを組み込んだ Dockerfile を用意する。

$ cat << 'EOF' > Dockerfile
FROM python:3.9

EXPOSE 8080

COPY server.py .

CMD python3 server.py
EOF

Docker イメージをビルドする。

$ docker build -t example/helloworld:0.1 .

手元のイメージは kind load docker-image コマンドを使ってクラスタに登録できる。

$ kind load docker-image example/helloworld:0.1

次のように自前のイメージを使って Pod を立ち上げるマニフェストファイルを用意する。 ポイントは imagePullPolicy: Never で、これで必ずローカルのイメージが使われるようになる。

$ cat << 'EOF' > helloworld.yaml
apiVersion: v1
kind: Pod
metadata:
  name: helloworld-pod
spec:
  containers:
  - name: helloworld-server
    image: example/helloworld:0.1
    imagePullPolicy: Never
EOF

マニフェストファイルを適用する。

$ kubectl apply -f helloworld.yaml
pod/helloworld-pod created

すると、次のように Pod が立ち上がる。

$ kubectl get pods
NAME             READY   STATUS    RESTARTS   AGE
helloworld-pod   1/1     Running   0          12s

ポートフォワーディングで Pod の TCP:8080 ポートを引き出してみよう。

$ kubectl port-forward helloworld-pod 8080:8080
Forwarding from 127.0.0.1:8080 -> 8080
Forwarding from [::1]:8080 -> 8080

別のターミナルからリクエストを送ると、ちゃんと HTTP のレスポンスが得られる。

$ curl http://localhost:8080
Hello, World! by nodename='helloworld-pod'

kubectl exec コマンドで Pod に入っても、ちゃんとプロセスが稼働していることが確認できる。

$ kubectl exec -it helloworld-pod -- /bin/bash
root@helloworld-pod:/# uname -a
Linux helloworld-pod 5.10.47-linuxkit #1 SMP PREEMPT Sat Jul 3 21:50:16 UTC 2021 aarch64 GNU/Linux
root@helloworld-pod:/# apt-get update && apt-get -y install iproute2
root@helloworld-pod:/# ss -tlnp
State                   Recv-Q                  Send-Q                                   Local Address:Port                                   Peer Address:Port                 Process                 
LISTEN                  0                       5                                              0.0.0.0:8080                                        0.0.0.0:*                     users:(("python3",pid=8,fd=3))
root@helloworld-pod:/# exit

マルチノードクラスタを作ってみる

kind の良いところは手軽にマルチノードクラスタも作れるところ。

一旦、シングルノードのクラスタは削除しておく。

$ kind delete cluster

そして、以下のような kind 用の設定ファイルを用意する。 これでコントロールプレーンとワーカーノード x2 のクラスタが作れる。

$ cat << 'EOF' > kind-multi-node-cluster.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
- role: worker
- role: worker
EOF

設定ファイルを使ってクラスタを作る。

$ kind create cluster \                      
    --name kind-multi \
    --config kind-multi-node-cluster.yaml
Creating cluster "kind-multi" ...
 ✓ Ensuring node image (kindest/node:v1.21.1) 🖼 
 ✓ Preparing nodes 📦 📦 📦  
 ✓ Writing configuration 📜 
 ✓ Starting control-plane 🕹️ 
 ✓ Installing CNI 🔌 
 ✓ Installing StorageClass 💾 
 ✓ Joining worker nodes 🚜 
Set kubectl context to "kind-kind-multi"
You can now use your cluster with:

kubectl cluster-info --context kind-kind-multi

Have a nice day! 👋

すると、次のように 3 つのノードから成るクラスタができる。

$ kubectl get nodes                            
NAME                       STATUS   ROLES                  AGE   VERSION
kind-multi-control-plane   Ready    control-plane,master   53s   v1.21.1
kind-multi-worker          Ready    <none>                 24s   v1.21.1
kind-multi-worker2         Ready    <none>                 24s   v1.21.1

確認すると、Docker コンテナも 3 つ稼働している。

$ docker container list
CONTAINER ID   IMAGE                  COMMAND                  CREATED              STATUS              PORTS                       NAMES
bcb2f337b802   kindest/node:v1.21.1   "/usr/local/bin/entr…"   About a minute ago   Up About a minute                               kind-multi-worker
eb32f4375353   kindest/node:v1.21.1   "/usr/local/bin/entr…"   About a minute ago   Up About a minute                               kind-multi-worker2
22da177bed79   kindest/node:v1.21.1   "/usr/local/bin/entr…"   About a minute ago   Up About a minute   127.0.0.1:50566->6443/tcp   kind-multi-control-plane

自前のイメージで Deployment を立ち上げてみる

マルチノードのクラスタを使って遊んでいこう。 試しに、複数の Pod を持った Deployment を作ってみる。

まずは先ほどビルドした Docker イメージをマルチノードのクラスタに登録する。

$ kind load docker-image example/helloworld:0.1 \
    --name kind-multi

そして、次のように Deployment 用のマニフェストファイルを用意する。 これで、Deployment x1 / ReplicaSet x1 / Pod x2 のリソースができる。

$ cat << 'EOF' > deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: helloworld-deployment
  labels:
    app: helloworld
spec:
  replicas: 2
  selector:
    matchLabels:
      app: helloworld
  template:
    metadata:
      labels:
        app: helloworld
    spec:
      containers:
      - name: helloworld-server
        image: example/helloworld:0.1
        imagePullPolicy: Never
        ports:
        - containerPort: 8080
EOF

マニフェストファイルを適用する。

$ kubectl apply -f deployment.yaml 
deployment.apps/helloworld-deployment created

すると、次のようにそれぞれのオブジェクトができた。 Pod を見ると、ちゃんとそれぞれのワーカーで動作している様子が確認できる。

$ kubectl get deployments         
NAME                    READY   UP-TO-DATE   AVAILABLE   AGE
helloworld-deployment   2/2     2            2           12s
$ kubectl get replicaset 
NAME                              DESIRED   CURRENT   READY   AGE
helloworld-deployment-d857c4cb6   2         2         2       25s
$ kubectl get pods      
NAME                                    READY   STATUS    RESTARTS   AGE
helloworld-deployment-d857c4cb6-8tvzk   1/1     Running   0          44s
helloworld-deployment-d857c4cb6-zcfl7   1/1     Running   0          44s

Pod に HTTP でアクセスする

続いては上記の Pod に HTTP でアクセスしてみる。 kubectl expose deployment コマンドで Service オブジェクトを作る。

$ kubectl expose deployment helloworld-deployment
service/helloworld-deployment exposed

これでアクセスするための IP アドレス (CLUSTER-IP) が割り当てられた。

$ kubectl get services -l app=helloworld            
NAME                    TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)    AGE
helloworld-deployment   ClusterIP   10.96.190.89   <none>        8080/TCP   102s

正し、この IP アドレスはクラスタ内部向けなので、Kind を実行しているホストからだと直接はアクセスできない。

$ ping -c 3 10.96.190.89  
PING 10.96.190.89 (10.96.190.89): 56 data bytes
Request timeout for icmp_seq 0
Request timeout for icmp_seq 1

--- 10.96.190.89 ping statistics ---
3 packets transmitted, 0 packets received, 100.0% packet loss

そこで、代わりにクラスタのコンテナ経由でアクセスしてみよう。

$ docker container exec -it kind-multi-control-plane curl http://10.96.190.89:8080
Hello, World! by nodename='helloworld-deployment-d857c4cb6-zcfl7'
$ docker container exec -it kind-multi-control-plane curl http://10.96.190.89:8080
Hello, World! by nodename='helloworld-deployment-d857c4cb6-8tvzk'

ちゃんと、それぞれの Pod がリクエストを捌いていることが確認できた。

ちなみに、デフォルトでは --type=LoadBalancer な Service は作れないようだ。

$ kubectl delete service helloworld-deployment
$ kubectl expose deployment helloworld-deployment --type=LoadBalancer
service/helloworld-deployment exposed
$ kubectl get services -l app=helloworld                             
NAME                    TYPE           CLUSTER-IP     EXTERNAL-IP   PORT(S)          AGE
helloworld-deployment   LoadBalancer   10.96.93.210   <pending>     8080:30640/TCP   20s

上記のように、状態が <pending> となっており、IP アドレスが割り振られない。

確認が済んだら Service を削除しておこう。

$ kubectl delete service helloworld-deployment

オートヒーリングを確認する

続いてはオートヒーリングの様子を観察するために Pod を壊してみよう。

まずは今の Pod の状態をかくにんしておく。

$ kubectl get pods -l app=helloworld          
NAME                                    READY   STATUS    RESTARTS   AGE
helloworld-deployment-d857c4cb6-8tvzk   1/1     Running   0          8m9s
helloworld-deployment-d857c4cb6-zcfl7   1/1     Running   0          8m9s

ここで、Pod を片方削除してみよう。

$ kubectl delete pods helloworld-deployment-d857c4cb6-8tvzk
pod "helloworld-deployment-d857c4cb6-8tvzk" deleted

別のターミナルから Pod の状態を確認すると、指定した Pod が削除されて新たに別の Pod が作られる様子が見える。

$ kubectl get pods -l app=helloworld
NAME                                    READY   STATUS        RESTARTS   AGE
helloworld-deployment-d857c4cb6-8tvzk   0/1     Terminating   0          9m29s
helloworld-deployment-d857c4cb6-ndprj   1/1     Running       0          34s
helloworld-deployment-d857c4cb6-zcfl7   1/1     Running       0          9m29s

最終的には以下のように Pod が 2 つの状態で安定する。

$ kubectl get pods -l app=helloworld
NAME                                    READY   STATUS    RESTARTS   AGE
helloworld-deployment-d857c4cb6-ndprj   1/1     Running   0          45s
helloworld-deployment-d857c4cb6-zcfl7   1/1     Running   0          9m40s

また、ログを確認すると Pod が作り直されていることがわかる。

$ kubectl describe replicaset helloworld-deployment | tail
    Environment:  <none>
    Mounts:       <none>
  Volumes:        <none>
Events:
  Type    Reason            Age    From                   Message
  ----    ------            ----   ----                   -------
  Normal  SuccessfulCreate  11m    replicaset-controller  Created pod: helloworld-deployment-d857c4cb6-8tvzk
  Normal  SuccessfulCreate  11m    replicaset-controller  Created pod: helloworld-deployment-d857c4cb6-zcfl7
  Normal  SuccessfulCreate  2m49s  replicaset-controller  Created pod: helloworld-deployment-d857c4cb6-ndprj

そんな感じで。

Python: Keras でカスタムメトリックを扱う

今回は Keras に組み込みで用意されていない独自の評価指標 (カスタムメトリック) を扱う方法について書いてみる。

なお、Keras でカスタムメトリックを定義する方法については、以下の公式ドキュメントに記載がある。

keras.io

使った環境は次のとおり。 Keras にはスタンドアロン版ではなく TensorFlow 組み込みのもの (tf.keras) を使った。

$ sw_vers  
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G4032
$ python -V                                                       
Python 3.7.7
$ python -c "import tensorflow as tf; print(tf.__version__)"
2.2.0
$ python -c "import tensorflow as tf; print(tf.keras.__version__)"
2.3.0-tf

下準備

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

$ pip install tensorflow

注意点について

Keras のメトリックを定義したオブジェクトには、正解と予測した内容が TensorFlow の Tensor オブジェクトとして渡される。 つまり、一般的な機械学習でおなじみの NumPy 配列のようには扱えない点に注意が必要となる。 そのような事情があるので、カスタムメトリックを計算するときは、はじめに REPL を使ってインタラクティブに動作を確認した方がわかりやすい。

ここでは、そのやり方について書いてみる。 はじめに、Python のインタプリタ (REPL) を起動しよう。

$ python

そして、TensorFlow のパッケージをインポートする。

>>> import tensorflow as tf
>>> from tensorflow.keras import backend as K

正解ラベルと、モデルが出力する予測を模したオブジェクトを次のように用意する。 今回の例は、多値分類問題のラベルを模している。

>>> y_true = tf.constant([[0., 0., 1.], [0., 1., 0.]])
>>> y_pred = tf.constant([[0., 0., 1.], [1., 0., 0.]])

このようにすると、TensorFlow 2 では NumPy の配列として中身が確認できる。

>>> y_true
<tf.Tensor: shape=(2, 3), dtype=float32, numpy=
array([[0., 0., 1.],
       [0., 1., 0.]], dtype=float32)>
>>> y_pred
<tf.Tensor: shape=(2, 3), dtype=float32, numpy=
array([[0., 0., 1.],
       [1., 0., 0.]], dtype=float32)>

このオブジェクトを使って計算方法の動作確認をしていく。

Recall を計算してみる

試しに Recall を計算してみよう。

正解と予測の Tensor で積を取って、両者が一致している部分だけ残す。

>>> y_true * y_pred
<tf.Tensor: shape=(2, 3), dtype=float32, numpy=
array([[0., 0., 1.],
       [0., 0., 0.]], dtype=float32)>

残っている部分を足し合わせれば True Positive の要素数になる。

>>> true_positives = K.sum(y_true * y_pred)
>>> true_positives
<tf.Tensor: shape=(), dtype=float32, numpy=1.0>

正解ラベル部分を足し合わせれば、すべての Positive な要素数が得られる。

>>> total_positives = K.sum(y_true)
>>> total_positives
<tf.Tensor: shape=(), dtype=float32, numpy=2.0>

あとは、 割ってやれば Recall のスコアが得られるという寸法。

>>> recall = true_positives / total_positives
>>> recall
<tf.Tensor: shape=(), dtype=float32, numpy=0.5>

Accuracy を計算してみる

もうひとつの例として Accuracy も計算してみる。

はじめに、正解ラベルと予測ラベルのインデックスを取り出す。

>>> y_true_argmax = K.argmax(y_true)
>>> y_pred_argmax = K.argmax(y_pred)
>>> y_true_argmax
<tf.Tensor: shape=(2,), dtype=int64, numpy=array([2, 1])>
>>> y_pred_argmax
<tf.Tensor: shape=(2,), dtype=int64, numpy=array([2, 0])>

両者が一致しているものを調べる。

>>> y_matched = K.equal(y_true_argmax, y_pred_argmax)
>>> y_matched
<tf.Tensor: shape=(2,), dtype=bool, numpy=array([ True, False])>

あとは平均を計算すれば Accuracy になる。

>>> accuracy = K.mean(y_matched)
>>> accuracy
<tf.Tensor: shape=(), dtype=float32, numpy=0.5>

もちろん、ここで示したのはあくまで一例で、色々な計算方法が考えられる。

カスタムメトリックを定義して組み込む (Stateless)

それでは、実際に前述した処理を使ってカスタムメトリックを定義してみよう。 とはいえ、やることは正解と予測の Tensor を受け取ってメトリックをまた Tensor として返す関数を用意するだけ。 あとは、それを tf.keras.models.Model#compile() メソッドで metrics 引数に渡してやれば良い。

以下のサンプルコードでは MNIST データセットを MLP で予測するときに、カスタムメトリックとして Recall と Accuracy を定義して使っている。

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

from tensorflow.keras import backend as K
from tensorflow.keras.datasets import mnist
from tensorflow.keras.layers import Input
from tensorflow.keras.layers import Dense
from tensorflow.keras.models import Model
from tensorflow.keras.utils import to_categorical
from tensorflow.keras.callbacks import EarlyStopping


def custom_recall(y_true, y_pred):
    """正解と予測の Tensor から Recall を計算する関数"""
    true_positives = K.sum(y_true * y_pred)
    total_positives = K.sum(y_true)
    return true_positives / (total_positives + K.epsilon())  # ゼロ除算対策


def custom_accuracy(y_true, y_pred):
    """正解と予測の Tensor から Accuracy を計算する関数"""
    y_true_argmax = K.argmax(y_true)
    y_pred_argmax = K.argmax(y_pred)
    y_matched = K.equal(y_true_argmax, y_pred_argmax)
    return K.mean(y_matched)


def main():
    # load dataset
    (x_train, y_train), (x_test, y_test) = mnist.load_data()

    # one-hot encode
    y_train = to_categorical(y_train)
    y_test = to_categorical(y_test)

    # flatten
    image_height, image_width = x_train.shape[1:]
    x_train = x_train.reshape(x_train.shape[0], image_height * image_width)
    x_test = x_test.reshape(x_test.shape[0], image_height * image_width)

    # min-max normalize
    x_train = (x_train - x_train.min()) / (x_train.max() - x_train.min())
    x_test = (x_test - x_test.min()) / (x_test.max() - x_test.min())

    # Multi Layer Perceptron
    inputs = Input(shape=(image_height * image_width,))
    x = Dense(64, activation='relu')(inputs)
    x = Dense(64, activation='relu')(x)
    num_of_classes = y_train.shape[1]
    outputs = Dense(num_of_classes, activation='softmax')(x)

    callbacks = [
        # 検証データに対する Recall が 10 エポック改善しないときは学習を打ち切る
        EarlyStopping(monitor='val_custom_recall',
                      patience=10,
                      verbose=1,
                      mode='max'),
    ]
    model = Model(inputs=inputs, outputs=outputs)
    model.compile(optimizer='adam',
                  loss='categorical_crossentropy',
                  # カスタムメトリックを計算する関数を登録する
                  metrics=['accuracy', custom_recall, custom_accuracy],
                  )

    model.fit(x_train, y_train,
              batch_size=8192,
              epochs=1000,
              verbose=1,
              # ホールドアウトデータを検証データとして用いる
              validation_data=(x_test, y_test),
              callbacks=callbacks)


if __name__ == '__main__':
    main()

上記を保存して実行してみよう。 ちゃんと、custom_ から始まる名前でカスタムメトリックが学習過程のログに登場していることがわかる。

$ python stateless.py
...
Epoch 1/1000
8/8 [==============================] - 0s 46ms/step - loss: 2.1424 - accuracy: 0.3151 - custom_recall: 0.1247 - custom_accuracy: 0.3321 - val_loss: 1.8506 - val_accuracy: 0.5677 - val_custom_recall: 0.1703 - val_custom_accuracy: 0.5829
Epoch 2/1000
8/8 [==============================] - 0s 25ms/step - loss: 1.6449 - accuracy: 0.6475 - custom_recall: 0.2220 - custom_accuracy: 0.6544 - val_loss: 1.3144 - val_accuracy: 0.7322 - val_custom_recall: 0.3227 - val_custom_accuracy: 0.7484
Epoch 3/1000
8/8 [==============================] - 0s 31ms/step - loss: 1.1343 - accuracy: 0.7647 - custom_recall: 0.3867 - custom_accuracy: 0.7668 - val_loss: 0.8659 - val_accuracy: 0.8126 - val_custom_recall: 0.5142 - val_custom_accuracy: 0.8298
...
Epoch 207/1000
8/8 [==============================] - 0s 28ms/step - loss: 0.0086 - accuracy: 0.9991 - custom_recall: 0.9927 - custom_accuracy: 0.9991 - val_loss: 0.1126 - val_accuracy: 0.9738 - val_custom_recall: 0.9718 - val_custom_accuracy: 0.9769
Epoch 208/1000
8/8 [==============================] - 0s 28ms/step - loss: 0.0087 - accuracy: 0.9991 - custom_recall: 0.9927 - custom_accuracy: 0.9992 - val_loss: 0.1131 - val_accuracy: 0.9736 - val_custom_recall: 0.9716 - val_custom_accuracy: 0.9766
Epoch 209/1000
8/8 [==============================] - 0s 26ms/step - loss: 0.0086 - accuracy: 0.9992 - custom_recall: 0.9929 - custom_accuracy: 0.9992 - val_loss: 0.1140 - val_accuracy: 0.9743 - val_custom_recall: 0.9717 - val_custom_accuracy: 0.9774
Epoch 00209: early stopping

ただ、上記を見ると組み込みの accuracy と、自分で定義した custom_accuracy の値が一致していない。

カスタムメトリックを定義して組み込む (Stateful)

先ほどの例で組み込みのメトリックと自前のメトリックが一致しなかった理由は、カスタムメトリックを定義する方法に Stateless と Stateful という 2 つのやり方があるため。 組み込みの accuracy は Stateful なやり方で定義されている一方で、先ほど自分で定義した custom_accuracy は Stateless だったので値がズレてしまった。 あらかじめ断っておくと、値がズレているからといって計算が間違っているわけではない。

それでは、次は Stateful なやり方でカスタムメトリックを定義する方法を試してみよう。 Stateful なやり方では、tensorflow.keras.metrics.Metric を継承して必要なメソッドを実装することでメトリックを計算する。

以下のサンプルコードでは Stateful なやり方で Recall と Accuracy を計算している。 Stateful という名のとおり、tensorflow.keras.metrics.Metric では累積的に与えられる正解と予測のラベルからメトリックを計算することになる。 具体的には、update_state() メソッドで正解と予測ラベルが与えられて、結果を result() メソッドから得る。 そして、状態をリセットしたいときには reset_states() メソッドが呼ばれる。

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

import tensorflow as tf
from tensorflow.keras.metrics import Metric
from tensorflow.keras import backend as K
from tensorflow.keras.datasets import mnist
from tensorflow.keras.layers import Input
from tensorflow.keras.layers import Dense
from tensorflow.keras.models import Model
from tensorflow.keras.utils import to_categorical
from tensorflow.keras.callbacks import EarlyStopping


class RecallMetric(Metric):
    """ステートフルに Recall を計算するクラス"""

    def __init__(self, name='custom_recall', *args, **kwargs):
        super().__init__(name=name, *args, **kwargs)
        # 状態を貯めておく変数を用意する
        self.true_positives = tf.Variable(0.)
        self.total_positives = tf.Variable(0.)

    def update_state(self, y_true, y_pred, sample_weight=None):
        """新しく正解と予測が追加で与えられたときの処理"""
        true_positives = K.sum(y_true * y_pred)
        total_positives = K.sum(y_true)

        self.true_positives.assign_add(true_positives)
        self.total_positives.assign_add(total_positives)

    def result(self):
        """現時点の状態から計算されるメトリックを返す"""
        return self.true_positives / (self.total_positives + K.epsilon())

    def reset_states(self):
        """状態をリセットするときに呼ばれるコールバック"""
        self.true_positives.assign(0.)
        self.total_positives.assign(0.)


class AccuracyMetric(Metric):
    """ステートフルに Accuracy を計算するクラス"""

    def __init__(self, name='custom_accuracy', *args, **kwargs):
        super().__init__(name=name, *args, **kwargs)

        self.matched = tf.Variable(0.)
        self.unmatched = tf.Variable(0.)

    def update_state(self, y_true, y_pred, sample_weight=None):
        y_true_argmax = K.argmax(y_true)
        y_pred_argmax = K.argmax(y_pred)

        y_matched = K.sum(K.cast(K.equal(y_true_argmax, y_pred_argmax), dtype='float32'))
        y_unmatched = K.sum(K.cast(K.not_equal(y_true_argmax, y_pred_argmax), dtype='float32'))

        self.matched.assign_add(y_matched)
        self.unmatched.assign_add(y_unmatched)

    def result(self):
        return self.matched / (self.matched + self.unmatched)

    def reset_states(self):
        self.matched.assign(0.)
        self.unmatched.assign(0.)


def main():
    # load dataset
    (x_train, y_train), (x_test, y_test) = mnist.load_data()

    # one-hot encode
    y_train = to_categorical(y_train)
    y_test = to_categorical(y_test)

    # flatten
    image_height, image_width = x_train.shape[1:]
    x_train = x_train.reshape(x_train.shape[0], image_height * image_width)
    x_test = x_test.reshape(x_test.shape[0], image_height * image_width)

    # min-max normalize
    x_train = (x_train - x_train.min()) / (x_train.max() - x_train.min())
    x_test = (x_test - x_test.min()) / (x_test.max() - x_test.min())

    # Multi Layer Perceptron
    inputs = Input(shape=(image_height * image_width,))
    x = Dense(64, activation='relu')(inputs)
    x = Dense(64, activation='relu')(x)
    num_of_classes = y_train.shape[1]
    outputs = Dense(num_of_classes, activation='softmax')(x)

    callbacks = [
        # 検証データに対する Recall が 10 エポック改善しないときは学習を打ち切る
        EarlyStopping(monitor='val_custom_recall',
                      patience=10,
                      verbose=1,
                      mode='max'),
    ]
    model = Model(inputs=inputs, outputs=outputs)
    model.compile(optimizer='adam',
                  loss='categorical_crossentropy',
                  # カスタムメトリックを計算するオブジェクトを登録する
                  metrics=['accuracy', RecallMetric(), AccuracyMetric()],
                  )

    model.fit(x_train, y_train,
              batch_size=8192,
              epochs=1000,
              verbose=1,
              # ホールドアウトデータを検証データとして用いる
              validation_data=(x_test, y_test),
              callbacks=callbacks)


if __name__ == '__main__':
    main()

実際に、上記を実行してみよう。

$ python stateful.py
...
Epoch 1/1000
8/8 [==============================] - 0s 46ms/step - loss: 2.2027 - accuracy: 0.2119 - custom_recall: 0.1161 - custom_accuracy: 0.2119 - val_loss: 1.9262 - val_accuracy: 0.4457 - val_custom_recall: 0.1544 - val_custom_accuracy: 0.4457
Epoch 2/1000
8/8 [==============================] - 0s 25ms/step - loss: 1.7486 - accuracy: 0.5403 - custom_recall: 0.1948 - custom_accuracy: 0.5403 - val_loss: 1.4170 - val_accuracy: 0.6929 - val_custom_recall: 0.2810 - val_custom_accuracy: 0.6929
Epoch 3/1000
8/8 [==============================] - 0s 31ms/step - loss: 1.2274 - accuracy: 0.7467 - custom_recall: 0.3455 - custom_accuracy: 0.7467 - val_loss: 0.9200 - val_accuracy: 0.8056 - val_custom_recall: 0.4659 - val_custom_accuracy: 0.8056
...
Epoch 178/1000
8/8 [==============================] - 0s 26ms/step - loss: 0.0147 - accuracy: 0.9979 - custom_recall: 0.9888 - custom_accuracy: 0.9979 - val_loss: 0.1057 - val_accuracy: 0.9717 - val_custom_recall: 0.9662 - val_custom_accuracy: 0.9717
Epoch 179/1000
8/8 [==============================] - 0s 27ms/step - loss: 0.0145 - accuracy: 0.9980 - custom_recall: 0.9889 - custom_accuracy: 0.9980 - val_loss: 0.1055 - val_accuracy: 0.9714 - val_custom_recall: 0.9664 - val_custom_accuracy: 0.9714
Epoch 180/1000
8/8 [==============================] - 0s 27ms/step - loss: 0.0143 - accuracy: 0.9980 - custom_recall: 0.9891 - custom_accuracy: 0.9980 - val_loss: 0.1063 - val_accuracy: 0.9728 - val_custom_recall: 0.9663 - val_custom_accuracy: 0.9728
Epoch 00180: early stopping

今度は組み込みで用意されている accuracy の値と、自前で定義した custom_accuracy の値が一致していることがわかる。 ちなみに、Stateless と Stateful ではメトリックを計算するデータの区間が異なるために値が微妙にズレる。

まとめ

  • Keras のカスタムメトリックには正解と予測のラベルが Tensor オブジェクトとして渡される
  • カスタムメトリックを定義するには Stateless と Stateful なやり方がある
  • Stateless では正解と予測のラベルを受け取る関数を定義する
  • Stateful では tensorflow.keras.metrics.Metric クラスを継承して正解と予測のラベルからの計算結果をためこむ

PythonとKerasによるディープラーニング

PythonとKerasによるディープラーニング

  • 作者:Francois Chollet
  • 発売日: 2018/05/28
  • メディア: 単行本(ソフトカバー)

Python: gensim の FAST_VERSION 定数の意味について

Python の gensim には自然言語処理 (NLP) に関する様々な実装がある。 そして、その中のいくつかのモジュールには FAST_VERSION という定数が定義されている。 この定数は環境によって異なる値を取って、値によってパフォーマンスが大きく異なる場合がある。

今回は、この数値が何を表しているかについて調べた。 結論から先に述べると、この定数は次のような対応関係にある。

  • -1
    • Cython で書かれた拡張モジュールが使えない
  • 0
    • Cython で書かれた拡張モジュールが使える
    • BLAS のドット積を計算する関数 (dsdot()) が倍精度浮動小数点型 (double) を返す
  • 1
    • Cython で書かれた拡張モジュールが使える
    • BLAS のドット積を計算する関数 (dsdot()) が単精度浮動小数点型 (float) を返す
  • 2
    • Cython で書かれた拡張モジュールが使える
    • BLAS のドット積を計算する関数 (dsdot()) が使えない
      • 代わりに Cython でループする

なお、パフォーマンス的には 0 > 1 > 2 > -1 だと考えられる。 また、これはあくまで「現時点で既知の値に関しては」なので、将来的に変わったり異なるモジュールは異なる意味になる可能性がある。

使った環境は次のとおり。

$ sw_vers               
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G4032
$ python -V                                                                       
Python 3.7.7
$ pip list | grep -i gensim                                           
gensim          3.8.3

下準備

とりあえず gensim をインストールしておく。

$ pip install gensim

gensim の FAST_VERSION について

たとえば、Word2Vec や fastText のモジュールに FAST_VERSION という定数が定義されている。 Word2Vec のものについて、定数の値を追ってみよう。 はじめに次の場所で、特定のモジュールがインポートできないときに -1 を取る。

gensim/word2vec.py at 3.8.3 · RaRe-Technologies/gensim · GitHub

インポートしようとしたモジュールでは Cython の拡張モジュールが使われている。 定数を初期化しているのは以下の関数で、それぞれの意味について記述されている。

gensim/word2vec_inner.pyx at 3.8.3 · RaRe-Technologies/gensim · GitHub

意味は前述したとおりで、0 または 1 なら Cython の拡張モジュールで BLAS の dsdot() 関数が使える。 2 のときは使えないので、代わりにドット積を計算するのに Cython のループを使うことになる。

Cython の拡張モジュールが使えない場合 (FAST_VERSION == -1) のパフォーマンス

試しに、Cython の拡張モジュールが使えない環境を用意した。

$ python -c "import gensim;print(gensim.models.word2vec.FAST_VERSION)" 2>/dev/null
-1
$ python -c "import gensim;print(gensim.models.fasttext.FAST_VERSION)" 2>/dev/null
-1

Text8 コーパスを使って Word2Vec を学習させてみよう。 はじめに、コーパスをダウンロードして展開する。

$ wget http://mattmahoney.net/dc/text8.zip
$ unzip text8.zip

次のようなベンチマーク用のモジュールを用意する。

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

import logging

from gensim.models.word2vec import Text8Corpus
from gensim.models import Word2Vec


def main():
    # 学習の過程をログに残す
    log_format = "%(threadName)s - %(name)s - %(levelname)s - %(message)s"
    logging.basicConfig(format=log_format, level=logging.INFO)

    # Text8 コーパスを Word2Vec で学習する
    corpus = Text8Corpus('text8')
    Word2Vec(
        corpus,
        sg=1,  # Skip-gram タスクを最適化する
        hs=1,  # 損失の計算に Hierarchical Softmax を使う
        negative=5,  # ポジティブサンプルに対するネガティブサンプル (ノイジーワード) の比率
        size=50,  # 埋め込む次元数
        iter=3,  # 学習エポック数
    )


if __name__ == "__main__":
    main()

上記に適用な名前をつけたら実行してみよう。 なお、実行が完了まで見守る必要はない。 学習時に出力されるスループットさえ確認できれば良い。

$ python benchmark.py 
unable to import 'smart_open.gcs', disabling that module
MainThread - gensim.models.base_any2vec - WARNING - consider setting layer size to a multiple of 4 for greater performance
/Users/amedama/.virtualenvs/py37/lib/python3.7/site-packages/gensim/models/base_any2vec.py:743: UserWarning: C extension not loaded, training will be slow. Install a C compiler and reinstall gensim for fast training.
  "C extension not loaded, training will be slow. "
MainThread - gensim.models.word2vec - INFO - collecting all words and their counts
MainThread - gensim.models.word2vec - INFO - PROGRESS: at sentence #0, processed 0 words, keeping 0 word types
...
MainThread - gensim.models.base_any2vec - INFO - training model with 3 workers on 71290 vocabulary and 50 features, using sg=1 hs=1 sample=0.001 negative=5 window=5
MainThread - gensim.models.base_any2vec - INFO - EPOCH 1 - PROGRESS: at 0.06% examples, 151 words/s, in_qsize 5, out_qsize 0
MainThread - gensim.models.base_any2vec - INFO - EPOCH 1 - PROGRESS: at 0.18% examples, 447 words/s, in_qsize 6, out_qsize 0
MainThread - gensim.models.base_any2vec - INFO - EPOCH 1 - PROGRESS: at 0.24% examples, 309 words/s, in_qsize 5, out_qsize 0
MainThread - gensim.models.base_any2vec - INFO - EPOCH 1 - PROGRESS: at 0.29% examples, 379 words/s, in_qsize 5, out_qsize 0

上記を見ると、だいたい 300 ~ 400 words/s くらいかな? ちなみに、実行直後には「C 拡張が使えないので遅くなるよ」という旨の警告が表示されている。

Cython の拡張モジュールが使える場合 (FAST_VERSION == 0) のパフォーマンス

続いては拡張モジュールが使える場合で、特に BLAS の dsdot() 関数が倍精度浮動小数点型 (double) を返す環境を用意した。

$ python -c "import gensim;print(gensim.models.word2vec.FAST_VERSION)" 2>/dev/null
0

この環境は、たとえば C コンパイラや OpenBLAS が使える環境でソースコードから gensim をビルドすればできると思う。たぶん。

$ xcode-select --install
$ brew install openblas
$ pip install -U --no-binary gensim gensim

この環境でも、先ほどのベンチマーク用の実行してみよう。

$ python benchmark.py                                                             
unable to import 'smart_open.gcs', disabling that module
MainThread - gensim.models.base_any2vec - WARNING - consider setting layer size to a multiple of 4 for greater performance
MainThread - gensim.models.word2vec - INFO - collecting all words and their counts
MainThread - gensim.models.word2vec - INFO - PROGRESS: at sentence #0, processed 0 words, keeping 0 word types
...
MainThread - gensim.models.base_any2vec - INFO - training model with 3 workers on 71290 vocabulary and 50 features, using sg=1 hs=1 sample=0.001 negative=5 window=5
MainThread - gensim.models.base_any2vec - INFO - EPOCH 1 - PROGRESS: at 1.47% examples, 177603 words/s, in_qsize 5, out_qsize 0
MainThread - gensim.models.base_any2vec - INFO - EPOCH 1 - PROGRESS: at 3.06% examples, 187492 words/s, in_qsize 5, out_qsize 0
MainThread - gensim.models.base_any2vec - INFO - EPOCH 1 - PROGRESS: at 4.64% examples, 189657 words/s, in_qsize 5, out_qsize 0
MainThread - gensim.models.base_any2vec - INFO - EPOCH 1 - PROGRESS: at 6.23% examples, 190525 words/s, in_qsize 5, out_qsize 0
...

すると、こちらの環境では 180k ~ 190k words/s のスループットが出ている。 Cython の拡張モジュールを使う場合と使わない場合で、だいたい 500 倍の違いが出た。

おまけ: Word2Vec や fastText の学習時に CPU の論理コアを使い切る

ちなみに現行の Word2Vec や fastText を学習させるときはワーカープロセスの数が 3 で固定されている。 そのため CPU のコア数が多い環境では、そこが学習のボトルネックになる。

CPU のコアを使い切りたいときは、multiprocessing モジュールをインポートして...

import multiprocessing

Word2Vec や FastText の引数として workers に環境の論理コア数を指定する。

    workers=multiprocessing.cpu_count(),  # 論理コア数分のワーカープロセスを使って学習する

同じようにベンチマークを実行してみよう。

$ python benchmark.py
...
MainThread - gensim.models.base_any2vec - INFO - EPOCH 1 - PROGRESS: at 2.41% examples, 294657 words/s, in_qsize 16, out_qsize 0
MainThread - gensim.models.base_any2vec - INFO - EPOCH 1 - PROGRESS: at 5.11% examples, 312823 words/s, in_qsize 15, out_qsize 0
MainThread - gensim.models.base_any2vec - INFO - EPOCH 1 - PROGRESS: at 8.00% examples, 323193 words/s, in_qsize 15, out_qsize 0
MainThread - gensim.models.base_any2vec - INFO - EPOCH 1 - PROGRESS: at 10.58% examples, 321791 words/s, in_qsize 15, out_qsize 1
MainThread - gensim.models.base_any2vec - INFO - EPOCH 1 - PROGRESS: at 13.35% examples, 324695 words/s, in_qsize 15, out_qsize 0
...

今回の環境は CPU の論理コアが 8 あるので、さらにスループットを上げることができた。

めでたしめでたし。

Python: gensim を使った Word Embedding の内省的評価について

以下の書籍では、Word Embedding の評価方法として内省的評価 (intrinsic evaluation) と外省的評価 (extrinsic evaluation) という 2 つのやり方が紹介されている。 内省的評価では、人間が判断した単語間の類似度や、単語の持つ意味を使ったアナロジーを、Word Embedding が適切に表現できているかを評価する。 それに対し、外省的評価では Word Embedding を応用する先となる最終的な目的を表したタスクを使って評価する。

今回は、gensim に用意されている内省的評価の仕組みがどんなことをやっているのか気になって調べた内容を書いてみる。

使った環境は次のとおり。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G4032
$ python -V
Python 3.7.7

下準備

はじめに、gensim をインストールしておく。

$ pip install gensim

続いて、評価したい Pretrained Word Embeddings を用意する。 今回は Facebook の公開している fastText を選んだ。 なお、評価用データとして gensim に組み込みで用意されている英語のコーパスを使うので、ひとまず英語を使ったものにする。 このファイルはサイズが 6GB ほどあるので結構な時間がかかる。

$ wget https://dl.fbaipublicfiles.com/fasttext/vectors-wiki/wiki.en.vec

ダウンロードできたら Python のインタプリタを起動しておく。

$ python

先ほどダウンロードした Pretrained Word Embeddings を gensim から読み込む。

>>> import gensim
>>> model = gensim.models.KeyedVectors.load_word2vec_format('wiki.en.vec', binary=False)

ちなみに、この操作にもかなりの時間 (数分以上) がかかるので気長に待つ。

内省的評価に使うデータのフォーマットについて

gensim には、内省的評価をするための API として単語間類似度を使ったものとアナロジータスクがサポートされている。 そして、英語のコーパスに関してはラベル付きデータも組み込みで提供されている。 ひとまず、どういったフォーマットになっているか紹介しておく。

単語間類似度

はじめに単語間類似度について。 単語間類似度は、2 つの単語について、どれだけ類似しているかを人間の主観で評価したもの。 単語間類似度を評価するためのデータとして、gensim では wordsim353.tsv というデータが組み込みで提供されている。

ファイルの場所は次のようにして gensim.test.utils.datapath() を使って得られる。

$ python -c "from gensim.test.utils import datapath; print(datapath('wordsim353.tsv'))" 2>/dev/null
/Users/amedama/.virtualenvs/py37/lib/python3.7/site-packages/gensim/test/test_data/wordsim353.tsv

先頭について確認すると、こんな感じ。 タブ区切りで、ある単語と別の単語の類似度が浮動小数点で記述されている。 シャープ (#) からはじまる行は、単なるコメントなので読み飛ばして構わない。

$ head $(python -c "from gensim.test.utils import datapath; print(datapath('wordsim353.tsv'))" 2>/dev/null)
# The WordSimilarity-353 Test Collection (http://www.cs.technion.ac.il/~gabr/resources/data/wordsim353/)
# Word 1   Word 2  Human (mean)
love    sex 6.77
tiger   cat 7.35
tiger   tiger   10.00
book    paper   7.46
computer    keyboard    7.62
computer    internet    7.58
plane   car 5.77
train   car 6.31

アナロジータスク

もうひとつのアナロジータスクでは、特定の単語の分散表現に別の単語を足したり引いたりして目当ての単語となるかどうかを評価する。 アナロジータスクを評価するためのデータとして、gensim では questions-words.txt というデータが組み込みで提供されている。

先頭について確認すると以下のとおり。 基本的に、スペース区切りで 4 つの単語が並んでいる。 1 列目の単語から 2 列目の単語を引いて、3 列目の単語を足したとき 4 列目の単語になるかを評価することになる。 先頭のコロン (:) からはじまる行については、タスクのジャンルを表している。 ちなみに、タスクのジャンルのことはセクションと呼ぶようだ。

$ head $(python -c "from gensim.test.utils import datapath; print(datapath('questions-words.txt'))" 2>/dev/null)
: capital-common-countries
Athens Greece Baghdad Iraq
Athens Greece Bangkok Thailand
Athens Greece Beijing China
Athens Greece Berlin Germany
Athens Greece Bern Switzerland
Athens Greece Cairo Egypt
Athens Greece Canberra Australia
Athens Greece Hanoi Vietnam
Athens Greece Havana Cuba

単語間類似度を使った評価

それでは、実際に単語間類似度を使った評価を試してみよう。 単語間類似度は、WordEmbeddingsKeyedVectors#evaluate_word_pairs() を使って評価する。

>>> from gensim.test.utils import datapath
>>> similarities = model.evaluate_word_pairs(datapath('wordsim353.tsv'))

得られる結果はタプルで、最初の要素がピアソンの相関係数になっている。 中身もまたタプルになってるけど、先頭が相関係数で二番目は「相関がないこと」を帰無仮説とした仮説検定の p-value らしい。

>>> similarities[0]
(0.6987675497727828, 5.237592231656601e-53)

次の要素はスピアマンの相関係数で、実装には scipy を使っているようだ。

>>> similarities[1]
SpearmanrResult(correlation=0.7388081960366618, pvalue=3.98104844873057e-62)

評価用のデータに記述された単語間の類似度を表す点数と、Word Embedding が出した単語ベクトル間のコサイン類似度の相関が高いほど、より優れていると評価することになる。

アナロジータスクを使った評価

続いてアナロジータスクを使った評価を試してみる。 アナロジータスクは WordEmbeddingsKeyedVectors#evaluate_word_analogies() を使って評価する。

>>> analogy_scores = model.evaluate_word_analogies(datapath('questions-words.txt'))

上記では、前述したとおり 1 列目の単語から 2 列目の単語を引いて、3 列目の単語を出した結果が 4 列目の単語になるか評価している。 ただし、ピンポイントで一致しなくともコーパスの中でベクトルが最も似ている TOP5 の中にさえ入っていれば正解としているようだ。

メソッドの返り値として得られるのは、こちらもタプルとなっている。 最初の要素は、正解ラベルが TOP5 に入ったか否かの二値で評価した Accuracy となっている。 この値が高いほど、より優れた Word Embedding と捉えることになる。

>>> analogy_scores[0]
0.7492042304138001

次の要素はリストで、これはアナロジータスクをセクションごとに正解したデータと不正解したデータで分けたもの。 questions-words.txt は 15 のセクションに分かれているらしい。

>>> type(analogy_scores[1])
<class 'list'>
>>> len(analogy_scores[1])
15

それぞれの要素は辞書になっている。

>>> analogy_scores[1][0].keys()
dict_keys(['section', 'correct', 'incorrect'])

内容を確認すると、セクションの名前や正解したタスク、正解できなかったタスクが入っている。

>>> analogy_scores[1][0]['section']
'capital-common-countries'
>>> from pprint import pprint
>>> pprint(analogy_scores[1][0]['correct'][:10])
[('ATHENS', 'GREECE', 'BAGHDAD', 'IRAQ'),
 ('ATHENS', 'GREECE', 'BANGKOK', 'THAILAND'),
 ('ATHENS', 'GREECE', 'BEIJING', 'CHINA'),
 ('ATHENS', 'GREECE', 'BERLIN', 'GERMANY'),
 ('ATHENS', 'GREECE', 'BERN', 'SWITZERLAND'),
 ('ATHENS', 'GREECE', 'CAIRO', 'EGYPT'),
 ('ATHENS', 'GREECE', 'CANBERRA', 'AUSTRALIA'),
 ('ATHENS', 'GREECE', 'HANOI', 'VIETNAM'),
 ('ATHENS', 'GREECE', 'HAVANA', 'CUBA'),
 ('ATHENS', 'GREECE', 'HELSINKI', 'FINLAND')]
>>> pprint(analogy_scores[1][0]['incorrect'][:10])
[('ATHENS', 'GREECE', 'LONDON', 'ENGLAND'),
 ('BAGHDAD', 'IRAQ', 'CANBERRA', 'AUSTRALIA'),
 ('BAGHDAD', 'IRAQ', 'LONDON', 'ENGLAND'),
 ('BANGKOK', 'THAILAND', 'LONDON', 'ENGLAND'),
 ('BEIJING', 'CHINA', 'LONDON', 'ENGLAND'),
 ('BERN', 'SWITZERLAND', 'LONDON', 'ENGLAND'),
 ('CAIRO', 'EGYPT', 'LONDON', 'ENGLAND'),
 ('CANBERRA', 'AUSTRALIA', 'LONDON', 'ENGLAND'),
 ('HANOI', 'VIETNAM', 'LONDON', 'ENGLAND'),
 ('HANOI', 'VIETNAM', 'BERLIN', 'GERMANY')]

最初の要素で示されている Accuracy の値を、次の要素を使って検算してみよう。

>>> from itertools import chain
>>> correct = len(list(chain.from_iterable(s['correct'] for s in analogy_scores[1])))
>>> incorrect = len(list(chain.from_iterable(s['incorrect'] for s in analogy_scores[1])))
>>> correct / (correct + incorrect)
0.7492042304138001

上記のとおり、ちゃんと値が一致した。

いじょう。