今天要介紹在 MLOps 或資料科學專案中最常見的單元測試情境。
本來想搭配 Rust 程式碼做範例,但 Rust 菜雞如我直接被編譯器毒打了哈哈哈,所以有些部分等我變強一點再來補!
附上跟我一樣嚇爛了的 Ferris:
MLOps 的程式碼在測試上雖然擁有與其他軟體專案相同的邏輯,但由於資料科學的本質,某些情境會顯得難以測試。
例如以下都是 MLOps 專案中常見的情境:
當遇到這些情境時,最好的解法就是事先設計測試案例,並將重點放在輸入/輸出、例外與測試資料轉換的行為。
換句話說就是測試驅動開發,藉由事先考慮如何將程式碼的功能分解為更小、更易於管理的部分,使得這些情境的測試更容易執行。
與 ETL 相關的函式通常仰賴於先決條件或是與環境互動才能正常運作,因此要測試它們就不僅是幾個 assert
可以搞定的。
例如在 MLOps 專案中常常會讀取並寫入 csv、讀取圖片或音檔,這類函式的先決條件就是要讀取的原始資料檔必須存在,而它們還會因為創造包含乾淨資料的檔案而改變環境:
假設我們有一個讀取資料的函式 load_data
,它會在檔案存在時讀取 csv 檔並回傳 DataFrame,檔案不存在時則回傳 None:
fn load_data(raw_data_path: &std::path::Path) -> Option<DataFrame> {
if raw_data_path.is_file() {
let df = CsvReader::from_path(raw_data_path)
.unwrap()
.finish()
.unwrap();
Some(df)
} else {
None
}
}
在這個函式裡面使用了標準函式庫 std::path::Path 的 is_file() 確認檔案是否存在,以及 polars 的 CsvReader::from_path(raw_data_path) 讀取原始資料。
但我們並不需要測試 CsvReader::from_path()
或 is_file()
函式,這是 polars 與 Rust 標準函式庫開發者的工作。
我們唯一需要測試的是這個函式的邏輯,也就是 load_data
能在檔案存在時讀取檔案,而在檔案不存在時不做任何事。
其中一個作法是提供一個範例原始檔,呼叫這個函式,然後驗證結果是否為 DataFrame 或 None。
如此一來,測試能夠執行的先決條件就是範例原始檔必須存在 (或不存在),但這樣有可能造成同一個測試在本機可以執行並通過,到了 build server 卻失敗,這並不是我們所希望的。
更好的作法是破除對範例原始檔的相依性,藉由 mock CsvReader::from_path()
與 is_file()
的呼叫,讓它們回傳預定義的結果,或是使用一個沒有副作用的 stub 來代替,這樣我們就不需要另外留著一個檔案來執行測試,且不論在哪裡執行,測試都能以同樣的方式運作。
mock 與 stub 詳細可以參考 單元測試之 mock/stub/spy/fake ? 傻傻搞不清楚
在清理或轉換資料時,唯一的訣竅就是測試固定的輸入與輸出,但每個測試都只做一個驗證。
這代表了會有多個測試使用相同的範例資料,此時利用 fixtures 回傳範例資料就是一個好方法。
而此時測試的步驟如下:
而目前 Rust 支援 fixtures 較受歡迎的測試框架為 rstest,只需要透過 #[fixture]
attribute 就可以建立。
資料驗證也是 MLOps 中很重要的一環,在測試的時候可以考慮資料缺失、離群值以及資料處理 pipeline 是否穩健。
為訓練模型的函式撰寫測試也是與傳統測試截然不同的感受。
因為機器學習模型的擬合過程通常都相當複雜,我們無法預測訓練之後會得到怎麼樣的模型,而不知道預期的回傳值,就無法進行測試。
以下對此提供兩個小訣竅:
除此之外,在訓練過程中我們還可以考慮以下方法來測試、除錯與驗證我們的模型:
模型訓練的測試取決於模型的種類,以及對資料的了解,因此可以多與領域專家討論,以寫出正確的測試。
在 單元 測試時,我們應該將模型讀取與預測像存取檔案一樣破除相依性 (這裡指的是 mock)。
當然,有時候我們還是需要讀取模型進行整合測試,但因為這通常會耗費較多的時間,所以才需要將其隔離開,讓其他部分依然可以快速地進行單元測試或 TDD。
在 Rust 中只需要加上 #[ignore]
attribute 即可,例如:
#[cfg(test)]
mod tests {
use my_crate::model;
#[test]
#[ignore]
fn test_model_load() {
let model = model::load().unwrap();
// code that takes an hour to run
}
}
而要單獨執行這部分測試則可以使用 cargo test -- --ignored
。
或是也可以與所有測試一起執行 cargo test -- --include-ignored
。
事實上,在對模型進行單元測試的目的並非其準確度或表現,而是對其程式碼品質的確認,例如:
所以模型的測試並不會遵循標準單元測試的最佳實踐,換句話說,並非所有的外部呼叫都會被 mock,這些測試更接近於 narrow integration test。
然而,對模型進行簡單的測試卻能幫助我們在投入數小時進行訓練之前就找出問題,以深度學習模型為例,可行的測試流程有:
好啦,以上就是今天的內容,明天將最後一次針對 Rust 與 Python 進行比較,再來就可以開始我們的專案了!
明天見啦~