Resurrecting a Dead Library: Part Two - Stabilization

Michael Lynch

死んだライブラリを蘇らせる:パート2 ─ 安定化

原文は Michael Lynch により に公開されました。 このブログを購読する

この記事では、テストがまったくないレガシーライブラリに自動テストを後付けする方法を紹介します。

これは、放置されていたライブラリを蘇らせ、自身のSaaSビジネスを支えるまでにした過程を綴る全3回のシリーズの第2回です。対象は、料理の材料表記(例:「2 cups milk」)を機械学習で構造化データに解析するライブラリingredient-phrase-taggerです。詳しい背景はパート1をご覧ください。かいつまんで言えば、放棄されたライブラリを見つけ、蘇らせたという話です。

  • パート1:蘇生 ─ コードを手当てして、あらゆるモダンな環境で動くようにする
  • パート2:安定化(今回の記事) ─ コードの修復を進める間、機能が後退しないようにする
  • パート3:リハビリテーション ─ コードのリファクタリングに着手する

ぐらつく家を支えるビーバーたち

継続的インテグレーションで実行する

パート1の最後で、私はライブラリをあらゆる環境で実行できるようにするDockerイメージを作成しました。次のステップは、そのライブラリを継続的インテグレーションで実行することでした。

Travis CIのロゴ

継続的インテグレーションとは、独立した管理された環境を使って、コードが変更されるたびにソフトウェアをテストする手法です。私が愛用している継続的インテグレーションサービスはTravisです。設定ファイルが直感的で、オープンソースプロジェクトには無制限の無料ビルドを提供しています。

Travisと連携させるため、Travisの設定ページで自分がフォークしたingredient-phrase-taggerを追加し、ビルドを有効にしました。

Travisを有効にするスクリーンショット

ingredient-phrase-taggerライブラリでTravisビルドを有効にする

次に、Travisにライブラリのビルド方法を指示する.travis.ymlというファイルを作成しました。

sudo: required
services: docker
script: docker build .

download travis.yml

コミットをGitHubにプッシュし、プルリクエストを作成すると、Travisがビルドに成功しました。

Travis CIでの初回ビルド成功のスクリーンショット

Travisでの初回ビルド成功

エンドツーエンドテストを追加する

TravisはDockerイメージをビルドしていましたが、そのビルドはまだ意味のあるものではありませんでした。ライブラリの依存関係をビルドしているだけで、その動作は一切検証していなかったのです。ライブラリの機能を壊したときに知らせてくれるビルドが欲しい。そのために必要だったのがエンドツーエンドテストでした。

エンドツーエンドテストは、現実的なシナリオ全体が期待通りに動作することを検証します。一般的には次のような構成になります。

  1. 事前に用意した入力と、その期待される出力(「ゴールデンアウトプット」とも呼ばれます)を用意する。
  2. 自動化ツールを使って入力をライブラリに与える。
  3. ライブラリの出力をゴールデンアウトプットと比較する。

元のリポジトリには、エンドツーエンドテストに似た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 text

test_filetest_outputtrain_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"

download build.sh

次に、このスクリプトをライブラリ専用の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.sh

download docker_build

docker_buildスクリプトにより、エンドツーエンドテストはDockerをサポートするあらゆるシステムで実行できるようになりました。当然、それを継続的インテグレーション環境でも実行したいと思いました。

継続的インテグレーションでエンドツーエンドテストを実行する

以前のTravis設定はDockerイメージをビルドするだけで、ライブラリ自体は検証していませんでした。しっかりしたテストスクリプトができたので、.travis.ymlファイルを更新してそれを実行するようにしました。

sudo: required
services: docker
-script: docker build .
+script: ./docker_build

変更をプッシュし、どこでも一貫して実行できる素晴らしいテストの威力を目の当たりにするのを楽しみにしていました。ところが、結果は失敗でした。

Travisでエンドツーエンドテストが失敗する様子

ローカルでは成功したエンドツーエンドテストが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-NAME

testing_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_learncrf_testはいずれも、ingredient-phrase-taggerの機械学習ロジックを担うエンジンであるCRF++のコマンドラインユーティリティでした。これらのユーティリティについて詳しくは知りませんでしたが、構文からcrf_learnが機械学習モデルを作成し、crf_testがそのモデルを使ってデータを分類していることは推測できました。

エンドツーエンドテストにより、$ACTUAL_CRF_TRAINING_FILE$ACTUAL_CRF_TESTING_FILEの中身がゴールデンバージョンと一致することは確認できていました。つまり、crf_learncrf_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++の出力と、ローカル環境での同じ行の出力を比較してみました。

ローカルマシンとTravisで異なるcrf_learnのスレッド数

crf_learnはTravisでは2スレッドで実行されるが、ローカル環境では8スレッドで実行される

なるほど!

--threadフラグを省略していたため、CRF++は利用可能なCPUコア数に基づいて自動的に値を設定していました。Travis環境には2つのCPUコアがあり、ローカルマシンには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を提供しています。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。

コメント