Resurrecting a Dead Library: Part One - Resuscitation

Michael Lynch

死んだライブラリを蘇らせる:パート1 ── 蘇生

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

現場に駆けつけたとき、そこにあったのは決して美しい光景ではなかった。

かつて活発で生き生きとしていたPythonのクラスが、長年まったく使われることなく、見るも無残にやせ衰えているのを目の当たりにした。抽象度の異なるあらゆる関数が、非人間的なまでにutilsというラベルの下に詰め込まれていた。UIのコードを読もうとしたが、何かが邪魔をして読めない。よく見ると吐き気がした。ビューレイヤーを塞いでいたものの正体は、無残に散らばったビジネスロジックの塊だったのだ。

コードは死んでいた。

この全3回のシリーズでは、どのようにしてこのコードを蘇らせ、そこからビジネスを立ち上げたのかを紹介する。

  • パート1:蘇生(本記事) - コードを手当てして、どんなモダンなシステムでも動くようにする
  • パート2:安定化 - コードを修復する間、機能が後退しないようにする
  • パート3:リハビリ - コードのリファクタリングを始める

クマの医者がパイソンを蘇生させている様子

ライブラリについて

そのライブラリとは、ingredient-phrase-taggerだった。The New York Timesが公開したオープンソースのライブラリで、レシピの材料表記を構造化されたデータに解析することができる。

数年前、Timesは膨大な量の料理レシピの歴史的アーカイブをデジタル化することにした。データ入力の担当者を雇い、レシピに書かれた生の材料表記を見て、そこに含まれるデータを一つひとつ分解させた。その結果、こんなデータベースが出来上がった。

元の材料表記数量単位名称コメント
3 tablespoons flour3.0tablespoonflour
2 1/2 cups of finely chopped red onions2.5cupred onionsfinely chopped
2 dried pasilla chilies2.0pasilla chiliesdried

6年間このデータベースを蓄積した後、彼らは人間の作業員のデータ入力判断を再現する機械学習モデルを訓練するのに十分なデータが集まったことに気づいた。プロジェクトは成功し、彼らはすべてのソースコードとデータを公開した。

それが自分と何の関係があったのか

私もTimesと同じ問題を抱えていた。私のプロジェクトであるKetoHubは、ウェブ上からレシピを集約し、材料で検索できるようにするものだ。レシピサイトはたいてい材料リストを構造化された形式で公開していないため、自分で構造を分解する必要があった。

KetoHubのスクリーンショット

KetoHubで「avocado」に一致するレシピを検索した結果

正規表現の実装のスクリーンショット

私のうんざりするような正規表現による解析実装の抜粋

ingredient-phrase-taggerに出会った当時、私は材料を醜く場当たり的な方法で解析していた。正規表現でだ。

それは持続可能なやり方ではなかった。KetoHubのインデックスに新しいレシピサイトを追加するたびに、新たなエッジケースに対応するために長々と連なる正規表現を修正しなければならなかった。やがて材料解析のコードは地獄のように入り組み、不可解な壊れ方をするようになった。

正規表現の保守やデバッグはうんざりする作業だった。目隠しをしてチェーンソーで材料を切り刻んでいるような気分だった。Timesのライブラリは、材料を清潔で外科的な精密さで解剖しているように見えた。どうしてもそれが欲しかった。

だがまずは、どうやって彼らのコードを動かすかを考えなければならなかった。

なぜ難しかったのか

Timesはこのライブラリをハックウィークのイベント用に作ったため、自動テストや十分なドキュメントといった、プロフェッショナルなソフトウェアプロジェクトに期待される多くの要素が欠けていた。READMEにはアプリケーションのインストール手順が書かれていたが、Mac OS Xでしか動かないものだった。テストも継続的インテグレーションの設定もないため、そもそもコードをどうやって動かせばいいのかすら不明だった。

OS X用のインストール手順

ingredient-phrase-taggerライブラリのインストール手順

もちろん、この問題に気づいたのは私だけではなかった。公開当時、Timesは著名なPython開発者であるD. John Trump氏から厳しい批判を受けた。

コードについての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

材料解析ライブラリの最初の依存関係は、その機械学習エンジンであるCRF++という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++の変更履歴

CRF++の変更履歴。最後のコミットは2015年

なんということだ!また別の死んだリポジトリなのか?すでに一つのライブラリを蘇らせているところなのに、もう一つまで抱え込みたくはなかった。

ちょっと寄り道をする

winmain.hに関するCRF++のエラーメッセージは嫌な兆候だったが、Timesの開発者がOS XでCRF++を動かせていたのなら、Windows以外の環境でも動かせるはずだと思った。

誰かがすでに修正していたのに、メンテナがマージしていないだけかもしれない。リポジトリの未処理のプルリクエストを確認してみた。その中でも、特に有望そうなものが一つあった。

CRF++のプルリクエスト

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++はライブラリが見つからないというエラーですぐにクラッシュした。

エラーメッセージをググって、ldconfigコマンドを実行するよう指示するStack Overflowの回答を見つけた。試してみると……

$ 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

ふう!そこでは何の問題も起きなかった。すべてインストールできたようだった。

試しに動かしてみる

リポジトリの「Quick Start」では、ライブラリの機能をエンドツーエンドで試すroundtrip.shというシェルスクリプトが紹介されていた。

$ ./roundtrip.sh
...
visualizing...
./roundtrip.sh: 18: ./roundtrip.sh: ruby: not found

うーん、このPythonライブラリがなぜかRubyを必要とするのか。まあいい、もう一度試してみよう。

$ apt-get install ruby -y
...
$ ./roundtrip.sh

約5分間実行され、大量の出力が吐き出された末、最後にこう表示された。

Sentence-Level Stats:
        correct:  1487
        total:  1999
        % correct:  74.3871935968

Word-Level Stats:
        correct: 10391
        total: 11450
        % correct: 90.7510917031

成功だ!

あれ、でも一体何をしたんだ?

自分の材料でテストする

ライブラリは何かしら動いてはいたが、何が起きているのかはよくわからなかった。ドキュメントには任意の材料を解析するための2つのスクリプト、parse-ingredients.pyconvert-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つ必要としていると判断したようだった。

機械学習モデルが生み出したCup Mozzarellaという商品の写真

機械学習モデルが生み出した商品

もっと手軽にする

ライブラリを実行するたびに毎回これらの手順を踏むのはごめんだったので、インストールプロセスを高速化する方法が必要だった。

まず、@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をダウンロード

今後このライブラリを含む環境が欲しくなったら、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生成の両方を担わなければならないのか?それは脳外科医に脳手術と病院の家具の組み立てを同時にやらせるようなものだ。

本当はすぐにコードに飛び込んで大規模な機能変更を加えたかったが、まずは重要なステップを踏まなければならなかった。安定化だ。ライブラリの既存の挙動を固定して、機能に加える変更がすべて明示的で意図的なものになるようにする必要があった。

その内容はシリーズのパート2で扱っている。そこでは次のことを説明する。

  • どうやってエンドツーエンドテストを追加して、うっかり何かを壊さないようにしたか
  • コードに変更を加える前にテストが自動実行されるように、どのように設定したか
  • 保守を容易にするために、どのように標準的なツールセットをコードベースに追加したか

カバーイラスト:Loraine Yow。ingredient-phrase-taggerライブラリの私のフォークはGitHubで公開している。このライブラリをベースにしたZestfulというマネージドサービスも提供している。

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

コメント