Resurrecting a Dead Library: Part Two - Stabilization

Michael Lynch

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

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

在這篇文章中,我將示範如何為一個完全沒有測試的陳舊函式庫補上自動化測試。

這是三部曲系列的第二篇,講述我如何讓 ingredient-phrase-tagger 起死回生的過程。這是一個利用機器學習將食材描述(例如「2 cups milk」)解析為結構化資料的函式庫。完整背景請參考第一篇,簡言之,我發現了一個已被遺棄的函式庫,並讓它重獲新生,成為我 SaaS 事業的核心動力:

  • 第一部:急救 — 我如何讓程式碼恢復健康,在任何現代系統上都能運行
  • 第二部:穩定化(本文) — 我如何在修復程式碼的同時,防止功能退化
  • 第三部:重建 — 我如何開始重構程式碼

海狸們正在穩住一間搖搖欲墜的房子

在持續整合環境中執行

在第一篇的最後,我建立了一個 Docker 映像檔,讓這個函式庫能在任何系統上執行。下一步,就是讓它在持續整合環境中運行。

Travis CI 標誌

持續整合是指在每次變更程式碼時,都在一個獨立、受控的環境中測試軟體的做法。我偏好的持續整合方案是 Travis。他們的設定檔直觀易懂,而且為開源專案提供無限次的免費建置。

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

啟用 Travis 的螢幕截圖

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

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

sudo: required
services: docker
script: docker build .

下載 travis.yml

我將 commit 推上 GitHub、建立了一個 pull request,而 Travis 也順利完成了建置

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

在 Travis 上的首次成功建置

加入端對端測試

Travis 雖然會建置我的 Docker 映像檔,但這樣的建置還沒有實質意義。它只建置了函式庫的相依套件,卻沒有實際執行任何功能。我想要的是一個能在我破壞函式庫功能時發出警示的建置。要做到這點,我需要一個端對端測試。

端對端測試會驗證一個完整、貼近真實世界的情境是否如預期運作。它的結構通常如下:

  1. 提供預先準備好的輸入資料及其預期輸出(也稱為「基準正確輸出」)。
  2. 使用自動化工具將輸入餵給函式庫。
  3. 將函式庫的輸出與基準正確輸出進行比對。

原始的儲存庫中包含一個名為 roundtrip.sh 的腳本,它已經很接近端對端測試。它將預先準備好的輸入提供給函式庫,用其中一部分資料訓練一個新的機器學習模型,再用這個模型去解析輸入資料的其他部分。唯一缺少的就是,它從未將結果與已知的正確輸出進行比對。

一個基本的端對端測試

第一篇中,我提到 roundtrip.sh 腳本的最終結果是一組關於模型效能的統計摘要:

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

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

這就足以讓我建立一個簡單的端對端測試。我重新執行了 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

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

端對端測試只有在能抓到錯誤時才有價值,所以下一步我刻意模擬一個會造成破壞的變更,看看我的端對端測試是否能抓到它。

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 不再視為數字時,函式庫的準確率下降了,腳本也以失敗的結束代碼終止。

擴充端對端測試

上述基本的端對端測試很有用,但 roundtrip.sh 執行的是包含多個階段的資料管線。如果能知道究竟是哪個階段出錯會更方便,因此我開始尋找更多可以納入端對端測試的輸出。

除了在主控台印出輸出外,這個腳本還會在名為 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 後,我把它們作為額外的基準正確輸出加入版本控制。接著,我在建置腳本中加入 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 的簡單包裝器,讓端對端測試能在函式庫專屬的 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 腳本,我的端對端測試就能在任何支援 Docker 的系統上執行。自然地,我也想讓它在持續整合環境中運行。

在持續整合中執行我的端對端測試

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

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

推送了變更,滿心期待要見證這個能在任何地方穩定執行的完美測試大放異彩。結果,它失敗了:

端對端測試在本地端通過,卻在 Travis 上失敗的螢幕截圖

端對端測試在本地端通過,卻在 Travis 上失敗

看到建置失敗當然不開心,但我也很慶幸自己的端對端測試確實抓到了問題。我只需要弄清楚到底是什麼問題。

除錯:找出差異

Docker 容器的重點就在於讓程式在任何地方的行為都一致,那麼為什麼我在兩個地方執行同一個容器,卻會看到不同的輸出呢?

Travis 的建置紀錄顯示,測試是在比對 testing_output 檔案時失敗的:

+ 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 機器學習邏輯的核心引擎。即使對這些工具不太熟悉,從語法上也能推斷出 crf_learn 是用來建立機器學習模型的,而 crf_test 則是用該模型來分類資料。

端對端測試已經驗證了 $ACTUAL_CRF_TRAINING_FILE$ACTUAL_CRF_TESTING_FILE 的內容與我的基準正確版本一致。這表示 crf_learncrf_test 在本地端和持續整合環境中接收到的輸入完全相同,卻根據環境產生了不同的輸出。

深入探究 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:

如果電腦有多個 CPU,你可以透過多執行緒來加快訓練速度。NUM 即為執行緒數量。

這聽起來很有希望。

我比較了 CRF++ 在 Travis 上和本地環境中相同的輸出內容:

本地機器與 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"

接著,我將新產生的輸出檔儲存為基準正確版本。我把變更推送到 GitHub,迎來了令人愉悅的景象:我的端對端測試通過了

修正端對端測試後成功的螢幕截圖

端對端測試在 Travis 上通過

好的測試的價值

端對端測試很快就證明了它的價值。雖然深入研究函式庫其中一個相依套件的文件很費工,但這個測試揭露了函式庫會因環境不同而產生不一致的結果。這很可能是原作者從未察覺的問題。

有了端對端測試和持續整合的加持,我就有了一個能展現函式庫預期功能的權威環境。這個測試提供了寶貴的保障,以防我做出任何無意間改變函式庫行為的修改。

接下來呢?

有了測試帶來的信心,就到了我最喜歡的軟體專案環節:重構。因為我知道,如果我做了什麼太離譜的修改,建置就會大聲地失敗,所以可以放心地對程式碼進行大規模的更動。

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

  • 加入單元測試
  • 自動套用程式碼風格規範
  • 將靜態分析整合到建置流程中

封面插圖由 Loraine Yow 繪製。我 fork 的 ingredient-phrase-tagger 函式庫可在 GitHub 上取得。我基於此函式庫提供一項名為 Zestful 的託管服務。

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

留言