死んだライブラリを蘇らせる:パート2 ― 安定化
この記事では、テストのないレガシーなライブラリに自動テストを後付けする方法を紹介します。
これは、機械学習を使って料理の材料表記(例:「2 cups milk」)を構造化データにパースするライブラリ ingredient-phrase-tagger を私がどのように蘇らせたかを綴る全3回シリーズのパート2です。詳しい経緯はパート1をご覧ください。かいつまんで言えば、放棄されたライブラリを見つけ、自分のSaaSビジネスを支えられるように復活させたという話です。
- パート1:蘇生 - モダンな環境ならどこでも動くようにコードを手当てする
- パート2:安定化(本記事) - コードの修復を進める間、機能が後退しないようにする
- パート3:リハビリテーション - コードのリファクタリングに着手する
継続的インテグレーションで実行する
パート1の最後で、どの環境でもライブラリを動かせるDockerイメージを作成しました。次のステップは、ライブラリを継続的インテグレーションで実行することでした。
継続的インテグレーションとは、独立した管理された環境で、コードの変更があるたびにソフトウェアをテストする手法です。私が好んで使っているのはTravisです。設定ファイルが直感的で、オープンソースプロジェクトならビルド回数が無制限で無料だからです。
Travisと連携するため、Travisの設定ページでingredient-phrase-taggerの私のフォークを追加し、ビルドを有効にしました。

ingredient-phrase-taggerライブラリでTravisビルドを有効にする
続いて、Travisにライブラリのビルド方法を指示するための .travis.yml というファイルを作成しました。
sudo: required
services: docker
script: docker build .コミットをGitHubにプッシュしてプルリクエストを作成すると、Travisがビルドに成功しました。

Travisで初めてビルドが成功した様子
エンドツーエンドテストを追加する
TravisはDockerイメージをビルドしていましたが、そのビルドはまだ意味のあるものではありませんでした。依存関係をビルドしているだけで、ライブラリの振る舞いは一切検証していなかったのです。ライブラリの機能を壊したときに知らせてくれるビルドが欲しい。そのためにはエンドツーエンドテストが必要でした。
エンドツーエンドテストとは、現実のシナリオ全体が期待通りに動くことを検証するテストです。おおむね次のような構成になります。
- 事前に用意した入力と、その期待される出力(いわゆる「ゴールデンアウトプット」)を用意する。
- 自動化ツールを使ってライブラリに入力を与える。
- ライブラリの出力をゴールデンアウトプットと比較する。
元のリポジトリにはroundtrip.shというスクリプトがあり、エンドツーエンドテストに近いものでした。事前に用意した入力をライブラリに渡し、入力の一部で新しい機械学習モデルを学習させ、そのモデルで残りの入力をパースするというものです。唯一欠けていたのは、結果を正しいと分かっている出力と比較していなかったことでした。
基本的なエンドツーエンドテスト
パート1で紹介したとおり、roundtrip.shスクリプトの最終的な出力は、モデルの性能に関するサマリー統計でした。
Sentence-Level Stats:
correct: 1487
total: 1999
% correct: 74.3871935968
Word-Level Stats:
correct: 10391
total: 11450
% correct: 90.7510917031これでシンプルなエンドツーエンドテストを作るには十分でした。roundtrip.shスクリプトの最後のステップを再実行し、コンソール出力を tests/golden/eval_output という新しいファイルにリダイレクトして、バージョン管理に追加しました。
python bin/evaluate.py tmp/test_output > tests/golden/eval_output正しい出力が手に入ったので、roundtrip.shの末尾を修正し、今後はすべての出力をこの保存した出力と比較するようにしました。
python bin/evaluate.py tmp/test_output > tmp/eval_output
diff tests/golden/eval_output tmp/eval_outputテストはコードの破損を検知できるか?
エンドツーエンドテストはバグを捉えてこそ意味があります。そこで、破壊的な変更をあえて加えて、テストがそれを検知できるか試してみました。
cli.pyには、数字の並び(例:"83625")にマッチする正規表現がありました。
m3 = re.match('^\d+$', ss)実験として、この正規表現を少し変えて、9を含む数字は認識しないようにしてみました。
m3 = re.match('^[0-8]+$', ss)そのうえで、修正したroundtrip.shスクリプトを再実行しました。
3c3
< correct: 1487
---
> correct: 1486
5c5
< % correct: 74.3871935968
---
> % correct: 74.33716858429うまくいきました!
コードに「9は数字ではない」と教えたところ、ライブラリの精度が下がり、スクリプトは失敗を示す終了コードで終了しました。
エンドツーエンドテストを拡張する
上記の基本的なエンドツーエンドテストは有用でしたが、roundtrip.shは複数のステージからなるデータパイプラインを実行しています。どのステージで壊れたのかが分かると便利なので、エンドツーエンドテストに含められる出力をさらに探しました。
コンソールへの出力に加えて、このスクリプトは tmp/ というサブディレクトリにもファイルを作成していました。
$ file tmp/*
tmp/model_file: data
tmp/output.html: HTML document, ASCII text, with very long lines
tmp/test_file: ASCII text
tmp/test_output: ASCII text
tmp/train_file: ASCII texttest_file、test_output、train_fileはいずれもプレーンテキストのファイルで、中身は次のようなものでした。
$ head -n 16 tmp/test_file
1 I1 L12 NoCAP NoPAREN B-QTY
boneless I2 L12 NoCAP NoPAREN I-COMMENT
pork I3 L12 NoCAP NoPAREN B-NAME
tenderloin I4 L12 NoCAP NoPAREN I-NAME
, I5 L12 NoCAP NoPAREN B-COMMENT
about I6 L12 NoCAP NoPAREN I-COMMENT
1 I7 L12 NoCAP NoPAREN B-QTY
pound I8 L12 NoCAP NoPAREN I-COMMENT
Salt I1 L8 YesCAP NoPAREN B-NAME
and I2 L8 NoCAP NoPAREN I-NAME
freshly I3 L8 NoCAP NoPAREN B-COMMENT
ground I4 L8 NoCAP NoPAREN I-COMMENT
black I5 L8 NoCAP NoPAREN B-NAME
pepper I6 L8 NoCAP NoPAREN I-NAMEこの時点ではファイル形式を理解していませんでしたが、理解する必要はありませんでした。必要なのは、ファイルが変わったことを検知する手段だけでした。
これらのファイルを tests/golden にコピーし、追加のゴールデンアウトプットとしてバージョン管理に保存しました。そして、ビルドスクリプトに diff を追加して、これらの出力ファイルが変わったときに検知できるようにしました。
完成したビルドスクリプト
roundtrip.shへの修正が一通り終わったので、これを build.sh という新しいファイルとして保存しました。内容は次のとおりです。
#!/bin/bash
# Exit build script on first failure
set -e
# Echo commands to stdout.
set -x
COUNT_TRAIN=20000
COUNT_TEST=2000
OUTPUT_DIR=$(mktemp -d)
ACTUAL_CRF_TRAINING_FILE="${OUTPUT_DIR}/training_data.crf"
ACTUAL_CRF_TESTING_FILE="${OUTPUT_DIR}/testing_data.crf"
ACTUAL_CRF_MODEL_FILE="${OUTPUT_DIR}/model.crfmodel"
ACTUAL_TESTING_OUTPUT_FILE="${OUTPUT_DIR}/testing_output"
ACTUAL_EVAL_OUTPUT_FILE="${OUTPUT_DIR}/eval_output"
bin/generate_data \
--data-path=nyt-ingredients-snapshot-2015.csv \
--count=$COUNT_TRAIN \
--offset=0 > "$ACTUAL_CRF_TRAINING_FILE"
bin/generate_data \
--data-path=nyt-ingredients-snapshot-2015.csv \
--count=$COUNT_TEST \
--offset=$COUNT_TRAIN > "$ACTUAL_CRF_TESTING_FILE"
crf_learn \
template_file "$ACTUAL_CRF_TRAINING_FILE" "$ACTUAL_CRF_MODEL_FILE"
crf_test \
-m "$ACTUAL_CRF_MODEL_FILE" \
"$ACTUAL_CRF_TESTING_FILE" > "$ACTUAL_TESTING_OUTPUT_FILE"
python bin/evaluate.py "$ACTUAL_TESTING_OUTPUT_FILE" > "$ACTUAL_EVAL_OUTPUT_FILE"
# Check against golden output.
GOLDEN_DIR=tests/golden
GOLDEN_CRF_TRAINING_FILE="${GOLDEN_DIR}/training_data.crf"
GOLDEN_CRF_TESTING_FILE="${GOLDEN_DIR}/testing_data.crf"
GOLDEN_TESTING_OUTPUT_FILE="${GOLDEN_DIR}/testing_output"
GOLDEN_EVAL_OUTPUT_FILE="${GOLDEN_DIR}/eval_output"
diff --context=2 "$GOLDEN_CRF_TRAINING_FILE" "$ACTUAL_CRF_TRAINING_FILE"
diff --context=2 "$GOLDEN_CRF_TESTING_FILE" "$ACTUAL_CRF_TESTING_FILE"
diff --context=2 "$GOLDEN_TESTING_OUTPUT_FILE" "$ACTUAL_TESTING_OUTPUT_FILE"
diff "$GOLDEN_EVAL_OUTPUT_FILE" "$ACTUAL_EVAL_OUTPUT_FILE"次に、このスクリプトをライブラリ専用のDockerコンテナ内で実行するための docker_build というシンプルなラッパーを追加しました。
#!/bin/bash
# Exit on first failing command.
set -e
# Echo commands to console.
set -x
IMAGE_NAME="ingredient-phrase-tagger-image"
CONTAINER_NAME="ingredient-phrase-tagger-container"
docker build \
--tag "$IMAGE_NAME" \
.
docker run \
--tty \
--detach \
--name "$CONTAINER_NAME" \
"$IMAGE_NAME"
docker exec "$CONTAINER_NAME" ./build.shdocker_buildスクリプトがあれば、Dockerが動く環境ならどこでもエンドツーエンドテストを実行できます。当然、継続的インテグレーション環境でも実行したいところです。
継続的インテグレーションでエンドツーエンドテストを実行する
以前のTravis設定ではDockerイメージをビルドするだけで、ライブラリの動作までは検証していませんでした。しっかりしたテストスクリプトができたので、.travis.ymlを更新して実行するようにしました。
sudo: required
services: docker
-script: docker build .
+script: ./docker_build変更をプッシュし、どこでも一貫して動く素晴らしいテストの雄姿を拝むつもりでした。しかし結果は失敗でした。

ローカルでは成功したエンドツーエンドテストがTravisでは失敗する
ビルドが壊れたのは残念でしたが、エンドツーエンドテストが何かを検知してくれたのは喜ばしいことでした。あとは原因を突き止めるだけです。
不一致の原因をデバッグする
Dockerコンテナの存在意義は、どこで実行してもプログラムが同じように振る舞うことです。同じコンテナを2箇所で実行して、なぜ出力が異なるのでしょうか。
Travisのビルドログを見ると、testing_outputファイルのdiffでテストが失敗していました。
+ diff --context=2 tests/golden/testing_output /tmp/tmp.W5S3C5T4if/testing_output
*** tests/golden/testing_output Fri Jul 27 02:44:20 2018
--- /tmp/tmp.W5S3C5T4if/testing_output Fri Jul 27 03:03:56 2018
***************
*** 173,178 ****
1 I1 L8 NoCAP NoPAREN B-QTY B-QTY
tablespoon I2 L8 NoCAP NoPAREN B-UNIT B-UNIT
! dark I3 L8 NoCAP NoPAREN B-COMMENT B-COMMENT
! corn I4 L8 NoCAP NoPAREN B-NAME B-NAME
syrup I5 L8 NoCAP NoPAREN I-NAME I-NAME
--- 173,178 ----
1 I1 L8 NoCAP NoPAREN B-QTY B-QTY
tablespoon I2 L8 NoCAP NoPAREN B-UNIT B-UNIT
! dark I3 L8 NoCAP NoPAREN B-COMMENT B-NAME
! corn I4 L8 NoCAP NoPAREN B-NAME I-NAME
syrup I5 L8 NoCAP NoPAREN I-NAME I-NAMEtesting_outputファイルは、build.shスクリプト内の次の2行によって生成されていました。
crf_learn \
template_file "$ACTUAL_CRF_TRAINING_FILE" "$ACTUAL_CRF_MODEL_FILE"
crf_test \
-m "$ACTUAL_CRF_MODEL_FILE" \
"$ACTUAL_CRF_TESTING_FILE" > "$ACTUAL_TESTING_OUTPUT_FILE"crf_learnとcrf_testはいずれもCRF++のコマンドラインユーティリティで、ingredient-phrase-taggerの機械学習ロジックを支えるエンジンです。これらのユーティリティについて詳しく知らなくても、構文からcrf_learnが機械学習モデルを作成し、crf_testがそのモデルを使ってデータを分類していることは推測できました。
エンドツーエンドテストでは、$ACTUAL_CRF_TRAINING_FILEと$ACTUAL_CRF_TESTING_FILEの内容がゴールデンバージョンと一致することを確認していました。つまり、crf_learnとcrf_testにはローカル環境と継続的インテグレーション環境でまったく同じ入力が渡されていたにもかかわらず、環境によって異なる出力が生成されていたことになります。
CRF++をさらに深掘りする
CRF++は非決定的なのだろうか? ローカルでテストを再実行すると成功しました。Travisで再実行すると同じ形で失敗しました。つまりCRF++は同じ環境内では一貫していましたが、環境をまたぐと結果が一致しなかったのです。
この方向性はあまり好ましくありませんでした。CRF++の挙動が基盤となるハードウェアに依存している可能性を示唆していたからです。もしかするとIntel CPUとAMD CPUで結果が異なるのかもしれません。そうだと厄介です。Travisはハードウェア環境について何も保証していません。さらに、ハードウェアの違いで結果が変わるのであれば、Dockerコンテナの意味がなくなってしまいます。
藁にもすがる思いで、ハードウェア依存の手がかりがないかCRF++のコマンドラインのドキュメントを確認しました。
$ crf_learn --help
...
-p, --thread=INT number of threads (default auto-detect)--threadフラグが気になりました。完全なドキュメントで詳細を確認しました。
-p NUM:
PCに複数のCPUがある場合、マルチスレッドを使うことで学習を高速化できます。NUMはスレッド数です。
これは有望に思えました。
Travis上でのCRF++の出力と、ローカル環境での同じ行の出力を比較しました。

crf_learnはTravisでは2スレッド、ローカル環境では8スレッドで実行されている
なるほど!
--threadフラグを指定していなかったため、CRF++は利用可能なCPUコア数に基づいて自動的に値を設定していました。Travis環境は2コア、ローカルマシンは8コアでした。
そこでbuild.shスクリプトを修正して、スレッド数を明示的に指定するようにしました。
-crf_learn template_file "$ACTUAL_CRF_TRAINING_FILE" "$ACTUAL_CRF_MODEL_FILE"
+crf_learn \
+ --thread=2 \
+ template_file "$ACTUAL_CRF_TRAINING_FILE" "$ACTUAL_CRF_MODEL_FILE"そして、新しく生成された出力ファイルをゴールデンコピーとして保存しました。変更をGitHubにプッシュすると、嬉しい光景が待っていました。エンドツーエンドテストが成功したのです。

Travisでエンドツーエンドテストが成功した様子
良いテストの価値
エンドツーエンドテストはすぐにその価値を証明しました。ライブラリの依存関係のひとつについてドキュメントを掘り下げるのは骨の折れる作業でしたが、このテストのおかげで、ライブラリが環境によって一貫性のない結果を出していたことが明らかになりました。オリジナルの作者たちもおそらく気づいていなかったことでしょう。
エンドツーエンドテストを用意し、継続的インテグレーションを回すことで、ライブラリに期待される動作を示す信頼できる環境ができました。このテストは、意図せずライブラリの挙動を変えてしまうような変更に対する貴重な安全網となりました。
次はどうする?
テストによって得られた自信を胸に、ソフトウェアプロジェクトで一番好きな段階、リファクタリングに取り掛かる時が来ました。あまりに的外れなことをすればビルドがはっきりと失敗してくれると分かっているので、大胆な変更も自由に行えました。
このシリーズのパート3では、次の内容について書いています。
- 単体テストを追加したこと
- コードにスタイル規約を自動適用したこと
- 静的解析をビルドに統合したこと
カバーイラストはLoraine Yow氏によるものです。ingredient-phrase-taggerの私のフォークはGitHubで公開しています。このライブラリをベースにしたマネージドサービスとしてZestfulを提供しています。
記事をランダムに読む

