Resurrecting a Dead Library: Part One - Resuscitation

Michael Lynch

讓已死的函式庫起死回生:第一部——急救復甦

當我到場時,景象實在不太好看。

我看到原本活躍、愉快的 Python 類別呈現出可悲的萎縮狀態,已經多年沒有運作。各種抽象層級的函式被不人道地全塞在 utils 這個標籤底下。我試著閱讀 UI 程式碼,卻發現有東西擋住了它。仔細一看,我感到一陣噁心。原來卡在視圖層的障礙物,竟是血淋淋的大塊商業邏輯。

程式碼已經死了。

在這個共三篇的系列文章中,我將向你展示我是如何讓它起死回生,並以此建立起一門生意的:

  • 第一部:急救復甦(本文)——在這一篇中,我會把程式碼搶救回來,讓它能在任何現代系統上執行
  • 第二部:穩定化——在這一篇中,我會防止功能在修復過程中退化
  • 第三部:復健——在這一篇中,我會開始重構程式碼

熊醫生正在為蟒蛇(Python)急救

這個函式庫

這個函式庫是 ingredient-phrase-tagger,一個由 The New York Times 發布的開源函式庫。它能讓使用者將食譜中的食材片語解析成結構化資料。

幾年前,The New York Times 決定將其龐大的歷史烹飪食譜典藏數位化。他們聘請了資料輸入人員來檢視這些食譜中的原始食材,並拆解出其中所代表的資料。成果是一個看起來像這樣的資料庫:

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

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

這跟我有什麼關係?

我遇到了和 The New York Times 同樣的問題。我的專案 KetoHub 會彙整來自網路各地的食譜,並讓使用者能依食材搜尋。食譜網站通常不會以結構化的格式發布食材清單,因此我必須自己拆解其中的結構。

KetoHub 的螢幕截圖

KetoHub 上搜尋符合「avocado」的食譜結果

正規表示式實作的螢幕截圖

我那令人作嘔的正規表示式解析實作摘錄

當我偶然發現 ingredient-phrase-tagger 時,我正用一種醜陋、拼湊的方式解析食材:透過正規表示式

這樣做無法長久維持。每當我在 KetoHub 的索引中加入新的食譜網站,就必須修改那一長串正規表示式來處理新的邊界情況。久而久之,食材解析的程式碼變得異常複雜,並開始以令人困惑的方式出錯。

我的正規表示式難以維護與除錯。我感覺自己像是蒙著眼睛拿著鏈鋸在切食材。而 The New York Times 的函式庫看起來卻能以乾淨、手術般的精準度來剖析食材。我迫切地想要它。

但首先,我得先弄清楚如何讓他們的程式碼跑起來。

為什麼這很困難?

The New York Times 是為了駭客週活動而打造這個函式庫的,因此它缺乏專業軟體專案所期望的許多功能,例如自動化測試或詳盡的文件。README 中包含了安裝應用程式的說明,但那些說明只能在 Mac OS X 上運作。在沒有測試或 continuous integration(持續整合)設定的情況下,根本不清楚該如何讓程式碼跑起來。

OS X 安裝說明

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

食材解析函式庫的第一個相依套件是其機器學習引擎:一個名為 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++ 錯誤訊息是個不祥之兆,但如果 The New York Times 的開發者能在 OS X 上執行 CRF++,我知道在非 Windows 環境中執行它是可行的。

或許已經有人修好了這個問題,只是維護者還沒合併這個變更。我查看了該儲存庫尚未處理的 pull request。其中一個特別有希望:

CRF++ 的 pull request

等待合併到 CRF++ 的 pull request

這個 pull request 標題簡直就像是寫著:「嘿,麥可!我解決了你正在苦惱的確切問題」,所以我套用了 @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

呼!那邊沒出什麼問題。看起來我已經把所有東西都安裝好了。

試跑一下

儲存庫中的「快速開始」說明提到了一個名為 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++ 儲存庫分支,其中包含了 @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 函式庫分支可在 GitHub 上取得。我提供一個以此函式庫為基礎的代管服務,名為 Zestful

原文由 Michael Lynch 發布

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