Resurrecting a Dead Library: Part One - Resuscitation

Michael Lynch

復活一個已死的函式庫:第一部——搶救復甦

原文由 Michael Lynch 發布,訂閱此部落格

當我抵達現場時,景象可不太好看。

我看到那些曾經活躍、充滿活力的 Python 類別,如今萎縮得慘不忍睹,多年來完全沒被動過。各種抽象層級的函式被不人道地全塞在 utils 這個標籤底下。我試著閱讀 UI 的程式碼,卻發現有東西擋住了去路。仔細一看,我差點作嘔。原來卡在視圖層的那些障礙物,竟是血淋淋的商業邏輯碎塊。

程式碼已經死了。

在這個三篇系列中,我將告訴你我是如何讓它起死回生,並以此為基礎打造出一門生意的:

  • 第一部:搶救復甦(本文)——在這一篇中,我會細心照料程式碼,讓它恢復健康,得以在任何現代系統上執行
  • 第二部:穩定化——在修復程式碼的同時,防止功能退化
  • 第三部:復健重整——開始重構程式碼

熊醫生正在搶救蟒蛇

這個函式庫

這個函式庫就是 ingredient-phrase-tagger,是《紐約時報》發布的開源函式庫。它能讓使用者將食譜的食材描述解析成結構化資料。

幾年前,《紐約時報》決定將其龐大的歷史食譜檔案數位化。他們聘請了資料輸入人員,檢視這些食譜中的原始食材字串,並將其中隱含的資料拆解出來。成果就是一個長得像這樣的資料庫:

原始食材數量單位名稱備註
3 tablespoons flour3.0tablespoonflour
2 1/2 cups of finely chopped red onions2.5cupred onionsfinely chopped
2 dried pasilla chilies2.0pasilla chiliesdried

在累積了六年的資料後,他們發現已經有足夠的資料可以 訓練機器學習模型來模擬人工輸入人員的判斷。這項專案相當成功,因此他們將所有的原始碼與資料全部公開了。

這跟我有什麼關係?

我遇到了和《紐約時報》一樣的問題。我的專案 KetoHub 會彙整來自網路各地的食譜,並讓使用者能依食材進行搜尋。食譜網站通常不會以結構化的格式發布食材清單,所以我得自己把結構拆解出來。

KetoHub 截圖

KetoHub 上搜尋「avocado」所得的食譜結果

正則表達式實作截圖

我那噁心至極的正則表達式解析程式碼節選

當我偶然發現 ingredient-phrase-tagger 時,我正用一種又醜又土砲的方式解析食材:靠 正則表達式

這樣根本無法長久維持。每當我把新的食譜網站加入 KetoHub 的索引時,就得修改那一長串正則表達式來處理新的邊界案例。久而久之,食材解析的程式碼變得極度糾結難解,還會以各種莫名其妙的方式壞掉。

我的正則表達式既難維護又難除錯。我感覺自己像是被蒙住眼睛、拿著電鋸在亂切食材。而《紐約時報》的那個函式庫看起來卻能以乾淨俐落、外科手術般的精準度來剖析食材。我迫切地想要它。

但首先,我得先想辦法讓他們的程式碼跑起來。

為什麼這麼困難?

《紐約時報》是為了內部的駭客週活動才打造這個函式庫的,因此它缺乏許多專業軟體專案應有的要素,例如自動化測試或完整的說明文件。README 裡雖然有安裝說明,但只在 Mac OS X 上才有效。在沒有測試、也沒有持續整合設定的情況下,根本搞不清楚到底要怎麼讓程式碼跑起來。

OS X 安裝說明

ingredient-phrase-tagger 函式庫的安裝說明

當然,不只有我注意到這些問題。在他們發布之初,《紐約時報》就收到了來自知名 Python 開發者 D. John Trump 的嚴厲批評:

關於程式碼的 Trump 推文

用 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

這個食材解析函式庫的第一個相依套件,是它的機器學習引擎:一個名為 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++ 錯誤訊息看起來不太妙,但既然《紐約時報》的開發者能在 OS X 上執行 CRF++,我就知道它一定也能在非 Windows 環境下執行。

也許早就有人修好了,只是維護者一直沒合併。我查看了該儲存庫尚未處理的 pull request,其中有一個看起來特別有希望:

CRF++ 的 pull request

針對 CRF++ 的待處理 pull request

這個 pull request 的標題簡直就像在說:「嘿,Michael 看過來!我剛好解決了你正在苦惱的問題」,於是我套用了 @humem 的 patch:

$ 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.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」的產品,而這份食譜需要一個那樣的東西。

Cup Mozzarella 產品圖片

機器學習模型發明出來的產品

讓事情變簡單一點

我可不想每次執行函式庫都要重跑這些步驟,所以得想個辦法加快安裝流程。

首先,我把 CRF++ 儲存庫 fork 了一份自己的版本,裡面包含了 @humem 的修正。這樣就有了一份能在 Linux 上乾淨建置的 CRF++ 原始碼副本。接著,我把所有執行過的 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

未來如果我想要一個包含這個函式庫的環境,只要在放有 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 拉下我自訂的映像、訓練機器學習模型,並解析一筆新的食材:

繼續前進

程式碼成功跑起來了,而且我的成果在任何支援 Docker 的系統上都能重現。接下來呢?

我還沒深入研究原始碼,但從執行指令稿的過程中就發現了一些怪事。最明顯的是,使用方式的指令稿感覺既不透明又很僵硬——訓練資料、檔案路徑和模型參數全都被寫死,深埋在 shell 指令稿裡。我希望使用者能自由調整這些數值,以最佳化模型的準確度。

還有,你有注意到解析輸出中的這個嗎?

"display": "<span class='qty'>2</span><span class='unit'>tablespoons</span><span class='name'>lemon juice</span>",

為什麼一個機器學習模型要同時負責整理食材資料產生 HTML?這就好像請一位神經外科醫師既要負責開腦,又要負責組裝醫院的家具一樣。

我本來很想直接跳進程式碼裡大刀闊斧地修改功能,但首先我得先完成一個關鍵步驟:穩定化。我需要先把函式庫現有的行為鎖定下來,這樣之後對功能的任何更動才會是明確且經過深思的。

我會在本系列的第二部中說明這部分,內容包括:

  • 我如何加入端對端測試,以免不小心弄壞任何東西
  • 我如何設定讓測試在每次修改程式碼前自動執行
  • 我如何把常用的工具組加入程式碼庫以方便日後維護

封面插圖由 Loraine Yow 繪製。我的 ingredient-phrase-tagger 函式庫 fork 版本可在 GitHub 上取得。我以這個函式庫為基礎提供一項名為 Zestful 的代管服務。

本文章由 muse-spark-1.2-contributor 進行翻譯

留言