死んだライブラリを蘇らせる:パート1 ─ 蘇生
現場に駆けつけたとき、目の前の光景は決して美しいものではありませんでした。
かつては活発で生き生きしていたPythonのクラスたちが、長年まったく使われないまま、すっかりやせ細った哀れな姿になっていました。抽象度の異なる関数たちは、utilsというラベルの下に非人道的に押し込められています。UIのコードを読もうとしても、何かが邪魔をして読めません。よく見てみると、吐き気を催しました。ビュー層を塞いでいたものは、なんとビジネスロジックの生々しい塊だったのです。
コードは死んでいました。
この全3回のシリーズでは、私がどのようにしてこのコードを蘇らせ、そこからビジネスを作り上げたのかをご紹介します。
- パート1:蘇生(本記事) ― 死にかけたコードを手当てし、どんな現代的な環境でも動くようにします
- パート2:安定化 ― コードを修復する間、機能が後退しないように手を打ちます
- パート3:リハビリテーション ― いよいよコードのリファクタリングを始めます
ライブラリについて
そのライブラリとは、ingredient-phrase-taggerという、ニューヨーク・タイムズが公開したオープンソースのライブラリでした。レシピの材料表記を構造化されたデータに解析するためのものです。
数年前、ニューヨーク・タイムズは膨大な料理レシピの歴史的アーカイブをデジタル化することにしました。彼らはデータ入力の担当者を雇い、これらのレシピに含まれる生の材料表記を見て、そこに含まれるデータを一つひとつ切り分けさせました。その結果、データベースは次のようになりました。
| 原材料表記 | 数量 | 単位 | 名称 | コメント |
|---|---|---|---|---|
| 3 tablespoons flour | 3.0 | tablespoon | flour | |
| 2 1/2 cups of finely chopped red onions | 2.5 | cup | red onions | finely chopped |
| 2 dried pasilla chilies | 2.0 | pasilla chilies | dried |
6年間にわたってこのデータベースを拡充した後、彼らは人間の作業員のデータ入力判断を再現する機械学習モデルを学習させるのに十分なデータが集まったことに気づきました。機械学習モデルを学習させる試みは成功し、彼らはすべてのソースコードとデータを公開しました。
それが私にどう関係するのか
私はニューヨーク・タイムズと同じ問題を抱えていました。私のプロジェクトであるKetoHubは、ウェブ上のレシピを収集して材料で検索できるようにするサービスです。レシピサイトは通常、材料リストを構造化された形式で公開していないため、自分でその構造を切り出す必要がありました。

KetoHubで「avocado」を検索した結果

私のひどい正規表現による解析実装の抜粋
ingredient-phrase-taggerに出会った当時、私は材料をひどく場当たり的な方法、すなわち正規表現で解析していました。
それは持続可能なやり方ではありませんでした。KetoHubのインデックスに新しいレシピサイトを追加するたびに、次々と現れる例外に対応するために、あの長い正規表現の連なりを修正しなければなりませんでした。時間が経つにつれ、材料解析のコードは恐ろしく複雑に入り組み、不可解な壊れ方をするようになっていきました。
正規表現の維持やデバッグは骨の折れる作業でした。目隠しをしてチェーンソーで材料を刻んでいるような気分でした。ニューヨーク・タイムズのライブラリは、材料を清潔かつ外科的な精度で切り分けているように見えました。私はどうしてもそれが欲しかったのです。
しかしその前に、彼らのコードをどうやって動かすのかを解明しなければなりませんでした。
なぜそれは難しかったのか
ニューヨーク・タイムズはこのライブラリをハッカソン的なイベントのために作ったため、自動テストや丁寧なドキュメントといった、プロフェッショナルなソフトウェアプロジェクトに期待される多くの要素が欠けていました。READMEにはアプリケーションのインストール手順が書かれていましたが、それはMac OS Xでしか動作しませんでした。テストも継続的インテグレーションの設定もないため、そもそもコードをどうやって動かせばよいのかさえ不明でした。

ingredient-phrase-taggerライブラリのインストール手順
もちろん、この問題に気づいたのは私だけではありませんでした。公開当時、ニューヨーク・タイムズは著名なPython開発者であるD. John Trump氏から厳しい批判を受けました。
Dockerでビルドする
私は、OSに関係なく一貫した動作を保証できる方法でライブラリをビルドしたいと考えました。そこでDockerの出番です。
Dockerを使えば、アプリケーションのための自己完結した環境を構築し、どこでも実行できます。ビルド用のUbuntuベース環境を立ち上げるのに必要なコマンドはたった一つでした。
$ docker run -it --rm ubuntu:16.04 /bin/bash
root@164475279c95:/# cat /etc/*release | head -n 2
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=16.04材料解析ライブラリの最初の依存関係は、その機械学習エンジンであるC++アプリケーションのCRF++でした。

CRF++のインストール手順
CRF++のビルド手順は一見シンプルでしたので、Ubuntuコンテナの中でコマンドを実行してみました。
$ apt-get update && apt-get install git build-essential -y
...
$ git clone https://github.com/taku910/crfpp.git
$ cd crfpp
$ ./configure
...
$ make
...
g++ -DHAVE_CONFIG_H -I. -O3 -Wall -c -o crf_learn.o crf_learn.cpp
crf_learn.cpp:9:21: fatal error: winmain.h: No such file or directory
compilation terminated.おっと、makeがWindowsのヘッダーファイルが見つからないというエラーで失敗しました。
このコードはまだメンテナンスされているのでしょうか。

CRF++の変更履歴。最後のコミットは2015年です
なんと、また別の死んだリポジトリでしょうか。私はすでに一つのライブラリを蘇らせようとしているところでした。もう一つまで抱えたくはありませんでした。
ちょっとした寄り道
winmain.hに関するCRF++のエラーメッセージは嫌な兆候でしたが、ニューヨーク・タイムズの開発者がCRF++をOS Xで動かしていたのなら、Windows以外の環境でも動かせるはずだと分かっていました。
もしかしたら誰かがすでに修正していて、メンテナがマージしていないだけかもしれません。リポジトリの未解決のプルリクエストを確認してみました。その中の一つは、特に有望に見えました。

CRF++への保留中のプルリクエスト
そのプルリクエストは、まるで「ねえMichael、君が今まさに悩んでいる問題を解決したよ」と題されているかのようでした。そこで@humemさんのパッチを当ててみました。
$ git remote add humem https://github.com/humem/crfpp.git
$ git checkout -b patch
Switched to a new branch 'patch'
$ git pull humem patch
remote: Counting objects: 2, done.
remote: Total 2 (delta 1), reused 1 (delta 1), pack-reused 1
Unpacking objects: 100% (2/2), done.
From https://github.com/humem/crfpp
* branch patch -> FETCH_HEAD
* [new branch] patch -> humem/patch
Updating 1dc92a6..37ec31f
Fast-forward
winmain.h | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 69 insertions(+)
create mode 100644 winmain.h…そして、再びビルドを試しました。
$ make
make all-am
make[1]: Entering directory '/crfpp'
g++ -DHAVE_CONFIG_H -I. -O3 -Wall -c -o crf_learn.o crf_learn.cpp
/bin/bash ./libtool --tag=CXX --mode=link g++ -O3 -Wall -o crf_learn crf_learn.o libcrfpp.la -lpthread -lpthread -lm -lm -lm
libtool: link: g++ -O3 -Wall -o .libs/crf_learn crf_learn.o ./.libs/libcrfpp.so -lpthread -lm
g++ -DHAVE_CONFIG_H -I. -O3 -Wall -c -o crf_test.o crf_test.cpp
/bin/bash ./libtool --tag=CXX --mode=link g++ -O3 -Wall -o crf_test crf_test.o libcrfpp.la -lpthread -lpthread -lm -lm -lm
libtool: link: g++ -O3 -Wall -o .libs/crf_test crf_test.o ./.libs/libcrfpp.so -lpthread -lm
make[1]: Leaving directory '/crfpp'素晴らしい。makeが成功しました。
あとはmake installしてCRF++を実行するだけでした。
$ make install
...
$ crf_test --version
crf_test: error while loading shared libraries: libcrfpp.so.0: cannot open shared object file: No such file or directoryなんと。インストールは成功したのに、CRF++はライブラリが見つからないというエラーですぐにクラッシュしてしまいました。
エラーメッセージをGoogleで検索すると、StackOverflowの回答でldconfigコマンドを実行するように書かれていました。試してみると…
$ ldconfig
$ crf_test --version
CRF++ of 0.59やった!うまくいきました!
改めてDockerでビルドする
あ、ちょっと待ってください。本来やろうとしていたことと違います。
ヤクの毛刈りに夢中になるあまり、本来の目的、すなわちDockerコンテナ内でingredient-phrase-taggerを動かすことをすっかり忘れていました。
それでも、最悪の事態は乗り越えたのではないかと期待していました。残りのインストール手順はライブラリのsetuptoolsインストーラーを実行するだけで、setuptoolsにはこれまで概ね良い印象を持っていました。
$ apt-get install python python-pip -y
...
$ git clone https://github.com/NYTimes/ingredient-phrase-tagger.git
$ cd ingredient-phrase-tagger
$ python setup.py install
...
Installed /usr/local/lib/python2.7/dist-packages/six-1.11.0-py2.7.egg
Finished processing dependencies for ingredient-phrase-tagger==0.0.0.dev0ふう、ここは問題ありませんでした。すべてインストールできたようです。
試運転してみる
リポジトリの「クイックスタート」の手順では、roundtrip.shというシェルスクリプトがライブラリの機能をエンドツーエンドで実行すると書かれていました。
$ ./roundtrip.sh
...
visualizing...
./roundtrip.sh: 18: ./roundtrip.sh: ruby: not foundなるほど、このPythonライブラリは何らかの理由でRubyが必要なようです。では、もう一度試してみます。
$ apt-get install ruby -y
...
$ ./roundtrip.sh5分ほど実行され、大量の出力が吐き出された後、最後にこう表示されました。
Sentence-Level Stats:
correct: 1487
total: 1999
% correct: 74.3871935968
Word-Level Stats:
correct: 10391
total: 11450
% correct: 90.7510917031成功です!
あれ、でも結局何をしたのでしょうか。
自分の材料でテストする
ライブラリは何かをやっているようでしたが、何が起きているのかは分かりませんでした。ドキュメントには任意の材料を解析するための2つのスクリプト、parse-ingredients.pyとconvert-to-json.pyについて書かれていたので、それらを試してみました。
$ echo "1 pinch Garlic Powder" >> input.txt
$ echo "1 Cup Mozzarella, shredded" >> input.txt
$ echo "6 slices cooked bacon" >> input.txt
$ python bin/parse-ingredients.py input.txt > results.txt
$ python bin/convert-to-json.py results.txt
[
{
"input": "1 pinch Garlic Powder",
"display": "<span class='qty'>1</span><span class='unit'>pinch</span><span class='name'>Garlic Powder</span>",
"name": "Garlic Powder",
"unit": "pinch",
"qty": "1"
},
{
"comment": "shredded",
"name": "Cup Mozzarella",
"qty": "1",
"other": ",",
"input": "1 Cup Mozzarella, shredded",
"display": "<span class='qty'>1</span><span class='name'>Cup Mozzarella</span><span class='other'>,</span><span class='comment'>shredded</span>"
},
{
"comment": "cooked",
"name": "bacon",
"qty": "6",
"input": "6 slices cooked bacon",
"display": "<span class='qty'>6</span><span class='unit'>slices</span><span class='comment'>cooked</span><span class='name'>bacon</span>",
"unit": "slice"
}
]動きました!
まあ、完全ではありませんでしたが。「1 Cup Mozzarella, shredded」の「Cup」を単位として認識できませんでした。機械学習モデルは、「Cup Mozzarella」という商品が存在し、レシピではそれが1つ必要だと考えたようです。

機械学習モデルが生み出した商品
もっと手軽にする
ライブラリを実行するたびにこれらの手順をすべて繰り返したくはなかったので、インストールのプロセスを高速化する方法が必要でした。
まず、@humemさんの修正を取り込んだCRF++リポジトリの自分のフォークを作りました。これでLinuxでもきれいにビルドできるCRF++のソースを手軽に用意できました。次に、これまで実行してきたシェルコマンドをすべてDockerfileにまとめました。
FROM ubuntu:16.04
RUN apt-get update -y && apt-get upgrade -y
RUN apt-get install -y build-essential git python2.7 python-pip ruby
# Install CRF++.
RUN git clone https://github.com/mtlynch/crfpp.git && \
cd crfpp && \
./configure && \
make && \
make install && \
ldconfig && \
cd ..
# Install ingredient-phrase-tagger.
RUN git clone https://github.com/NYTimes/ingredient-phrase-tagger && \
cd ingredient-phrase-tagger && \
python setup.py install
WORKDIR /ingredient-phrase-tagger今後、このライブラリを使える環境が欲しいときは、Dockerfileのあるディレクトリで次の2つのコマンドを実行するだけで済みます。
docker build --tag phrase-tagger .
docker run -it --rm phrase-tagger /bin/bashこれらのコマンドで、材料解析ライブラリの依存関係をすべて満たしたDockerコンテナが作成されます。この環境内であれば、roundtrip.shスクリプトをエラーなく実行できます。
$ ./roundtrip.sh
...
Done!434.32 s
testing...
visualizing...
evaluating...
Sentence-Level Stats:
correct: 1487
total: 1999
% correct: 74.3871935968
Word-Level Stats:
correct: 10391
total: 11450
% correct: 90.7510917031さらに手軽にするために、コンテナイメージをDocker Hubにアップロードしましたので、ご自宅の皆さんも次の1つのコマンドで私のコンテナを使えます。
docker run -it --rm mtlynch/ingredient-phrase-tagger:nyt-untouched /bin/bash下のデモは、Docker以外何もインストールされていないUbuntuシステムで収録したものです。Docker Hubから私のカスタムイメージを取得し、機械学習モデルを学習させ、新しい材料を解析しています。
次へ
コードは無事に実行できるようになり、作業はDockerをサポートするどのシステムでも再現可能になりました。次は何をすべきでしょうか。
まだソースコードを深く掘り下げてはいませんでしたが、スクリプトを実行する中で奇妙な点に気づきました。特に、使用スクリプトが不透明で硬直的だったのです。学習データやファイルの場所、モデルのパラメータなどがすべてハードコードされ、シェルスクリプトの中に埋もれていました。ユーザーがモデルの精度を最適化するために、これらの値を調整できる自由が欲しいと思いました。
それと、解析結果の中のこれにお気づきでしょうか。
"display": "<span class='qty'>2</span><span class='unit'>tablespoons</span><span class='name'>lemon juice</span>",なぜ機械学習モデルが、材料データの構造化とHTMLの生成の両方を担っているのでしょうか。脳外科医に脳手術と病院の家具の組み立ての両方を任せるようなものです。
すぐにコードに飛び込んで大規模な機能変更を行いたいところでしたが、その前に critical なステップ、すなわち安定化を行う必要がありました。ライブラリの既存の挙動を固定し、私が加える機能変更がすべて明示的かつ意図的なものになるようにしなければなりませんでした。
それについては本シリーズのパート2で取り上げています。内容は次のとおりです。
- 誤って何かを壊さないように、エンドツーエンドのテストを追加した方法
- コードに変更を加える前にテストが自動的に実行されるように設定した方法
- メンテナンスを容易にするために、私の標準的なツールセットをコードベースに追加した方法
カバーイラストはLoraine Yowさんによるものです。ingredient-phrase-taggerライブラリの私のフォークはGitHubで公開しています。このライブラリをベースにしたマネージドサービスとしてZestfulを提供しています。
記事をランダムに読む

