機械学習システムとテスト (2/4)
このシリーズでは機械学習システムにおけるテストについて考えていきます。全 4 回を予定しているうちの今回は第 2 回目です。
第 1 回目では機械学習システムのテストと通常のソフトウェアのテストを比較し、「テスト項目の設計」や「妥当性の評価」が困難であることを確認しました。第 2 回目以降では具体的な機械学習パイプラインを想定し、さまざまなテストについて具体的に検討します。
想定する機械学習パイプライン
本シリーズではテスト対象とする機械学習パイプラインとして reproio/lab_sample_pipelines を想定します。この機械学習パイプラインは次の処理を行うコンポーネントから成り立ちます。
ETL
前処理
機械学習モデルの訓練
評価
それぞれのコンポーネントはコンテナイメージであり、Python で書かれたスクリプトを実行します。また、それぞれのコンポーネントは別のコンポーネントの出力を入力として利用します。たとえば、機械学習の訓練を行うコンポーネントは前処理を行うコンポーネントの出力を入力ととして利用します。
今回はパイプライン自体の実装には立ち入りません。パイプラインは実装の詳細については別記事を参照ください。
テストの全体像
以降ではまず、採用するテストの粒度について検討します。次に、それらの組み合わせのために採用するテストのモデルについて検討します。
テストの粒度
今回の機械学習パイプラインはコンテナで起動する Python のアプリケーションであるため、通常のコンテナを用いたアプリケーションのテストを検討できます。次の粒度でテストを検討できるでしょう。
単体テスト
結合テスト (コンポーネント単位でのテスト)
パイプラインのテスト
また、上記に加えて本番環境でのテストも検討します。機械学習システムは一般的に評価用のデータセットを用いてテストされますが、本番環境で用いられるデータをあらかじめ評価用のデータセット内にすべて含めることは不可能です。
また、ユーザーの行動をもとに再学習を行うような機械学習システムでは、ユーザーとシステムの間のフィードバックループによりシステムが望ましくないふるまいをすることが知られています。これはコンテンツのレコメンドを行う場合にはフィルターバブルとしてよく知られています。このような現象のテストを評価用データセットだけから行うことは非常に困難であり、本番環境でのテストが必要です。
本番環境でのテストでは次の 3 つを検討します。
カナリアリリースに代表される段階的なリリースによるテスト
A/B テストに代表されるビジネス指標への影響のテスト
監視・モニタリングによる継続的な品質のテスト
上記のような「品質保証の取り組みをワークフローの後ろの方に広げていく取り組み」をシフトライトと呼びます。
機械学習システムのテストを考慮する上では、本番環境でモデルに入力される未知データに対する振る舞いの考慮が不可欠です。シフトライトの考え方を取り入れることで、テストの範疇を本番環境まで広げることができます。
テストのモデル
機械学習システムのテストを整理する方法について検討します。通常のソフトウェアテストにおいてよく用いられるのは次のようなテストピラミッドでしょう。
上記の図では本番環境でのテストについて考慮できていないため、拡張が必要です。単純に本番環境のテストを追加すると次のようになるでしょう。
この図は簡潔なのでホワイトボードで手書きし、議論を推進する目的では適しています。一方、機械学習システムのテストを書き表すにはいささか不十分です。
この図からは「ある機能について網羅的に単体テストが行われており、その一部に対して結合テストを行う」という印象を受けます。しかし、機械学習システムのテストでは必ずしもそれは正しくありません。たとえば「モデルの推論」のためにはモデルの訓練がその前にかならず必要になるため、厳密に単体テストに還元することは不可能です。
このように、機械学習システムのテストでは、異なるレイヤーで異なる範囲のテストを実行することになります。次のバグフィルターの図はそのような状況を表現できます。
この図では上から降ってくるさまざまな大きさのバグを、積み重ねられたフィルターで補足していきます。
積み重ねられた 6 つの箱は、システムで行われるさまざまなテストのレイヤーを示しています。上から下に進むに従って本番環境に近づいていき、下三段は本番環境のアラート検知、モニタリング、ロギングとなっています。
箱に備え付けられたメッシュの細かさはそのレイヤーで検知できるバグの細かさを示しています。ユニットテストはコードの細かなバグを検知できますが、アラートではそれよりも大きなバグのみが検知されます。
それぞれの箱の大きさは、そのレイヤーで検知できるバグの範囲を示しています。上記のような機械学習モデルの推論は上のレイヤーではテストされずに、あるレイヤーで急にテストされることになるでしょう。
このようなバグフィルターを用いてテスト戦略を立案することで、機械学習のテストという複雑な問題により良く取り組めるでしょう。
機械学習パイプラインの単体テスト
以降では機械学習パイプラインの単体テストについて検討します。それぞれのコンポーネントごとに単体テストで実施すべきテストを検討していきましょう。
機械学習に限らず、テストの検討の際にしばしば問題になるのが「どこまでが単体テストでどこからが結合テストなのか」という定義です。
前述したように、機械学習モデルの推論を扱う場合、厳密には単体テストの範疇を超えてしまいます。このような混乱を避けるため、単体テストではコードのみをテスト対象とし、データはテスト対象外とします。また、以降では「テストに必要なデータはテストコード中で生成するか、数個のサンプルを用いる」という前提をおいて考えます。
この粒度でのテストは、この粒度でのテストは通常のソフトウェア開発におけるテスト手法がかなり流用できます。以降で確認していきましょう。
ETL 処理の単体テスト
ETL 処理を行うコンポーネントの単体テストについて検討します。チェックする項目としては次のものが考えられるでしょう。
データソースに正常にアクセスできる場合、読み込んだデータのカラム名が正しい
データソースに正常にアクセスできる場合、読み込んだデータの型 (shape やデータ型) が正しい
データソースに正常にアクセスできる場合、読み込んだデータの件数が正しい
上記のテストは通常のソフトウェア開発で行うテストとそう変わらないでしょう。機械学習パイプラインにおいて、機械学習のアルゴリズムを直接扱わない部分もかなりの割合で存在します。実際、今回のコンポーネントのうち半分は機械学習を直接扱いません。
機械学習を扱っていない部分については通常のソフトウェア開発で行うテストの手法をを適用できます。また、データサイエンティストがソフトウェアのテストを学ぶことで、普段の業務に活かせる知識や技術を得られるとも言えるでしょう。
以降では通常のソフトウェア開発で行うようなテストについては割愛していきます。
前処理の単体テスト
次に、前処理を行うコンポーネントの単体テストについて検討します。今回サンプルとして用いているパイプラインでは前処理でデータを変換するのと同時に、データを訓練用と評価用に分割しています。ここではその両方の処理について検討します。
この前処理を行うコンポーネントにおいても大半が普通のソフトウェア開発で行うテストに同じですが、データの分割には特別の注意が必要です。以降で詳しく見ていきましょう。
機械学習の訓練用のデータセットと評価用のデータセットを作成する際に、単純にデータセットをランダムに分割するだけでは十分でないケースがあります。訓練用のデータとは別に用意する必要があることについてはさまざまな本で述べられていますが、追加でどのような考慮が必要になるのかいくつかのケースで確認しましょう。
時系列データの分割
第一に、時系列データを用いている場合は分割においても時系列を加味した考慮が必要です。
具体例としては本番環境での機械学習モデルの監視について (1/3)で示した需要予測が挙げられます。このように、特定期間の情報から未来の情報を予測するような機械学習タスクの場合には特別な考慮が必要です。
記事中では 3 月の行動履歴と 4 月の購買回数を集計したデータを訓練データとし、4 月の行動履歴と 5 月の購買回数を集計したデータを評価に用いています。これはモデルの運用においては未知の期間に対する精度を評価したいためです。
もし、3 月の行動履歴と 4 月の購買回数を集計したデータを単純にランダムに分割して訓練用データと評価用データを作成すると、どちらも同じ期間のデータで評価することになってしまいます。この結果、例えば 3/2 のデータが訓練データにが含まれ、3/3 のデータが評価データに含まれることになり、モデルにとっては過剰に有利な状況設定となってしまいます。
このようなデータの分割を行う場合、評価用データと検証用データでは期間が重ならないようにロジックが実装されているかテストすると良いでしょう。
時系列データにおける訓練用データと評価用データの分割ミスは単純ですが、事例として枚挙に暇がありません。データの分割においてまず注意すべき点のひとつと言えます。
極端に偏ったデータの分割
他にも、分類問題において極端に予測対象が偏っている場合にも注意が必要です。前述の需要予測タスクではアイテムを購入しているユーザーがサービス利用者全体のごく一部といった場合が該当します。
このような場合に、ランダムな分割では訓練用データや評価用データで購入しているユーザーの割合がさらに偏ってしまい、うまく評価できなくなることがあります。ランダムな分割に任せた場合では、極端な場合、評価用データに購入したユーザーのデータが 1 件もないということも発生しうるでしょう。
もし、あらかじめ予測対象の分布が極端に偏っているとわかっている場合、データの分割前後で割合が保たれるように調整されるように実装されているか、テストすると良いでしょう。
ここまでに述べてきたデータの分割におけるミスは実行時のエラーを引き起こしにくく、気が付きにくいバグとなります。一口にデータの分割といってもさまざまな手法があります。scikit-learn の cross validation に関するドキュメントが詳しいので、実装前に一度確認しておくと良いでしょう。
データの分割に失敗した事例
正しいデータの分割方法は、そのデータの収集方法に依存して決まります。もし、仕様を策定する段階で誤った分割方法を選択してしまった場合には仕様バグが発生してしまいます。
データの分割に関する仕様バグは単体テストだけでは検知することが困難なため、この記事の範疇を超えてしまいますが、前述したとおり枚挙にいとまがないものでもあります。ここではいくつかの事例を紹介します。
最初の事例は医療用画像の事例です。X 線画像から肺炎の検出を行うタスクにおいて、専門家を超える精度の機械学習モデルの作成に成功したという論文が出たことがあります。しかし、これは訓練データと評価データの作成にミスがあり、正しく評価できていなかったことが後に明らかになりました。
使われたデータセットには同じ患者から撮影された画像が複数枚含まれており、これをランダムに訓練データと評価データに割り振っていました。このため、訓練データと評価データに同じ患者の画像が含まれてしまいました。
この結果、訓練データと類似したデータで評価することになってしまい、モデルの性能を過剰に高く評価してしまいました。
この事例は深層学習のリーダーである Andrew Ng によるものです。現在もその発言を確認できます。正しくデータを分割することは困難であり、専門家であっても間違えてしまうものであることをよく示しています。
次の事例は画像認識コンペにおける不備です。SIGNATE で行われたひろしまQuest2020:画像データを使ったレモンの外観分類では当初、ひとつのレモンを複数回撮影してデータセットを作成しており、同じレモンの画像が訓練データと評価データに含まれていました。
このコンペにはほかにも複数の問題があり、データセットを作り直しコンペが仕切り直されました。
最後の事例は時系列データをランダムに分割した例です。京都大学の研究で、深層学習モデルを用いて気候変動を予測するモデルを作成したという発表がなされたことがあります。論文では評価方法について、ランダムにデータセットを分割し、訓練データと評価データを作成したと述べられています。
この方法では訓練データと評価データに同じ時期のデータが含まれてしまうため、やはり似たデータで訓練と評価を行うことになり、適切な評価方法とは言えません。実際、追試を試みたところ同じような性能を出すことはできなかったというブログ記事も存在します。
このような訓練用・評価用データの分割については、現状、人間がドメイン知識や過去の経験から正しさを判断するしかありません。上記のような事例から学び、正しいデータの分割について検討する必要があります。
訓練処理の単体テスト
モデルの訓練を行うコンポーネントの単体テストについて検討しましょう。
まず注意しておきたいのは、必ずしも機械学習アルゴリズムのすべてを検査しなければいけないわけではないという点です。もし、ライブラリが提供する既存の機械学習アルゴリズムを使うのであれば、アルゴリズム自体のテストはライブラリの責務として良いでしょう。
単体テストとしては次のような項目が考えられます。
機械学習アルゴリズムに渡っているデータの型 (shape やデータ型) が正しい
機械学習モデルにダミーデータを与えて訓練させたとき、訓練前後でモデルのパラメーターが変化している (例: 深層学習モデルの場合、各レイヤーの重みが変化している)
機械学習モデルに単純なダミーデータを与えて訓練させたとき、学習させたダミーデータを入力するとダミーデータのラベルと同じ値が返る
訓練済みのモデルに想定するデータを渡すと正しく受け付ける (エラーを引き起こさない)
訓練済みのモデルに想定するデータを渡したとき、帰ってくるデータの型が正しい
(損失関数を自分で実装した場合) 実装した損失関数に自明な入力を与えたときの出力が正しい (例: 損失関数の入力に同じ値を入力した場合に 0 が出力される)
訓練済みのモデルを保存したあと、そのファイルが存在する
機械学習モデルを訓練するためのコードについて、エラーがあったとしても学習用のアルゴリズムが動いてしまうことがある点に注意しましょう。
たとえば、損失関数の実装にバグがあり、100 より大きな値しか返さなかったとしても勾配降下法は「正しく」動き続けます。上記のような状況では、訓練処理中に損失が思うように減らず、データセットを含めてさまざまな箇所を疑う必要が出てしまいデバッグが困難になります。
結果を手で計算できる入力を与え、理論通りの結果が帰ってくるか確認しておくと良いでしょう。
最後に、機械学習モデルに対するテストコードを実装する場合、訓練を実行した結果を保持する必要がある点に注意しましょう。この制約により、テストとテストの間に順序関係を生まれてしまうケースがあります。
単体テストを分散して実行しているケースでは、できるだけそのような記述を避けつつも、あまりにもわかりにくい記述とならないよう折り合いをつけていく必要があるでしょう。
評価処理の単体テスト
最後に、モデルの評価を行うコンポーネントの単体テストについて検討します。
モデルの訓練を行うコンポーネントと同様に、評価用の指標の計算すべてについてテストしなければいけないわけではありません。ライブラリが提供する評価用の関数 (scikit-learn の accuracy など) のテストはライブラリの責務とできます。もし、ライブラリが壊れているのならライブラリを直しましょう。
単体テストとしては次のような項目が考えられます。
訓練済みのモデルを保存したファイルが存在する場合、正しく読み込める
訓練済みのモデルに想定するデータを渡すと正しく受け付ける (エラーを引き起こさない)
訓練済みのモデルに想定するデータを渡したとき、帰ってくるデータの型が正しい
(評価用の関数を自分で実装した場合) 実装した評価関数に自明な入力を与えたときの出力が正しい
単体テストとは別の話題になりますが、ライブラリが提供するものであったとしても評価関数自体の振る舞いは事前に検証しておくことをおすすめします。
たとえば、R2 スコアは広く用いられる指標ですが、R2 スコアにはさまざまな定義があります。たとえば、scikit-learn で提供される R2 スコアは負の値を取りえますが、これはもしかすると手元の統計の教科書では予期しない振る舞いかもしれません。そのライブラリが計算している指標の定義や振る舞いは確認しておくと良いでしょう。
本記事では機械学習システムのテストの全体像について検討し、機械学習パイプラインの単体テストについて検討しました。次回はコンポーネント単位でのテストについて検討します。