Pythonの機械学習プロジェクトにおけるプログラミング設計

テクノロジー

タイトルの通り、Pythonの機械学習プロジェクトにおけるプログラミング設計について、最近私が意識していることを書いてみたいと思います。

イメージ画像

はじめに

この内容が役に立つかもしれない人は、機械学習のプログラミングをする人で、

という人です。

設計方針

設計は「何に備えるか」を考える事に近いと思います。
通常私が機械学習プロジェクトで意識しているのは以下のような点です。

設定により振る舞いを簡単に変えることができる

例えば、「前処理の方法」「Modelのレイヤー数」「学習するEpoch数」のような局所的な振る舞いから、開発環境 or Staging環境 or Production環境毎に異なる「データソースの場所」や「権限情報などの機密データ」の指定、のようなものまで色々あります。
基本的にはこれらは「設定」により切り替えられるようにします。

ログはちゃんと残す

バッチ的に動くことが多いので、ログは非常に重要です。
時刻付きで出力したり、ログレベル(どれだけ詳細にログを出力するか)が調整できることが大事です。

機械学習モデルの切り替えができるようにする

大抵はある程度性能を出すために、まだまだ人手によるモデルの試行錯誤が必要です。
単なるパラメータの違いは設定で変更できるようにできますが、
昨日とは全く違った新しい構造のモデルが発表され、試したくなることも多いでしょう。
そういう時に、新しい部分だけ記述することで、動作するようになっていると幸せです。

他のプログラムから起動しやすいようにする

特にハイパーパラメータの探索などの場合、複数のハイパーパラメータでの学習と評価を何度も行うことが多いです。そのような場合は、別プロセスで起動する方が何かと影響が少なくて良い気がします。
そういうケースを想定して、学習や評価を他のプログラムから別プロセスで実行しやすいようにしておくと便利です。

Pythonの機械学習プロジェクトにおけるプログラミング設計

設定周り

設定を保持するClassを作ってデフォルト値を定義する

設定はFlatなKey-Valueで持つよりは、JSONのような構造化できるように持っておくほうが表現力が高く、まとまりが良いので何かと便利です。

まずは、

class Config: def __init__(self, **args): self.resource = ResourceConfig() self.model = ModelConfig() self.training = TrainingConfig() ... class ModelConfig: def __init__(self, **args): self.resnet_layer_num = 10 self.l2_decay = 0.01 ...

というように多少面倒でもClassを定義して、デフォルト値などを定義しておきます。
このように定義しておくメリットは、Type Hintなどを上手く使うことでPyCharmなどのIDEが補完してくれるようになることです。
そうすると、たくさんある設定の名前を正確に覚える必要もなくなり、タイプミスによる不具合をなくすことができます。

設定ファイルを読み込んで、デフォルト値を上書きできるようにする

次に少し実装が必要ですが、YAMLのような人間が読み書きし易いフォーマットで設定を定義して、デフォルト値を上書きできるようにしておくと便利です。

環境についての設定は環境変数から取得するようにする

例えば、権限情報やデータソースの設定は環境によって変わることがあります。
最近だと、GPUの数やメモリサイズなどが違う場合こともあるでしょう。
それらの設定はYAMLファイルからではなく、環境変数から読み込むと上記のYAMLを書き換えずに別の環境で動作させることが簡単にできます。

環境変数への設定は、python-dotenv などを使うと、
.env というファイルにKey-Value形式で書いておくことで読み込んでくれます(後述します)。
この .envというファイルはGitなどにコミットしてはいけないファイルなので、.gitignoreなどに書いておくようにしましょう。

ログ周り

Pythonには logging という標準packageがありますが、なんか使い方がよくわからないので、私は最近まで使っていませんでした。
しかし、ログレベルやログの出力先などを制御する仕組みを今更自分で再発明するのも無駄です。
最近はhttps://qiita.com/amedama/items/b856b2f30c2f38665701 というエントリを参考にして、ログを出力したくなった各Pythonファイルの先頭の方に、

from logging import getLogger logger = getLogger(__name__)

と書いて

logger.info("message")

というように使っています。
loggingのConfigurationは、

from logging import StreamHandler, basicConfig, DEBUG, getLogger, Formatter def setup_logger(log_filename): format_str = '%(asctime)s@%(name)s %(levelname)s # %(message)s' basicConfig(filename=log_filename, level=DEBUG, format=format_str) stream_handler = StreamHandler() stream_handler.setFormatter(Formatter(format_str)) getLogger().addHandler(stream_handler)

というようにして、起動時に1回実行しておくと、ログファイルと標準出力に時刻付きで吐き出してくれます。
後々、環境別にログレベルを変えたい場合も簡単にできます(ちょっと書き足せば)。

機械学習周り

ほとんど @icoxfog417 さんの「機械学習で泣かないためのコード設計」を参考にしています。

Resource

先程述べた Config がそれに該当します。各種設定を保持します。
以降のクラスは、インスタンス生成時にこのConfigを渡して保持しておくと便利です。

DataProcessor

主にデータの読み込みと前処理を担当します。
前処理は、訓練の時と、予測の時に両方使うことがあるので、分離しておくと良いです。

いつも自前で作っているのですが、
最近はTensorFlowの Dataset というのがあって、
この辺りの標準的になりそうな仕組みを活用できるようにした方がより良くなるような気がしています。

Model

DL系のModelをWrapします。TensorFlowやChainerなどの実装を隠蔽し、
build() でモデルを構築し、, save(), load() でモデルの保存と読み込みを行うようにします。

Trainer

モデルの訓練を担当します。
compile(), fit() などのMethodを持たせます。

ModelAPI

モデルによる予測を担当します。predict() という Methodをもたせます。
Modelによっては、InputやOutputの形状が変わったりするので、その辺りを吸収することがよくあります。

モデルの切り替え

例えば、同じタスクでも、RNNを使うモデルやCNNを使うモデルがあります。
それらを切り替えたい場合に、設定だけで切り替えると、Modelの中にIF文が大量発生して、可読性が悪くなります。
また、Training時の工夫もモデルの構造で変わってくることが多いので、大変です。

モデルを切り替える場合、(Model, Trainer, ModelAPI)の3つを1つのセットとして増やしていくと、たぶん破綻せずにすみます。
例えば、

config.py data_processor.py model_cat/model.py model_cat/trainer.py model_cat/model_api.py model_dog/model.py model_dog/trainer.py model_dog/model_api.py

というような構成にします。

こうしておけば、設定ファイルのmodel_catを使うと書けばmodel_catを使うようにしておくことで、簡単にモデルを切り替えることができるようになります。

このような構成で1年以上運用していて、何パターンも新しいモデルを追加していますが、今のところ破綻せずに済んでいます。

プログラムの起動周り

ここは色々な方法がありそうですが、私は以下のような方法に決めています。

run.py

まず、プログラムの起動は run.py で始めるようにします。
だいたい以下のようなコードになります。

run.py
import os import sys from dotenv import load_dotenv, find_dotenv if find_dotenv(): load_dotenv(find_dotenv()) _PATH_ = os.path.dirname(os.path.dirname(__file__)) # これはソースコードのTop Directoryを指すようにする if _PATH_ not in sys.path: sys.path.append(_PATH_) if __name__ == "__main__": from myproject import manager sys.exit(manager.start())

以下の処理をしています。

この manager.py を別ファイルにしておくと、相対PATHによるimportができるので少し便利です。

また、毎回毎回実行時に環境変数PYTHONPATHを指定したりしなくて済みます。

manager.py

例えば、以下のようなコードになります。
https://github.com/mokemokechicken/reversi-alpha-zero/blob/2a48aeccfc79038bc153518f1da36ec3ec7e50ec/src/reversi_zero/manager.py

などを行います。

他のプログラムから呼び出しやすい?

一応、YAMLファイルを一時ディレクトリに書き出して、引数で与えることでプログラムの振る舞いを変えることができます。
入力や出力も、その一時ディレクトリに書き出すようにそのYAMLファイルで指示しておけば、並列に独立に実行することが一応できます。

Dockerなどのコンテナに入れて実行する場合も大きな問題はないです(そりゃまあ、そうか)。

さいごに

以前、他の人から質問を受けたので書いてみました。
少しでも参考になれば幸いです。

サービス資料ダウンロード

Sprocketの特徴、MA・CDP・BIの機能、コンサルティングサービス、事例などをご紹介します。

資料ダウンロード

導入検討の相談・見積もり

新規導入、乗り換えのご相談、MA・CDP・BIの各ツールの比較などお気軽にお問い合わせください。

お問い合わせ

03-6420-0079(受付:平日10:00~18:00)