データ分析プロジェクトの品質をキープしつつ効率的な検証をサポートする一時ファイル群の管理

研究開発部の takahi_i です。本稿はデータ分析、 機械学習関係のプロジェクトで数多く生成される一時オブジェクトおよびそれらのオブジェクトを保持するファイル(一時ファイル)を管理する取り組みについて解説します。

本稿の前半はデータを分析するプロジェクトの一般的なフローと起こりがちな問題(コードの品質管理)について解説します。後半はプログラム上で生成されるオブジェクト群をファイルに自動でキャッシュを管理するツール(Hideout)を使って、コードを整理整頓しやすくする施策について紹介します。

データを分析するプロジェクトの一般的なフロー

まずデータを処理するプロジェクトや機械学習プロジェクトの典型的なフローについて考えてみます。まずは単純に機械学習器を取得した入力に対して適用するプロジェクト、次にもう少し複雑な事例、アプリケーションで利用するデータを生成するプロジェクトのフローについて見てゆきます。

単一の機械学習器を適用するプロジェクト

データ分析するプロジェクトの典型例として機械学習器を入力データに対して適用するプロジェクトを考えます。

学習用のデータのように静的に変化しないデータはレポジトリに同梱されることもありますが、Webサービスのデータが対象の場合、推論用のデータの多くは日々変化するため、データベース(DWH)から動的に取得します。

機械学習を適用するプロジェクトは取得したデータを多段に変換しながら出力となる判別結果を生成してゆきます。

以下の図は一般的な機械学習を利用したプロジェクト検証スクリプトのフローです。

f:id:takahi-i:20191015114010p:plain
単一の機械学習器を適用するプロジェクト

上記のプロジェクトでは必要なデータを取得、学習、推論(テスト)と連続して処理がおこなれています。各処理ごとにデータは変換され、生成されます(例えば学習後にはモデルオブジェクトが生成されます)。

たとえば、レシピに含まれるステップを本来のステップとそれ以外(コメントなど)に分類する バッチ処理(こちらを参照)はちょうどこのような内容になっています。このバッチでは一部のデータを切り出して処理しているのですが、それでも取得、学習、推論(テスト)ステージでかなりの時間がかかっています。

アプリケーション用データを生成するプロジェクト

さらに自然言語のような非定形データを扱う中でも複雑なタスク(対話や質問応答)ではDWHから取得した データを多くのオブジェクトを生成しつつ多段で加工してゆくことも一般的です(End-to-Endの学習で一度に処理してしまう場合もありますが)。

f:id:takahi-i:20191015114231p:plain
多段に処理をするプロジェクト

クックパッドの研究開発部においてこのような多段の処理を適用するプロジェクトに レシピのMRRへの変換や、 クックパッドのAlexaスキルが提供する調理補助用の質問応答 で利用されているKnowledge Baseの生成プロジェクトがあります。

これまで紹介した2つのタイプのプロジェクト(単一の機械学習器を適用するプロジェクト、アプリケーション用データを生成するプロジェクト)のどちらのプロジェクトタイプでも、まず一つ以上の入力データを抽出します。データは入力データ、必要なリソース(辞書)、機械学習器が出力したアノテーション結果などがあります。そしてその後のデータ変換、集約処理が多段に続きます。各処理では一時オブジェクトを入力として別のオブジェクトを生成します。

そしてこのオブジェクトの集約、加工、生成処理の実行はそれぞれ時間がかかります。このことがデータ分析プロジェクトを中長期メインテナンスする場合に品質上の問題を引き起こします。

データ処理スクリプトに対する修正要求とコード品質問題

データを分析、加工するプログラムでも普通のプログラムと同じように機能追加の要求にさらされ修正され続けます。 多くの場合、修正するべき箇所はプログラムの中の一部に過ぎませんが、修正が問題をはらんでいないかをチェックする にはE2Eテスト(機械学習の学習、Inferenceなど)を走らせます。また、自然言語を入力とするタスクの場合にはテスト しきれないことがどうしても発生するため、一部のデータを使ってプログラムを実行して変更が問題を発生していないかも確認したくなります。

このような検証目的の実行時に扱うデータの規模は大規模ではなくても、各ステージごとの処理に十秒から数分かかってしまいます。

結果、微細な修正をした後にコードに問題がないかを確認するだけでも結構な時間を消費してしまいます。 実行に時間がかかりコードを修正をするコストが大きくなるため、コードを修正するサイクルが大きく(試行できる回数が少なく)なり、コードを整理するハードルが高くなってしまいます。このような状況ではプログラムの検証実行や、テストに時間がかかりすぎるためコードの品質をキープしづらくなります。

不十分な解決方法

検証時の実行に時間がかかってしまう問題に対するする解決策として、以下のような対処方法が考えられます。

一時ファイルの手動追加

データの取得、生成時間を省略するために、データベースから切り出した入力データや機械学習器が出力したモデルファイルのような一時データを保持するファイル(一時ファイル)をレポジトリや、ローカルディレクトリに同梱しているプロジェクトを見かけます。データ分析プロジェクトの一時ファイルは、モデル、データベースから抽出した辞書リソース、前処理済みの入力データなどがあります。

たとえば以下のプロジェクトでは入力ファイルに前処理を適用したファイル(preprocessed_input1.txt)と、機械学習器が生成したモデルファイル(validation_model1.dat)をレポジトリに同梱しています。

.
├── Makefile
├── README.md
├── config
│   ├── __init__.py
│   └── env.py
├── data
│   ├── dictionary.dic
│   ├── preprocessed
│   │   ├── preprocessed_input1.txt
│   │   └── preprocessed_input2.txt
│   ├── models
│   │   ├── validation_model1.dat
│   │   └── validation_model2.dat
...

もちろんこれらの一時ファイル群は本来はレポジトリに含まれるべきではありません。それでも、こういった一時ファイルを利用することでプログラムの動作検証の速度を向上できます。

しかし、このように安易に加工済み入力ファイルやモデルファイルをレポジトリに同梱してしまうと問題が発生します。

問題の一つは加工済み入力データの生成方法がコードから分離してしまい、プロジェクトが進むにつれデータの加工方法と乖離してしまう点です。 入力データも含め、プログラムで扱うデータやオブジェクトはコード修正とともに変化してゆきます。 たとえばモデルファイルのような生成されたデータを一時ファイルから読み出して検証的に実行している場合、コードの修正によってデータがファイルから読み出しているものから変化し本来は実行時に問題が発生していることがあります。残念ながら一時ファイルを利用して検証実行している場合、このような問題に気がつくのは難しいです。というのも修正した部分(たとえば検証用に切り出した小規模データでの学習処理)はキャッシュファイルを使うと実行されず、テストやローカル環境での実行は中途半端にうまく動作してしまうのです。

さらにテストがレポジトリに添付されたモデルファイルを利用してしまっている場合には、CI環境でもコードの修正にともなうバグを検知できません。 このような状況で問題が発覚したときにはコミットすでにがかなり積まれてしまい、問題箇所を同定するのが難しくなっていることがあります。

もう一つの問題は、レポジトリを中長期メインテナンスすると発生します。本来は一時的な目的でVCSレポジトリに追加されたはずの中間オブジェクトを保持するファイル群は、役割を全うした後も消されることなく(消し忘れ)レポジトリにとどまり続けることがあります。このような消し忘れファイル群はプロジェクトの開発時には問題にならないのですが、時間経過(半年、一年)を経るとエンジニアは生成方法を記憶していないため特に引き継ぎ時に大きな問題になります。

キャッシュ処理の追加

必要な一時ファイルを活用するためのもう少しましな解決方法に、関数へのキャッシュ処理の追加があります。 たとえばMRRの生成プロジェクトでは、入力、中間データ(それぞれが数十〜数百MBのデータ)をファイルにキャッシュをすることで、 検証時の実行速度を向上して開発速度を高めました。以下はMRRの生成プロジェクトで利用されているメソッドの一部です。

def get_ingredient_id_map(cache_file_path):
    if os.path.exists(cache_file_path) and not self.force:
        with open(cache_file_path, mode='rb') as f:
            return pickle.load(f)

    ingredient_id_map = _get_ingredient_map_impl()

    if not os.path.exists(cache_file_path) or not self.force:
        with open(cache_file_path, mode='wb') as f:
            pickle.dump(ingredient_id_map, f)
    return ingredient_id_map

この関数は、キャッシュファイルがあればロードしたものを返し、なければ生成したうえで、(キャッシュ)ファイルに保存します。 この関数を使うことでローカル環境で(2回目の実行以降)オブジェクトを生成するコストは低減できます。

大きめのオブジェクトを生成する関数にキャッシュファイルを生成する処理を付与することでテストや検証目的に実行していたプログラムの実行時間が、数分から10秒程度に減少できました。これによりコードを積極的に整理できるようになりました。 またCIで簡単な学習➡推論をすることで、学習プロセスに問題ないかを常時クリーン環境でテストし続けられるという 利点があります。

しかしこのやり方にも問題があります。数多く存在する中間オブジェクトごとに上記のようなキャッシュする処理をつけるのは面倒ですし 、処理内容とは関係のない内容で関数を埋めてしまうのにも抵抗があります。また、多くの機能追加の要請で必要な修正は コードの一部のコンポーネントに限られます。そのため一部のキャッシュだけは効かせたくない場合がありますが、 各変換処理にキャッシング処理をベタ書きした状態では対応が難しいです。

こういった問題を解決するため、最近はオブジェクトのファイルへのキャッシュ処理を自動化する Hideout という簡素なツールを作って利用しています。

Hideout: データ分析プロジェクト用、ファイルキャッシュ

Hideoutはオブジェクトを生成するタイミングでキャッシュファイルもあわせて生成するツールです。 実行時に環境変数で指定するキャッシュ設定がオンになっていてかつ生成されたキャッシュファイルが存在すれば、 キャッシュファイルをロードしてオブジェクトを返し、なければ指定された生成用関数を呼び出します。

基本的な使い方

たとえば以下の generate_large_object 関数はオブジェクトを生成するのに時間がかかります(人工的なサンプルですが)。

def generate_large_object(times):
    sleep(1000)
    return map(lambda x: x*2, range(times))

この関数から生成されるオブジェクトを Hideout でキャッシュするには以下のように記述します。

large_object = hideout.resume_or_generate(
    label="large_object",
    func=generate_large_object,
    func_args={"times": 10}
)

funcにはオブジェクトを生成する関数、func_argにはfuncを実行するのに必要な引数を辞書として渡します。

HideoutはデフォルトではキャッシュがOffになっています。そのため、 デフォルトではキャッシュはされず単にfuncに指定された関数を実行してオブジェクトを生成します。

キャシュをOnにしてオブジェクトを使いまわしたい場合には環境変数、HIDEOUT_ENABLE_CACHETrueを設定します。 ローカルで検証しているときにはコマンドを実行するターミナルで環境変数を指定します。

使用例

クックパッドのAlexaスキルで使用している質問応答用のKnowledge Baseを生成するプロジェクトではHideoutを利用して 生成される中間オブジェクトの一部をキャッシュしています。

該当レポジトリは Cookiecutter Docker Science テンプレートで生成されているので、 Makefileをワークフローの管理に使用しています。MakefileにはKnowledge Base生成用のターゲットを登録してあります。 ローカルにおける検証では以下のようにキャッシュをOnにして実行しています。

$ make generate BATCH_SIZE=500 HIDEOUT_ENABLE_CACHE=True 

テストではモデルファイルを使ったE2Eのケースも含まれていますが、同じように make コマンドに HIDEOUT_ENABLE_CACHE=True を指定した上で実行すると数秒で終わります。

$ make test HIDEOUT_ENABLE_CACHE=True

Hideoutにおいてキャッシュ設定はデフォルトではOffになっているので、CIやプロダクション環境で誤ってキャッシュファイルが生成されることはありません。 そのため修正時にはPull Requestをこまめに作り細かくコミットをプッシュすると、キャッシュが効いていない環境でテストが走るため、思わぬ不具合に気づけて便利です。

ステージごとにキャッシュOffを指定

本稿の前半で紹介したようにデータ処理をするプロジェクトには複数のデータソースを複数のステージで多段に加工するものがあります。

f:id:takahi-i:20191015114414p:plain
複数のステージから成るプロジェクト

上記の図ではデータを抽出した後「Preliminary1」や「Preliminary2」、「Transform」というステージがあります。 このような少々の複雑さをもつプロジェクトであっても機能拡張依頼が来たとき、多くの場合には修正する箇所は ソースコードの一部でしかありません。

このようなとき修正が必要な一部のステージだけキャッシュファイルをロードする処理をオフにしたいことがあります。この目的のために HideoutはHIDEOUT_SKIP_STAGESという環境変数を提供しています。たとえばキャッシュした ファイルを利用して実行したいが、Preliminary2Transform ステージだけはキャッシュを Offにしたい場合が考えられます。 このような場合、make build HIDEOUT_ENABLE_CACHE=True HIDEOUT_SKIP_STAGES=Preliminary1,Transform と キャッシュをしないステージを指示します。

Hideoutにおいて指定するステージ名はhideout.resume_or_generatelabelオプションで付与します。

large_object = hideout.resume_or_generate(
    label="Preliminary1",
    func=generate_preliminary_object,
    func_args={"times": 10}
)

今後

Hideoutを利用するユーザはプログラム中の関数ではなく、キャッシュファイルを生成している部分に適用する部分に処理を追加します。これはキャッシュする部分を追いやすくするためで、インターフェース名もHideoutが利用される箇所がわかりやすくなるように長く(resume_or_generate)なっています。 ただ最近、同僚から「デコレータでやったほうがシンプルなんでは」というコメントを頂きました。そこで今後デコレータのインターフェースも提供してみたいと考えています。

まとめ

本稿はデータ分析をするプロジェクトにおける一時オブジェクトを保存したファイル(一時ファイル)の扱いについて解説しました。一時ファイルはデータ分析の結果を高速に検証するのに便利ですが、安易に レポジトリに追加するとプロジェクトの保守性が下がります。

また、必要な入力データや中間オブジェクトをファイルにキャッシュする処理を追加するのも コストがかかります。そこで本稿の後半では、このようなプロジェクトを保守するのに役立つキャッシュツール、Hideoutについて紹介し、一時ファイルを利用しつつもレポジトリをクリーンに保つ方法について解説しました。