🐷

機械学習プロジェクトの設計を考えてみる

2023/05/06に公開

現職で機械学習プロジェクトに携わっており少しずつ知見が溜まってきたので,機械学習プロジェクト特有の開発方法を考慮した上での良さげな設計について考えてみる.全体の流れとしては,最初に機械学習プロジェクトの特性やそれに伴う課題についてまとめた後,それをうまく解決できる設計方法を模索してみる.
※ 筆者の妄想が多く含まれている

機械学習プロジェクト

まずは機械学習プロジェクトの開発の流れや基本的な構成などを考え,その上で感じている課題などを説明していこうと思う.

開発の全体的な流れ

機械学習プロジェクトの目的は多岐にわたり,すべての場合で「機械学習プロジェクトの進め方はこう!」と説明するのは難しい.しかしながら,フェーズを抽象化することである程度は一般化できると思う.

機械学習プロジェクトのフェーズは主に以下の3つがあると私は考えている.

  • 立ち上げフェーズ
  • 性能改善フェーズ
  • 安定運用フェーズ

まず,「立ち上げフェーズ」はその名の通り,機械学習プロジェクトの立ち上げを行う時期であり,最適化する目的関数・変数の設定やモデル設計,データ収集・加工などを行うことになる.その際にゴールとなるのはモデルの学習・評価を終えて,推論用のAPI(もくしはパッケージなど)を提供できるようになるところまでで,その段階ではまだ精度を追い求めることはしない(と思っている).ここで重要なのは「機械学習モデルを使って何かしら出力できている」ということだ.(機械学習モデルの運用に一定の精度を求められ,いきなり「性能改善フェーズ」のようになることもあると思うが,水面下では最初に「立ち上げフェーズ」があると思う)

次に,「性能改善フェーズ」が存在する.ここでは,最初のフェーズで求められなかったモデルの精度を追い求めて,利益を拡大させることを目的とする.これより前のフェーズではまだ投資段階であり,リターンを求めるべきではないが.ここからは資金の回収段階に入るため「既存の方法よりも稼げる」ということを積極的に示していく必要がある(主に経営などの上位層に向けて).ここで具体的にやることは実験が多く,「立ち上げフェーズ」ある程度整った地盤をもとにさらに性能が上がるような特徴量を追加したり,前処理やモデルを変更してみたりする.また,このフェーズでは性能改善にとどまらず新機能の開発などをすることもある(それを同じプロジェクトでやるのかはケースバイケースだが).

最後に,「安定運用フェーズ」がある.このフェーズでは,モデル性能や内部情報,入力データの分布などを監視し,それを自動化することによって安定的に一定の性能を出すことができるようにすることを目的とする.このフェーズは一つ前の「性能改善フェーズ」がある程度行った段階で取り組み始めることが多く,基準としては「既存の方法よりも稼げるようになった」というラインを超えたあたりからだと思う.そのため,このフェーズに入ったからといって「性能改善フェーズ」が完全に終了したというわけではなく,このフェーズが終わった後,もしくはこのフェーズと並行して「性能改善フェーズ」を行うことが多い.

具体例

立ち上げフェーズ

自社商品のレコメンドは,その商品に魅力を感じるユーザに対して検索結果などに加えて表示することで,自社サイトの売上を伸ばすことを目的とする場合が多い.その際にやみくもに商品を表示するのではなく,「任意のユーザが魅力を感じそうな(購入しそうな)商品」を予測して表示することが必要になり,その予測(入力:ユーザIDや特徴,出力:商品群)を機械学習で解く問題として定義する.

このフェーズのゴールとしてはとりあえず推論用APIを立てることで,下の例の場合はユーザに推薦するアイテムの候補を出力できることだ.(実際のモデルの出力は各アイテムの推薦確率のようになるだろうが,他サービスから使用されるAPIは推薦アイテム候補群を返すものが良さそう)

性能改善フェーズ

レコメンドの機械学習モデルの性能改善施策の例として,ユーザの特徴量に加えて統計特徴量を入力する改善が考えられる.統計量を特徴量に加えることで,一般的なユーザがどんな行動をしているか,そして対象となるユーザがその行動からどれほど離れているかなどをモデルの学習に組み込めるため,単純なモデル性能の向上が期待できる.

このように,このフェーズでは基本的に性能を向上させる実験とその実装を行うことが多い.(図中には書いていないが,性能の工場が確認できた際にはレコメンドAPIにその改善を組み込む実装をする必要がある.)

安定運用フェーズ

このフェーズでは今まで開発・改善してきた機械学習モデルの性能監視を行い,必要に応じで自動的に最新データでのモデルの再学習をする.それによって,モデルの相対的な劣化(情勢などによって入力データの傾向などが変化することでモデル性能が悪化する,など)をなるべく抑えて安定して利益を得ることができる.

よく用いられるディレクトリ構成

ようやく本題に差し掛かってきた.
では実際に,機械学習プロジェクトの開発に使用するリポジトリのディレクトリ構造も説明する.
一般的に用いられるのがCookie cutter (Data science)のテンプレートで,構成は以下のようになっている.

cookiecutter-data-science
├── LICENSE
├── Makefile           <- Makefile with commands like `make data` or `make train`
├── README.md          <- The top-level README for developers using this project.
│
├── data
│   ├── external       <- Data from third party sources.
│   ├── interim        <- Intermediate data that has been transformed.
│   ├── processed      <- The final, canonical data sets for modeling.
│   └── raw            <- The original, immutable data dump.
│
├── docs               <- A default Sphinx project; see sphinx-doc.org for details
├── models             <- Trained and serialized models, model predictions, or model summaries
├── notebooks          <- Jupyter notebooks. Naming convention is a number (for ordering),
│                         the creator's initials, and a short `-` delimited description, e.g.
│                         `1.0-jqp-initial-data-exploration`.
├── references         <- Data dictionaries, manuals, and all other explanatory materials.
├── reports            <- Generated analysis as HTML, PDF, LaTeX, etc.
│   └── figures        <- Generated graphics and figures to be used in reporting
├── requirements.txt   <- The requirements file for reproducing the analysis environment, e.g.
│                         generated with `pip freeze > requirements.txt`
├── setup.py           <- makes project pip installable (pip install -e .) so src can be imported
│
├── src                <- Source code for use in this project.
│   ├── __init__.py    <- Makes src a Python module
│   ├── data           <- Scripts to download or generate data
│   │   └── make_dataset.py
│   ├── features       <- Scripts to turn raw data into features for modeling
│   │   └── build_features.py
│   ├── models         <- Scripts to train models and then use trained models to make
│   │   │                 predictions
│   │   ├── predict_model.py
│   │   └── train_model.py
│   └── visualization  <- Scripts to create exploratory and results oriented visualizations
│       └── visualize.py
│
└── tox.ini            <- tox file with settings for running tox; see tox.readthedocs.io

かなりシンプルな構成だが,もとのリポジトリには7,000ほどのスターが付いていることから様々なプロジェクトで用いられていることがわかる.簡単に説明すると,src配下にデータ収集,前処理,モデル学習・評価などのスクリプトを格納し,それらで生成された出力をdata, models配下に格納する(ここはおそらくGitで管理しない).その他は分析用途でnotebooks配下などを利用するようだ.

先に説明したように機械学習プロジェクトにはいくつかのフェーズが存在し,この構成はシンプル故に「立ち上げフェーズ」には身軽に動ける(制約が少なく自由度高く開発できる)ため良さそうに思える.ただ,実際に一年間ほどこんな感じ(実際にはもう少しちゃんとしているが...)の構成で開発を続けていくと課題点もいくつか見えてきた.

課題

TODO: これらの課題点(実験と実装の乖離,依存関係の逆転ができてない,など)

実験と実装の乖離

まずは,”実験と実装が乖離してしまう”ことが課題の一つとしてあげられる.
Cookie cutterの構成だと,以下の部分で主に実験(モデル学習・評価)を行うことになる.

├── src                <- Source code for use in this project.
│   ├── __init__.py    <- Makes src a Python module
│   ├── data           <- Scripts to download or generate data
│   │   └── make_dataset.py
│   ├── features       <- Scripts to turn raw data into features for modeling
│   │   └── build_features.py
│   ├── models         <- Scripts to train models and then use trained models to make
│   │   │                 predictions
│   │   ├── predict_model.py
│   │   └── train_model.py
│   └── visualization  <- Scripts to create exploratory and results oriented visualizations
│       └── visualize.py

ここで危険なのが,それぞれの処理が「独立したスクリプト」になってしまうことだ.コメントにも書いてある通り,この構成ではそれぞれのスクリプトで実験のコードを書くことを想定いるため,実験から実装に移る際にそれぞれ別のコードを書く必要が出てくる.必要な処理だけうまくパッケージにしていればよいのだが,この構成だと自然とスクリプトのように書いてしまうことで実装時に流用できないコードになってしまうことが多い(少なくとも現職ではそうだった).

この「実装時に流用できない」というのは単純に二度手間になるのが嫌という意味だけではなく(もちろんそれもあるのだが...),実験時では確認していたような項目(例えば,特徴量の検証など)を実装時に見落としてしまい,それに気づかずにリリースしてしまうという意味も含んでいる.

そのため,この問題は検証スピードの低下だけでなく,売上の減少などの直接利益に関わる可能性もある.

部品の切り替え

もう一つは,”部品の切り替えが難しい”という問題だ.
ここでいう”部品”とは,機械学習モデルやデータの前処理などのことを指しており,その切り替えが難しいというのは言い換えると,「異なる機械学習モデルやデータ前処理を用いた実験がしづらい」とも言える.

では実際に具体例を上げてみよう.
自社商品のレコメンドを行う状況で,現在のモデルはLightGBMを使用しているがこれをニューラルネットワークに変更してみる実験を行うことを想定する.まず既存の実験コードの一部は以下のようになっている.

既存の実験コード
class LightGBM:
    def __init__(self):
        ...

    def __call__(self, input):
        return self.model(input)
    
    @classmethod
    def load(cls):
        ...

...

model = LightGBM.load()
input_df = load_df()
output = model(input_df)
...

すごくシンプルに,モデルのクラスを作成してそのまま入力値をモデルに入れているスクリプトとなっている.ではこれをニューラルネットワークのモデルに変更してみる.

変更後の実験コード
class NN:
    def __init__(self):
        ...

    def __call__(self, input):
        return self.model(input)
    
    def load(self):
        ...

...

model = NN.load()
input_df = load_df()
output = model(input_df)
...

変更点は単純に新しいモデルクラスを定義したのと,使用するモデルをNN(ニューラルネットワーク)に変更しただけなので,実験の内容をそのまま反映したような説明性の高いコードになった.しかし,実際に実行してみるとモデル読み込みができずにエラーになってしまった!...実はよくコードを見てみるとNNクラスの方ではloadメソッドがstaticmethodではなくなっていることがわかる.そのため,エラーを解消するにはmodel = NN()NNクラスを初期化してからloadメソッドを呼ぶ必要がある.

これは,独立したスクリプトのように実験コードを書いてしまうことが原因のひとつとなっている.そもそも機械学習プロジェクトでは各部品を入れ替えて実験するケースが多いため,本来は「新しいモデルクラスを定義&切り替え」というコードの変更のみで実験できることが好ましいが,上記の例のように独立したスクリプトを書きやすい構成だと適切にインターフェースなどを作成することも少ない(と思う).そのため,新しいモデルクラスを定義するだけでなく,そのクラスを使う場所に影響が出ないかいちいち確認しなくてはいけなくなってしまう.

ちなみにこれは「上位のモジュール」が「下位のモジュール」に依存しているため,SOLID原則のひとつである依存性逆転の原則に反している.この場合の「上位モジュール」は”実験プロセス”,「下位モジュール」は”LightGBMクラス”にあたる.本来は”実験プロセス”は機械学習モデルという抽象化したものに依存するべきなのだが,その具体的な実装である”LightGBMクラス”に依存してしまっているため,そのクラスと同じ実装のモデルクラスしか受け付けないような設計になってしまっている.

この問題は,直接的なモデル性能への影響こそないものの,検証サイクルが非常に回しにくくなってしまう.

入力・出力値の検証

最後に,”入力・出力値の検証がしづらい”という問題がある.

これは言葉の通りで,この構成だとデータセット作成用のコードはあるが検証用のコードの置き場所は用意されていないため,データセット作成用のコードに検証の責務を押し付けるか,そもそも検証を行わないかの良くない二択になってしまう(前者は検証自体はしているものの,コードの責務が増えて管理がしづらくなる).

この問題は,本番環境でおきるとモデルの本来の性能を引き出せない(しかもそれに気が付かない)ため,売上への影響が出てしまう.

良さげな設計

そもそも機械学習プロジェクトによく適用されているような設計パターン,アーキテクチャなどがないと思うので,一般的なWebサービスなどに使われるアーキテクチャが流用できないか考えてみる.先程上げた3つの課題がうまく解決できているかどうかという視点がメイン.

クリーンアーキテクチャ

このサイトをみて少し理解した気になった.
https://gist.github.com/mpppk/609d592f25cab9312654b39f1b357c60

クリーンアーキテクチャはRobert C. Martin(Uncle Bob)が2012年に提唱した、DBやフレークワークからの独立性を確保するためのアーキテクチャであり、以下の図が大変有名です。

クリーンアーキテクチャは,「ビジネスロジックなどの本質的であまり変更しないものにほかを依存させよう」というコンセプトだと理解している(間違っていたら申し訳ない).逆に言うと,DBやUI,その他の本質的でなく変更する可能性が高い実装には依存させないようにしようとも言える.

これは変更頻度などを考えると至極真っ当なことで,頻繁に変更するようなものに依存させてしまうと依存先のものまで変更が必要になってしまうため,必要最低限の変更で機能するように設計するのはとても良いことだと思う.

クリーンアーキテクチャでは,上の図のように円の中心に向かって各層が依存するような構造になっている.一番中心の「Entities」という層はビジネスロジックなどの本質的な部分で,その一つ外側にある「Use Cases」はEntitiesを操作するようなアプリ固有のロジックの部分となっている.更にその外側は主にEntities, Use Casesと後述するFrameworks & Driversのつなげるためのアダプターとなる.そして一番外側にはUIやDBなどの部分がある.

このような構成となっているため,円の内側を実装するときに外側を気にする(依存する)ことがないようにできていて,現実世界のロジックなどが歪に実装させることがないようにできている.(クリーンアーキテクチャの翻訳本にもそうかいてあるらしい.)

このアーキテクチャを機能させる重要なルールが、依存ルールだ。このルールにおいては、ソースコードは、内側に向かってのみ依存することができる。内側の円は、外側の円についてなにも知ることはない。とくに、外側の円で宣言されたものの名前を、内側の円から言及してはならない。これは、関数、クラス、変数、あるいはその他、名前が付けられたソフトウェアのエンティティすべてに言える。 同様に、外側の円で使われているデータフォーマットを内側の円で使うべきではない。とくに、それらのフォーマットが、外側の円でフレームワークによって生成されているのであれば。外側の円のどんなものも、内側の円に影響を与えるべきではないのだ。 -- クリーンアーキテクチャ(The Clean Architecture翻訳)

ディレクトリ構成

このサイトを参考にさせてもらった.
https://qiita.com/hirotakan/items/698c1f5773a3cca6193e
以下はそのページに書いてあった構成をそのまま持ってきたもの.

一般的な構成
└── src
    ├── app
    │   ├── domain
    │   ├── infrastructure
    │   ├── interfaces
    │   │   ├── controllers
    │   │   └── database
    │   ├── server.go
    │   └── usecase

ユーザリソースを提供するためのAPIサーバを作成している.
機械学習プロジェクトとはかなり開発の仕方も違うと思うので,上の構成を参考にして機械学習プロジェクト用の構成を少し考えてみる.

※正直クリーンアーキテクチャは少しサイトを見て勉強した程度で,実践で使ったことがないので正しいのか全くわからない.ただ,コンセプトである「本質的でないものに依存するな」っていうところはズレないように意識した.

機械学習プロジェクト用に考えてみた構成
.
└── src
    ├── entity          <-- API入力値・特徴量・モデル・前処理など
    │   ├── domain
    │   ├── feature.py
    │   ├── input.py
    │   ├── output.py
    │   ├── processing.py
    │   ├── model.py
    │   ├── train.py
    │   ├── evaluate.py
    │   └── repositry
    ├── usecase         <-- 実験コードで使う処理
    ├── experiment      <-- 実験コード
    ├── presentation    <-- API用
    └── infrastructure  <-- モデルや前処理,リポジトリの実体(インターフェスに従って書く)
        ├── processing
        ├── model
        └── repositry

上の構成は主に以下の点を変更した.

  • entityにビジネスルールだけでなく,実験に必要な特徴量・処理・モデルなどを入れる
  • usecaseに実験コードに使用する関数などを入れる
  • experimentという実験コード用の層を追加.(presentationと同様にusecaseに依存)
  • infrastructureにモデルや前処理などの実験ごとに変更するものの実体を持たせた

もともと機械学習プロジェクト用に作られているものではないと思うし,機械学習プロジェクトは実験と実装が混ざり合っている特殊なものなので少し歪になってしまったような気がする(この構成書いてる時点でちょっと不安になってきた...w).

解決する課題

この構成によって,先程上げた課題はすべて解決する.

  • 実験と実装の乖離
  • 部品の切り替え
  • 入力・出力値の検証

実験と実装の乖離

クリーンアーキテクチャのentityに特徴量クラスやモデルクラスを置くことで,学習・評価時と推論(API)時で同じ実装を使えるようになった.また,この構成では特徴量などもすべてentityに自然と置くようになるため,特に意識しなくても実験と実装が乖離することがないようになっているのが非常に良い点だと思う.

部品の切り替え

この構成にすることで主にこの部分が改善する.(メインポイント)

クリーンアーキテクチャでは「本質的でないもの(フレームワーク,ツールなど)に依存しない」というコンセプトがあると思っていて,そのために上位のものを抽象化しインターフェースにすることで,詳細な実装を簡単に切り替えられるようなコードになっている.「課題」の部分で上げた例である,機械学習モデルをLightGBMからニューラルネットワークに変更する実験を再度考えてみる.

entity/model.py
from entity.input import IModelInput
from entity.output import IModelOutput

class IModel:
    def __init__(self):
        ...

    def __call__(self, input: IModelInput) -> IModelOutput:
        ...
    
    def load(self) -> IModel:
        ...
infrastructure/models.py
from entity.model import IModel
from infrastructure.input.lightgbm import LightGBMInput     # IModelInputを継承
from infrastructure.output.lightgbm import LightGBMOutput   # IModelOutputを継承

class LightGBMModel(IModel):
    def __init__(self):
        ...

    def __call__(self, input: LightGBMInput) -> LightGBMOutput:
        ...
    
    def load(self) -> IModel:
        ...

このようにentityでインターフェースを定義して,詳細な実装はinfrastructureの方で行う.そして,実際にモデル学習・評価,APIなどにインターフェースの方をentity/train.pyentity/evaluate.pyなどで引数として指定するようにすれば,与える詳細なモデルを変更するだけで他の部分を一切変えずに実験が行える(意識せずとも自然とそういうコードになる).

入力・出力の検証

クリーンアーキテクチャでは,各オブジェクト・ルールをentityとしてクラスで表現し,そのクラス内で制約をかけると思う.そのため,検証用のコードを別途書かなくても自然と入出力や特徴量の検証を行うことができる(もちろんどこかのタイミングで”それぞれが制約を満たしているか”を確認する必要はあるが...).例えば,特徴量の中にユーザの平均ページ滞在期間があったとする.その場合はentityに以下のようなクラスを作成するだろう.

class Second:
    def __init__(self, second: int):
        ...

    @classmethod
    def create(cls, second: int) -> Second:
        second_cls = cls(second)
        if not second_cls.is_valid:
            raise ValidationError
        return second_cls
    
    def valid(self) -> bool:
        is_valid = true
        is_valid = is_valid and self.second > 0
        return is_valid

    ...


class TimeOnPage:
    def __init__(self, time: Second):
        ...

    @classmethod
    def create(cls, time: Second) -> TimeOnPage:
        timeonpage_cls = cls(time)
        if not timeonpage_cls.is_valid:
            raise ValidationError
        return timeonpage_cls
    
    def valid(self) -> bool:
        is_valid = true
        # (例)300秒以上のページ滞在時間は外れ値として処理する
        is_valid = is_valid and self.time.second() < 300
        return is_valid

ページ滞在時間は別にint型でそのまま表すこともできるが,このように個別にクラス化することでそれぞれで検証用の処理を持つことができるので高凝集な良いコードになる.このクラスを学習・評価,APIなどにそのまま使用すれば,オフライン・オンラインともに入力・出力値,特徴量の検証漏れを心配せずにコーディングすることができる(ヒューマンエラーでモデル性能が下がるという状況ができにくい).

残った課題・新しく出てきた課題

上記のように,はじめにあげた3つの課題はすべて解決できたが,ここで新しい課題が出てきている気がしてきた.

それが,「実験部分と実装部分(APIサービス)がごちゃまぜになっている」ということだ.上のクリーンアーキテクチャの構成では,entity配下に実験で使用する特徴量などのクラスと,APIサービスで使用するであろう他の値(APIそのものの入力値など)がごちゃまぜに入れられてしまう(ただこれが具体的にどういった問題につながるのかはまだわかっていない).

これは,そもそも「実験と実装」の2つを一つのリポジトリに共存させているのがこういった歪な構造を生み出しているように思える.本来,機械学習プロジェクトでは機械学習用の基盤を使用して実験とAPI提供などの部分を切り離す(MLOps)ことが多い.そのため,今紹介したようにディレクトリ構成の工夫だけで”実験と実装の乖離”や”部品の切り替え”などの実装面での課題を完全に解決できるかといわれるとそうでもないような気がする.

まとめ

長々と書いてしまったため,だんだんと何を書きたいのかわからなくなってしまった感があるw
機械学習プロジェクトは流行ってきているのに対して,デファクトスタンダードな設計方法などがあまり決まっていないように感じる.そのため,よくない設計方法のせいで「やりたい実験がたくさんあるのに,実装が足をひっぱってできない!」という状況を回避したいと思い,一般的なアーキテクチャを適用してみることができないか模索してみた.

ただ,ここまで色々と書いては見たものの,そもそも機械学習プロジェクトは特有の基盤(Kubeflowなど)に載せて,うまく実験と実装を切り離す感じのほうが良いのかもしれない.

Discussion