Resurrecting a Dead Library: Part Two - Stabilization

Michael Lynch

讓已死的函式庫起死回生:第二部——穩定化

在這篇文章中,我將示範如何為未經測試的遺留函式庫補上自動化測試。

這是三部曲系列的第二篇,講述我如何復活 ingredient-phrase-tagger——一個使用 machine learning(機器學習) 將烹飪食材(例如「2 cups milk」)解析為結構化資料的函式庫。完整背景請閱讀 第一部,簡而言之,我發現了一個被棄置的函式庫,並讓它起死回生,使其能夠驅動我的 SaaS 事業:

  • 第一部:復甦 - 在其中我讓程式碼恢復健康,使其能在任何現代系統上執行
  • 第二部:穩定化(本文) - 在其中我防止功能在修復程式碼的過程中退化
  • 第三部:復健 - 在其中我開始 refactoring(重構) 程式碼

海狸們正在穩固搖搖欲墜的房屋

在 continuous integration(持續整合) 中執行

在第一部結束時,我建立了一個 Docker 映像檔,讓該函式庫能在任何系統上執行。下一步便是在 continuous integration 中執行這個函式庫。

Travis CI 標誌

continuous integration 是一種在程式碼每次變更時,使用獨立、受控的環境來測試軟體的實踐。我偏好的 continuous integration 解決方案是 Travis。它的設定檔直觀易懂,而且為開源專案提供無限次的免費建置。

為了與 Travis 整合,我在 Travis 的設定頁面上加入了 我 fork 的 ingredient-phrase-tagger,然後啟用了建置:

啟用 Travis 的螢幕截圖

為 ingredient-phrase-tagger 函式庫啟用 Travis 建置

接著,我建立了一個名為 .travis.yml 的檔案,告訴 Travis 如何建置這個函式庫:

sudo: required
services: docker
script: docker build .

下載 travis.yml

我將提交推送到 GitHub,建立了一個 pull request,而 Travis 也 成功建置了它

在 Travis CI 上首次成功建置的螢幕截圖

在 Travis 上的首次成功建置

加入 end-to-end test(端對端測試)

Travis 正在建置我的 Docker 映像檔,但這次建置還沒有實質意義。它只建置了函式庫的相依套件——並未真正執行其任何功能。我想要一個能在我破壞函式庫功能時發出警示的建置。為此,我需要一個 end-to-end test。

end-to-end test 會驗證一個完整的、真實世界的場景是否如預期般運作。它通常符合以下結構:

  1. 提供預先產生的輸入及其預期輸出(也稱為「golden output」)。
  2. 使用自動化工具將輸入餵給函式庫。
  3. 將函式庫的輸出與 golden output 進行比較。

原始儲存庫包含一個名為 roundtrip.sh 的腳本,其形式類似 end-to-end test。它向函式庫提供預先產生的輸入,使用一部分輸入來訓練新的 machine learning 模型,然後使用該模型來解析輸入的其他部分。唯一缺少的一環是它從未將結果與已知的正確輸出進行比較。

基本的 end-to-end test

第一部 中,我展示了 roundtrip.sh 腳本的最終結果是一組關於模型效能的彙總統計數據:

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

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

這已足夠讓我建立一個簡單的 end-to-end test。我重新執行了 roundtrip.sh 腳本的最後一個步驟,但將主控台輸出重新導向到一個名為 tests/golden/eval_output 的新檔案,並將其加入版本控制:

python bin/evaluate.py tmp/test_output > tests/golden/eval_output

有了已知的正確輸出後,我修改了 roundtrip.sh 的結尾,使其將所有未來的輸出與這份儲存的輸出進行比較:

python bin/evaluate.py tmp/test_output > tmp/eval_output
diff tests/golden/eval_output tmp/eval_output

我的測試能察覺程式碼損壞嗎?

唯有能抓到錯誤,end-to-end test 才有價值,因此我的下一步是模擬一個會造成破壞的變更,並檢查我的 end-to-end test 是否能捕捉到它。

cli.py 中,有一個用來匹配數字序列(例如 "83625")的正規表示式:

m3 = re.match('^\d+$', ss)

作為實驗,我微調了這個正規表示式,使其無法辨識任何包含 9 的數字:

m3 = re.match('^[0-8]+$', ss)

接著,我重新執行了修改後的 roundtrip.sh 腳本:

3c3
<       correct:  1487
---
>       correct:  1486
5c5
<       % correct:  74.3871935968
---
>       % correct:  74.33716858429

成功了!

當我告訴程式碼 9 不再被視為數字時,函式庫的準確度下降了,腳本也以失敗的結束代碼終止。

擴充 end-to-end test

上述基本的 end-to-end test 很有用,但 roundtrip.sh 執行的是包含多個階段的資料處理管線。若能知道是哪一個特定階段出錯會更方便,因此我尋找更多可納入 end-to-end test 的輸出。

除了將輸出印到主控台外,該腳本還會將檔案寫入名為 tmp/ 的子目錄中:

$ file tmp/*
tmp/model_file:  data
tmp/output.html: HTML document, ASCII text, with very long lines
tmp/test_file:   ASCII text
tmp/test_output: ASCII text
tmp/train_file:  ASCII text

test_filetest_outputtrain_file 都是純文字檔,外觀大致如下:

$ head -n 16 tmp/test_file
1       I1      L12     NoCAP   NoPAREN B-QTY
boneless        I2      L12     NoCAP   NoPAREN I-COMMENT
pork    I3      L12     NoCAP   NoPAREN B-NAME
tenderloin      I4      L12     NoCAP   NoPAREN I-NAME
,       I5      L12     NoCAP   NoPAREN B-COMMENT
about   I6      L12     NoCAP   NoPAREN I-COMMENT
1       I7      L12     NoCAP   NoPAREN B-QTY
pound   I8      L12     NoCAP   NoPAREN I-COMMENT

Salt    I1      L8      YesCAP  NoPAREN B-NAME
and     I2      L8      NoCAP   NoPAREN I-NAME
freshly I3      L8      NoCAP   NoPAREN B-COMMENT
ground  I4      L8      NoCAP   NoPAREN I-COMMENT
black   I5      L8      NoCAP   NoPAREN B-NAME
pepper  I6      L8      NoCAP   NoPAREN I-NAME

我當時還不了解這個檔案格式,但其實不需要了解。我所需要的只是一種能偵測檔案何時發生變動的方法。

將這些檔案複製到 tests/golden 後,我將它們作為額外的 golden output 儲存到版本控制中。接著,我在建置腳本中加入了 diff 來偵測這些輸出檔何時發生變化。

完整的建置腳本

在對 roundtrip.sh 進行所有修改後,我將其另存為名為 build.sh 的新檔案,內容如下:

#!/bin/bash

# Exit build script on first failure
set -e
# Echo commands to stdout.
set -x

COUNT_TRAIN=20000
COUNT_TEST=2000

OUTPUT_DIR=$(mktemp -d)
ACTUAL_CRF_TRAINING_FILE="${OUTPUT_DIR}/training_data.crf"
ACTUAL_CRF_TESTING_FILE="${OUTPUT_DIR}/testing_data.crf"
ACTUAL_CRF_MODEL_FILE="${OUTPUT_DIR}/model.crfmodel"
ACTUAL_TESTING_OUTPUT_FILE="${OUTPUT_DIR}/testing_output"
ACTUAL_EVAL_OUTPUT_FILE="${OUTPUT_DIR}/eval_output"

bin/generate_data \
  --data-path=nyt-ingredients-snapshot-2015.csv \
  --count=$COUNT_TRAIN \
  --offset=0 > "$ACTUAL_CRF_TRAINING_FILE"
bin/generate_data \
  --data-path=nyt-ingredients-snapshot-2015.csv \
  --count=$COUNT_TEST \
  --offset=$COUNT_TRAIN > "$ACTUAL_CRF_TESTING_FILE"

crf_learn \
  template_file "$ACTUAL_CRF_TRAINING_FILE" "$ACTUAL_CRF_MODEL_FILE"

crf_test \
  -m "$ACTUAL_CRF_MODEL_FILE" \
  "$ACTUAL_CRF_TESTING_FILE" > "$ACTUAL_TESTING_OUTPUT_FILE"

python bin/evaluate.py "$ACTUAL_TESTING_OUTPUT_FILE" > "$ACTUAL_EVAL_OUTPUT_FILE"

# Check against golden output.
GOLDEN_DIR=tests/golden
GOLDEN_CRF_TRAINING_FILE="${GOLDEN_DIR}/training_data.crf"
GOLDEN_CRF_TESTING_FILE="${GOLDEN_DIR}/testing_data.crf"
GOLDEN_TESTING_OUTPUT_FILE="${GOLDEN_DIR}/testing_output"
GOLDEN_EVAL_OUTPUT_FILE="${GOLDEN_DIR}/eval_output"

diff --context=2 "$GOLDEN_CRF_TRAINING_FILE" "$ACTUAL_CRF_TRAINING_FILE"
diff --context=2 "$GOLDEN_CRF_TESTING_FILE" "$ACTUAL_CRF_TESTING_FILE"
diff --context=2 "$GOLDEN_TESTING_OUTPUT_FILE" "$ACTUAL_TESTING_OUTPUT_FILE"
diff "$GOLDEN_EVAL_OUTPUT_FILE" "$ACTUAL_EVAL_OUTPUT_FILE"

下載 build.sh

接著,我為該腳本加上一個名為 docker_build 的簡易包裝器,讓 end-to-end test 在函式庫專屬的 Docker 容器內執行:

#!/bin/bash

# Exit on first failing command.
set -e
# Echo commands to console.
set -x

IMAGE_NAME="ingredient-phrase-tagger-image"
CONTAINER_NAME="ingredient-phrase-tagger-container"

docker build \
  --tag "$IMAGE_NAME" \
  .

docker run \
  --tty \
  --detach \
  --name "$CONTAINER_NAME" \
  "$IMAGE_NAME"

docker exec "$CONTAINER_NAME" ./build.sh

下載 docker_build

有了 docker_build 腳本,我的 end-to-end test 就能在任何支援 Docker 的系統上執行。自然地,我想在我的 continuous integration 環境中執行它。

在 continuous integration 中執行我的 end-to-end test

我先前的 Travis 設定只建置了 Docker 映像檔,但沒有實際執行函式庫。現在有了完整的測試腳本,我更新了 .travis.yml 檔案來執行它:

sudo: required
services: docker
-script: docker build .
+script: ./docker_build

推送了變更,準備見證我那能在任何地方一致執行的精妙測試的風采。結果,它失敗了:

在 Travis 上失敗的 end-to-end test

在本地端通過後,卻在 Travis 上失敗的 end-to-end test

看到建置失敗我並不開心,但我很慶幸我的 end-to-end test 捕捉到了問題。我只需要弄清楚那是什麼。

除錯差異

Docker 容器的核心精神就是程式在任何地方的行為都應該一致,那麼我怎麼會在兩個地方執行同一個容器,卻看到不同的輸出呢?

Travis 建置日誌顯示測試在 testing_output 檔案的 diff 上失敗:

+ diff --context=2 tests/golden/testing_output /tmp/tmp.W5S3C5T4if/testing_output
*** tests/golden/testing_output  Fri Jul 27 02:44:20 2018
--- /tmp/tmp.W5S3C5T4if/testing_output  Fri Jul 27 03:03:56 2018
***************
*** 173,178 ****
  1  I1  L8  NoCAP  NoPAREN  B-QTY  B-QTY
  tablespoon  I2  L8  NoCAP  NoPAREN  B-UNIT  B-UNIT
! dark  I3  L8  NoCAP  NoPAREN  B-COMMENT  B-COMMENT
! corn  I4  L8  NoCAP  NoPAREN  B-NAME  B-NAME
  syrup  I5  L8  NoCAP  NoPAREN  I-NAME  I-NAME

--- 173,178 ----
  1  I1  L8  NoCAP  NoPAREN  B-QTY  B-QTY
  tablespoon  I2  L8  NoCAP  NoPAREN  B-UNIT  B-UNIT
! dark  I3  L8  NoCAP  NoPAREN  B-COMMENT  B-NAME
! corn  I4  L8  NoCAP  NoPAREN  B-NAME  I-NAME
  syrup  I5  L8  NoCAP  NoPAREN  I-NAME  I-NAME

testing_output 檔案是我的 build.sh 腳本中這兩行的結果:

crf_learn \
  template_file "$ACTUAL_CRF_TRAINING_FILE" "$ACTUAL_CRF_MODEL_FILE"

crf_test \
  -m "$ACTUAL_CRF_MODEL_FILE" \
  "$ACTUAL_CRF_TESTING_FILE" > "$ACTUAL_TESTING_OUTPUT_FILE"

crf_learncrf_test 都是 CRF++ 的命令列工具,而 CRF++ 正是驅動 ingredient-phrase-tagger 內 machine learning 邏輯的引擎。即使對這些工具不太了解,我也能從語法推斷出 crf_learn 會建立 machine learning 模型,而 crf_test 則使用該模型來分類資料。

end-to-end test 已驗證 $ACTUAL_CRF_TRAINING_FILE$ACTUAL_CRF_TESTING_FILE 的內容與我的 golden 版本相符。這意味著 crf_learncrf_test 在我的本地系統與 continuous integration 中接收了相同的輸入,卻依環境產生了不同的輸出。

深入探討 CRF++

CRF++ 是否是非確定性的?我試著在本地再次執行測試。它通過了。我在 Travis 上重新執行測試,它又以同樣的方式失敗。這告訴我,CRF++ 在相同環境中的多次執行結果是一致的,但在不同環境間卻不一致。

我不喜歡這個指向的結論。這暗示 CRF++ 的行為取決於系統底層的硬體。也許 Intel CPU 產生的結果與 AMD CPU 不同。那將會很麻煩,因為 Travis 並不保證其硬體環境。再者,如果不同硬體產生不同結果,那就會違背 Docker 容器的初衷。

情急之下,我查看了 CRF++ 的命令列說明文件,尋找任何可能暗示硬體相依性的線索:

$ crf_learn --help
...
 -p, --thread=INT            number of threads (default auto-detect)

--thread 旗標看起來很有意思。我查看了 完整說明文件以取得更多細節:

-p NUM:

如果 PC 有多個 CPU,你可以透過使用多執行緒來加快訓練速度。NUM 是執行緒的數量。

這聽起來很有希望。

我比較了 Travis 上的 CRF++ 輸出與本地環境中相同的輸出行:

本地機器與 Travis 之間 crf_learn 執行緒數量的差異

crf_learn 在 Travis 上以兩個執行緒執行,但在我的本地環境中則以八個執行緒執行

啊哈!

因為我省略了 --thread 旗標,CRF++ 便根據可用的 CPU 核心數量自動設定它。我的 Travis 環境有兩個 CPU 核心,而我的本地機器則有八個。

我調整了 build.sh 腳本,明確設定執行緒數量:

-crf_learn template_file "$ACTUAL_CRF_TRAINING_FILE" "$ACTUAL_CRF_MODEL_FILE"
+crf_learn \
+  --thread=2 \
+  template_file "$ACTUAL_CRF_TRAINING_FILE" "$ACTUAL_CRF_MODEL_FILE"

接著,我將新產生的輸出檔儲存為我的 golden 複本。我將 變更推送到 GitHub,迎來了令人愉悅的景象:我的 end-to-end test 通過了

修復 end-to-end test 後的成功畫面

在 Travis 上通過的 end-to-end test

良好測試的價值

end-to-end test 很快就證明了它的價值。雖然深入研究函式庫其中一個相依套件的說明文件相當繁瑣,但該測試揭露了函式庫會依環境產生不一致的結果。這很可能是函式庫原作者從未察覺的事。

有了 end-to-end test 和運作中的 continuous integration,我便擁有一個能展示函式庫預期功能的權威環境。該測試提供了一道寶貴的防線,以防我做出任何無意間改變函式庫行為的變更。

接下來呢?

有了測試帶來的信心,是時候進入我最喜歡的軟體專案環節了:refactoring。我可以自由地對程式碼進行大規模的改動,因為我知道如果我做了什麼太蠢的事,建置就會大聲地失敗。

請繼續閱讀本系列的 第三部,在其中我將說明我是如何:

  • 加入 unit tests(單元測試)
  • 自動對程式碼套用風格規範
  • 將 static analysis(靜態分析) 整合到建置中

封面插圖由 Loraine Yow(蘿蘭·尤)繪製。我 fork 的 ingredient-phrase-tagger 函式庫可在 GitHub 上取得。我提供一個以此函式庫為基礎的託管服務,名為 Zestful

原文由 Michael Lynch 發布

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