讓已死的函式庫起死回生:第二部——穩定化
原文由 Michael Lynch 于 發布,訂閱此部落格
在這篇文章中,我將示範如何為一個完全沒有測試的陳舊函式庫補上自動化測試。
這是三部曲系列的第二篇,講述我如何讓 ingredient-phrase-tagger 起死回生的過程。這是一個利用機器學習將食材描述(例如「2 cups milk」)解析為結構化資料的函式庫。完整背景請參考第一篇,簡言之,我發現了一個已被遺棄的函式庫,並讓它重獲新生,成為我 SaaS 事業的核心動力:
在持續整合環境中執行
在第一篇的最後,我建立了一個 Docker 映像檔,讓這個函式庫能在任何系統上執行。下一步,就是讓它在持續整合環境中運行。
持續整合是指在每次變更程式碼時,都在一個獨立、受控的環境中測試軟體的做法。我偏好的持續整合方案是 Travis。他們的設定檔直觀易懂,而且為開源專案提供無限次的免費建置。
要整合 Travis,我在 Travis 的設定頁面中加入了我 fork 的 ingredient-phrase-tagger,然後啟用建置:

為 ingredient-phrase-tagger 函式庫啟用 Travis 建置
接著,我建立了一個名為 .travis.yml 的檔案,告訴 Travis 該如何建置這個函式庫:
sudo: required
services: docker
script: docker build .我將 commit 推上 GitHub、建立了一個 pull request,而 Travis 也順利完成了建置:

在 Travis 上的首次成功建置
加入端對端測試
Travis 雖然會建置我的 Docker 映像檔,但這樣的建置還沒有實質意義。它只建置了函式庫的相依套件,卻沒有實際執行任何功能。我想要的是一個能在我破壞函式庫功能時發出警示的建置。要做到這點,我需要一個端對端測試。
端對端測試會驗證一個完整、貼近真實世界的情境是否如預期運作。它的結構通常如下:
- 提供預先準備好的輸入資料及其預期輸出(也稱為「基準正確輸出」)。
- 使用自動化工具將輸入餵給函式庫。
- 將函式庫的輸出與基準正確輸出進行比對。
原始的儲存庫中包含一個名為 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 texttest_file、test_output 和 train_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"接著,我為這個腳本加上一個名為 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 的系統上執行。自然地,我也想讓它在持續整合環境中運行。
在持續整合中執行我的端對端測試
我先前的 Travis 設定只會建置 Docker 映像檔,並沒有實際測試函式庫的功能。現在有了完整的測試腳本,我便更新 .travis.yml 來執行它:
sudo: required
services: docker
-script: docker build .
+script: ./docker_build我推送了變更,滿心期待要見證這個能在任何地方穩定執行的完美測試大放異彩。結果,它失敗了:

端對端測試在本地端通過,卻在 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-NAMEtesting_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_learn 和 crf_test 都是 CRF++ 的命令列工具,而 CRF++ 正是驅動 ingredient-phrase-tagger 機器學習邏輯的核心引擎。即使對這些工具不太熟悉,從語法上也能推斷出 crf_learn 是用來建立機器學習模型的,而 crf_test 則是用該模型來分類資料。
端對端測試已經驗證了 $ACTUAL_CRF_TRAINING_FILE 和 $ACTUAL_CRF_TESTING_FILE 的內容與我的基準正確版本一致。這表示 crf_learn 和 crf_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 上和本地環境中相同的輸出內容:

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 的託管服務。
隨機一篇部落格


留言
登入後參與討論