让一座死去的库重获新生:第一部分——复苏
我赶到现场时,那情景可不怎么好看。
我看到那些曾经活跃、欢快的 Python 类,因为多年没有锻炼,已经萎缩得不成样子。各个抽象层级的函数都被残忍地塞在一起,统统冠以 utils 之名。我试着阅读 UI 代码,却发现有东西挡住了它。仔细一看,我顿时感到一阵恶心。视图层里的障碍物,实际上是一块块血淋淋的业务逻辑。
代码已经死了。
在这个分为三部分的系列文章中,我会向你展示我是如何让它起死回生,并用结果构建起一门生意的:
这座库
这座库就是 ingredient-phrase-tagger,由 The New York Times 发布的一个开源库。它允许用户将食谱配料解析为结构化数据。
几年前,The New York Times 决定将其庞大的历史烹饪食谱档案数字化。他们雇佣数据录入员查看食谱中的原始配料,并拆解出其中代表的数据。最终得到的数据库看起来是这样的:
| 原始配料 | 数量 | 单位 | 名称 | 备注 |
|---|---|---|---|---|
| 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 |
在向这个数据库持续添加数据六年后,他们意识到数据量已经足够用来训练一个 machine learning(机器学习) 模型,让模型模拟人工录入员的数据录入决策。项目取得了成功,于是他们公开了全部源代码和数据。
这关我什么事?
我也遇到了和 The New York Times 相同的问题。我的项目 KetoHub 汇总全网食谱,并支持按配料搜索。食谱网站通常不会以结构化格式发布配料清单,所以我必须自己拆解其中的结构。

KetoHub 搜索匹配“avocado”的食谱所得结果

我那套令人作呕的 regex 解析实现的片段
当时我偶然发现 ingredient-phrase-tagger 时,正以一种丑陋而取巧的方式解析配料:使用 regular expressions(正则表达式)。
这种方式无法长期维持。每当我向 KetoHub 的索引中加入一个新的食谱网站,就必须修改那串冗长的正则表达式,以处理新的边界情况。随着时间推移,配料解析代码变得错综复杂,开始以令人困惑的方式不断出错。
我的正则表达式既难以维护,也难以调试。我感觉自己像是蒙着眼睛用电锯砍配料。The New York Times 的库看起来像是用干净、精准的外科手术方式剖析配料。我非常想要它。
但首先,我得想办法让他们的代码运行起来。
为什么这么难?
The New York Times 为一次 hack week 活动构建了这个库,因此缺少专业软件项目通常具备的许多功能,比如自动化测试或完备的文档。README 中包含应用安装说明,但这些说明只能在 Mac OS X 上使用。由于没有测试,也没有 continuous integration(持续集成) 配置,根本不清楚该如何让代码运行起来。

ingredient-phrase-tagger 库的安装说明
当然,注意到这些问题的不止我一个。当时发布之后,The New York Times 还受到了著名 Python 开发者 D. John Trump(D. 约翰·特朗普)的严厉批评:
在 Docker 中构建
我希望以一种能够确保无论操作系统如何,行为都保持一致的方式构建这个库。这看起来像是 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这个配料解析库的第一个依赖,是它的 machine learning(机器学习) 引擎:一个名为 CRF++ 的 C++ 应用。

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 年
不会吧!又一个死掉的仓库?我已经在让一个库起死回生了,可不想再接手另一个。
稍微绕个道
CRF++ 关于 winmain.h 的错误消息不是个好兆头,但既然 The New York Times 的开发者能在 OS X 上运行 CRF++,我知道它应该可以在非 Windows 环境中运行。
也许已经有人修复了这个问题,只是维护者从未合并这项更改。我查看了仓库中尚未处理的 pull request。其中一个 pull request 尤其值得期待:

提交到 CRF++ 的待处理 pull request
这个 pull request 简直可以直接命名为:“嘿,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 中构建
哦,等等。那其实并不是我真正想做的事。
我的 yak shaving 冒险把我带偏得太远,以至于我忘记了最初的目标:在 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 的 shell 脚本,它可以端到端地运行这个库的功能:
$ ./roundtrip.sh
...
visualizing...
./roundtrip.sh: 18: ./roundtrip.sh: ruby: not found嗯,所以这个 Python 库不知为何需要 Ruby。好吧,我们再试一次:
$ apt-get install ruby -y
...
$ ./roundtrip.sh它运行了大约五分钟,输出了大量内容,最后以这些结果收尾:
Sentence-Level Stats:
correct: 1487
total: 1999
% correct: 74.3871935968
Word-Level Stats:
correct: 10391
total: 11450
% correct: 90.7510917031成功!
哦,等等。它到底做了什么?
用我的配料进行测试
这个库确实做了某些事,但没有让我了解究竟发生了什么。文档提到有两个用于解析任意配料的脚本: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”是计量单位。这个 machine learning(机器学习) 模型显然认为存在一种叫作“Cup Mozzarella”的产品,而食谱需要一份这种产品。

由 machine learning(机器学习) 模型发明的产品
让它更容易使用
我不想每次运行这个库时都重复那些步骤,所以需要一种加快安装过程的方法。
首先,我从 CRF++ 仓库派生了自己的副本,其中包含 @humem 的修复。这样我就有了一份方便的 CRF++ 源代码副本,可以在 Linux 上顺利构建。然后,我把执行过的所有 shell 命令收集进了一个 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 的目录中运行以下两条命令:
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,这样家里的各位只需运行一条命令,就能使用我的容器:
docker run -it --rm mtlynch/ingredient-phrase-tagger:nyt-untouched /bin/bash下面这段演示是在一台除了 Docker 之外什么都没安装的 Ubuntu 系统上录制的。我用它从 Docker Hub 拉取自定义镜像,训练 machine learning(机器学习) 模型,并解析一种新的配料:
继续前进
代码成功运行了,而且只要系统支持 Docker,我的工作就能在任何系统上复现。接下来该做什么?
我还没有深入研究源代码,但运行这些脚本时已经注意到一些奇怪的地方。最明显的是,使用脚本既不透明又僵化——训练数据、文件位置和模型参数全都硬编码在 shell 脚本中,埋得很深。我希望用户能够自由调整这些值,以优化模型的准确率。
另外,你注意到解析输出中的这一点了吗?
"display": "<span class='qty'>2</span><span class='unit'>tablespoons</span><span class='name'>lemon juice</span>",一个负责对配料数据进行结构化的 machine learning(机器学习) 模型,为什么还要负责生成 HTML?这就好比让一名神经外科医生既负责脑部手术,又负责组装医院家具。
我本来很想直接深入代码,进行大刀阔斧的功能改动,但首先必须完成一个关键步骤:稳定化。我需要锁定这个库现有的行为,这样我对其功能所做的任何更改才会是明确且经过深思熟虑的。
我将在本系列第二部分中介绍这一过程,内容包括:
- 我如何添加端到端测试,从而避免意外破坏任何东西
- 我如何配置测试,使其在对代码应用任何更改之前自动运行
- 我如何将自己的标准工具集添加到代码库中,以便于维护
封面插画由 Loraine Yow(洛林·尤)创作。我的 ingredient-phrase-tagger 库副本可在 GitHub 上获取。我还基于这个库提供一项托管服务,名为 Zestful。
随机一篇博客

