この記事は enechain Advent Calendar 2023 の2日目の記事です。
はじめに
enechainのData Science Deskでデータサイエンス系のプロジェクトに関わるソフトウェアエンジニアをしている北村 @kitayuta です。
この記事では、今まで主にPoCレベルの開発のみを行っていたデータサイエンティストが、PoCレベルを脱却して本番レベルの品質の開発を行うためのTipsを紹介します。
私は前職にて2年半弱、受託の案件において機械学習モデル・最適化アルゴリズムを開発するデータサイエンティストとして働いており、PoCレベルの開発までを担当することが主でした。その後、データサイエンス系のプロジェクトを担当するソフトウェアエンジニアとしてenechainにジョインし、PoCフェーズだけでなく、その後の本番システムの開発・運用まで携わるようになりました。この記事では、私がこうしてエンジニアリングの領域にまで携わるようになった際に実際に必要となったことをベースにお話しします。
なお、この記事のTipsはプログラミング言語の種類によらず適用できる部分も多いと思いますが、以下ではデータサイエンス系のデファクトスタンダードであるPythonの利用を前提として説明しております。
基本的なTips
まず、PoCレベルの開発であっても重要な基本的プラクティスとして以下が挙げられると思います。こちらは既に実践されている方も多いと思いますので、詳細な説明は割愛します。
- ある程度どのような環境でも実行結果の再現性を保つため、Poetryなどのパッケージマネージャを利用する
- コードの可読性を保つため、Blackやisortのようなコードフォーマッターで整形する
- 基本的なエラーの検知や標準的なコーディングスタイル(PEP 8)に準拠しているかどうかをチェックするため、flake8のようなリンターを使う
- 型関連のエラーを実行前に検知するため、型ヒントを適切に記述し、mypyによる静的型チェックを行う
- コードの可読性や保守性を保つため、適切な粒度のモジュールとしてファイルを分割する
一方で、PoCレベルの開発では、モデルやアルゴリズムの性能の評価や、プロトタイプとしての最低限の動作が可能であればそれで済むため、しばしば以下のような状態にあるかと思います。
- 通常のコード実行時にはおおむね問題なく動作しているように見える
- しかし、実際には品質上問題がある
- 細かなロジックにバグがあり、本当は期待通りの動作になっていない
- テストや動作確認が不十分で、イレギュラーなケースをカバーできていない
PoCではこの状態でもあまり問題になることはないかもしれませんが、本番システムとしてリリースする場合には、以下の条件を満たしている必要があります。
- ロジック全体が、細部も含めて期待通りの動作になっている
- イレギュラーなケースであっても適切に動作する
上記の条件を満たしていることを保証するためには、まず開発者テストとQAテストを入念に行うことが必要になります。
開発者テスト
開発者テストは、典型的にはコードとして記述して、主に個々の関数やコンポーネントが正しく動作するかをテストするものです。「テストを書く」と言う際に指している「テスト」というのが一番分かりやすいかもしれません。データサイエンティストの方もご存じかと思いますので、開発者テストそのものの詳細な説明は割愛させていただきます。
開発者テストは、PoCの開発においても、特に重要な部分(例えばレポーティングの際の指標を算出するコンポーネントなど)については行うこともあると思いますが、網羅的に行うことは少ないと思います。一方、本番システムの開発においては、コード全体が期待通りに動作することを保証するため、なるべく網羅的に書く必要があります。単純な話ですが、この点がPoCレベルと本番レベルの開発との間の重要なギャップの一つだと思います。
開発者テストの網羅性を担保するための方法の一つとして、テストカバレッジの測定があります。テストカバレッジとは、用意したテストを実行する過程で、テスト対象のコードのうちのどの程度の割合のコードが実際に実行されたかを表す指標です。テストカバレッジは通常パーセンテージで表され、高いパーセンテージであるほどより多くのコードがテストされていることを示し、網羅性が高いことを示します。(しかし、100%のテストカバレッジが達成されていたからといって、完璧なテストであることが保証されている訳ではないことには注意が必要です)
私のチームではテストフレームワークとしてpytestを使っているため、テストカバレッジの測定ツールとしてはそれに対応したpytest-covを利用しています。また、開発者テストはGitHubのPull Request作成・更新などのタイミングでGitHub Actionsを用いて自動的に実行されるようにしており、pytest-coverage-commentというActionを利用して、テストの実行完了時にテストカバレッジがコメントとして投稿されるようにしています。
また、データサイエンス系のプロジェクト特有の話題として、複雑で条件分岐も多いロジックのテストをどのように行うか?という問題があります。この問題については、ロジックを一まとめの関数として実装するのではなく、シンプルで小さく、個々にテストしやすい関数に分解して実装し、細かな条件分岐に関するテストはその小さな関数について行い、それらを組み合わせたより大きな関数では、全体として正常に動作するかを中心にテストを行う、というような対応を取っています。また、システム全体として具体的なデータに対してロジックが正常に動作しているかのテストは、後述するQAテストで実施しています。
QAテスト
QAテストは開発者テストとは異なり、データサイエンティストの中には聞きなじみのない方も多いかと思います。
QA (Quality Assurance) テストとは、開発テストではカバーできない、システムが全体として期待通りに動作するかをテストするものです。PoCの開発においても、コード全体を実行して動作確認することは多々あるかと思いますが、その動作確認を例外的なケースや異常なケースも含めて、体系的に行うものとイメージしていただければ良いかと思います。
具体的には、QAテストは以下のような流れで体系的に実施します。
- 正常ケースだけではなく、例外的・異常なケースも含めて、どういった観点でテストするか(テスト観点)を決める
- テスト観点に従って、具体的なテストケースをリストアップする
- 各テストケースは以下から構成される
- 前提条件:テストの前提となるテストデータなどの状態
- 手順:テストを実行する際の手順
- 期待値:テストを実行した際に期待される結果
- 各テストケースは以下から構成される
- 実際にシステムを動作させて各テストケースを実行し、結果が期待通りかどうかを判定する
- テストケースの実行は自動化する場合もあるが、手動で行う場合も多い
- 期待通りでなかった場合はバグとして報告し、修正をする
本番システムでは、コアロジックの正常な動作だけではなく、システム全体として正常に動作することを保証する必要があるため、開発者テストだけでなくシステム全体をテストするQAテストも重要になります。
QAテストの詳細や方法論については様々な情報源で知ることができると思いますが、初学者にはこのマンガが取っつきやすく一通り全体像を知ることができておすすめです。
また、開発者テストの項目の最後で話題に取り上げた、データサイエンス系特有の、複雑で条件分岐も多いロジックのテストに関しては、QAテストのテストケースとして細かな条件分岐を全て網羅するのはあまり現実的ではないので、その部分の正しさの担保は開発者テストの方に任せ、QAテストでは現実のデータを(多様性を保って)何例かピックアップしてきて期待通りの結果になるかをテストしたり、代表的な例外的ケースをいくつか作成してテストするというような対応を取っています。
システムが複雑・規模の大きい場合のTips
次に、システムが複雑・規模が大きくなってきた場合にも見通しよく・効率よく本番レベルの開発を行うためのTipsを紹介します。こちらのTipsについては、PoCレベルの開発であっても複雑・規模が大きい場合には役に立つことも多いと思います。
データサイエンスに関するシステムが複雑・規模が大きくなってきた場合の特有の問題として、「DataFrameを受け取ってDataFrameを返すような関数を大量に定義することになって、どの関数にどのようなDataFrameを渡す必要があって、どのようなDataFrameが返ってくるのか訳が分からなくなってくる」というような問題があるかと思います。
このようなコードの状態だと、機能の追加・修正・保守が困難になり、開発のスピードが低下してしまいます。また、開発者テストの作成にもコストがかかり、本番システムに求められる、様々なケースにおいて正常に動作することの担保も難しくなってしまいます。そこで、このようなコードを改善するためのTipsを紹介します。
適切なデータクラスを定義する
データサイエンティストがデータに関する処理を実装する場合、典型的にはPandasのDataFrameに対しての処理として実装していくことが多いと思います。
例えば、各レコードが以下のようなスキーマに従っている、何らかの取引のデータに関する処理を考えます。
フィールド名 | データ型 |
---|---|
id |
文字列型 |
timestamp |
日時型 |
product |
列挙型 |
price |
整数型 |
このようなデータをDataFrameとして扱って処理を実装する場合、カラムが id
, timestamp
, product
, price
から成るDataFrameを入出力とするような関数として実装することになると思います。
def process_trades(trades_df: pd.DataFrame) -> pd.DataFrame: ...
しかし、このような形で実装すると、このDataFrameに対し、どのような名前のカラムから構成されているか、それらのカラムがどのようなデータ型になっているか、コード上では保証できていないため、以下のような問題点があります:
- コードを見ただけではどのようなスキーマのデータかが分からない
- フィールドの過不足や、それらの型の誤りを静的に検知できない
- バリデーションのコードを書いて実行時に検知することはできるが、各処理について入れる手間がかかる上に抜け漏れの可能性がある
- 各レコードのフィールドを参照するコードを書く際に、誤ったフィールド名で参照してしまっても静的に検知できない
- エディタやIDEでもそのようなコードを書こうとした際にも適切に補完されない
- フィールドを参照してきた際に、その型が静的に推論されない
この問題への対処方法としては、大きく以下の2つが考えられます。
- データはDataFrameとして扱いつつ、panderaなどのライブラリを使ってスキーマを指定する
- 各レコードをデータクラスとして定義し、そのインスタンスのリストとしてデータを扱う
1.の方法でも上述の問題点はある程度解決できます。特に、データ量が大きかったりPandasの関数を使う必要があるなどDataFrameのまま扱うことが望ましい場面では1.の方法が適していると思います。
一方で、そのような事情がない場合には、2.の方法は上述の問題点を全て解決できるため、こちらの方法を採るのが適切と考えられます。直近携わっているプロジェクトでは扱うデータ量があまり大きくないこともあり、2.の方法を主に採用しています。
2.の方法を実現するには、データのクラスを通常のPythonのクラスとして定義することもできますが、Python標準のdataclassや、pydanticなどのライブラリを使うとより簡潔に定義できます。
dataclassを使う場合:
@dataclasses.dataclass class Trade: id: str timestamp: datetime.datetime product: Product # Product は Enum として別に定義している price: int
pydanticを使う場合:
class Trade(pydantic.BaseModel): id: str timestamp: datetime.datetime product: Product price: int
特に、pydanticは、dataclassに比べ、実行時に型のバリデーションをしてくれたり、また型だけでなくそれ以上のカスタムのバリデーション(例えば、price
として正の整数のみを許容するバリデーションなど)を行ってくれるなどの優位性があるため、私のチームではpydanticを使ってデータクラスを定義することが多いです。
このようなデータクラスを入出力として関数を定義すると、
def process_trades(trades: list[Trade]) -> list[Trade]: ...
のようになり、入出力がどのようなデータかが明確になり、DataFrameを入出力としていた場合の問題点を解決することができます。
おわりに
この記事では、データサイエンティストがPoCレベルを脱却して、本番レベルの品質の開発を行うためのTipsとして、まず開発者テスト・QAテストの重要性についてお伝えしました。また、DataFrameを濫用せずpydanticなどを用いて適切なデータクラスを定義することでコードを改善するというTipsをご紹介しました。
enechainで私の所属しているData Science Deskでは、データサイエンスを活用してエネルギーのマーケットを作り上げていく新たなメンバーを募集しています。
明日以降の enechain Advent Calendar 2023 の記事もお楽しみください!