はじめに
タイトルの通り、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のような人間が読み書きし易いフォーマットで設定を定義して、デフォルト値を上書きできるようにしておくと便利です。
例えば、こんな感じの実装があります。
https://github.com/mokemokechicken/moke_config
色々な設定を1つの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/trainer/model_api の基底クラスを作っておいても良いかもしれません。
こうしておけば、設定ファイルのmodel_cat
を使うと書けばmodel_catを使うようにしておくことで、簡単にモデルを切り替えることができるようになります。
このような構成で1年以上運用していて、何パターンも新しいモデルを追加していますが、今のところ破綻せずに済んでいます。
プログラムの起動周り
ここは色々な方法がありそうですが、私は以下のような方法に決めています。
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())
以下の処理をしています。
- python-dotenv を呼び出して、環境変数の設定
- PYTHONPATHにプロジェクトコードのTop Directoryを注入
- 真の mainルーチンの呼び出し
この manager.py を別ファイルにしておくと、相対PATHによるimportができるので少し便利です。
※ Pythonだと起動時に呼び出したファイルから from .hoge import fuga
という相対指定のimportができない。
また、毎回毎回実行時に環境変数PYTHONPATHを指定したりしなくて済みます。
manager.py
例えば、以下のようなコードになります。
https://github.com/mokemokechicken/reversi-alpha-zero/blob/2a48aeccfc79038bc153518f1da36ec3ec7e50ec/src/reversi_zero/manager.py
- ArgumentParser で 引数の処理
- Config インスタンスの作成
- ログのConfiguration
- 起動時にやっておくと良い色々な処理
- 実際に行いたいコマンドに応じて、Configを引数とした関数の呼び出し。
などを行います。
他のプログラムから呼び出しやすい?
一応、YAMLファイルを一時ディレクトリに書き出して、引数で与えることでプログラムの振る舞いを変えることができます。
入力や出力も、その一時ディレクトリに書き出すようにそのYAMLファイルで指示しておけば、
並列に独立に実行することが一応できます。
Dockerなどのコンテナに入れて実行する場合も大きな問題はないです(そりゃまあ、そうか)。
さいごに
以前、他の人から質問を受けたので書いてみました。
少しでも参考になれば幸いです。