AI 互動教室 ‹ MLOps 自動化技術
下載 .py 開啟實戰 notebook ↗ 留言回報
ONNX · 補充 J · 15

ONNX 匯出上線:
同一個模型,推論快幾百倍

模型上線之後,那台伺服器裡跑的其實是一個 pickle 出來的 Python 物件—— 它綁 Python、綁 scikit-learn 版本,而且每一筆預測都要穿過一整層 Python 呼叫。 把同一個模型匯出成 ONNX(模型的通用交換格式)之後,用 onnxruntime 執行, 答案一模一樣,時間差幾個數量級。差多少要看你一次送幾列——自己選選看:

一次送
模型
scikit-learn
onnxruntime

長條為 log 刻度(不用 log,快的那根短到看不見)

毫秒數是同一台 CPU 機器上的實測(onnxruntime 1.29、onnx 1.22、100 棵樹深度 8 的 RandomForest), 多次量測會在一個範圍內浮動——看倍數,不要看絕對值。GPU 或深度模型的比例完全不同(常見是 1–3 倍)。

01 · 為什麼換格式

pickle 的三個代價,上線後才會痛

訓練完 pickle.dumps(rf) 存檔,是最順手的做法,也是大多數服務上線時的樣子。 它有三個代價,在筆記本裡完全感覺不到,一上線就全部浮出來:

代價症狀ONNX 怎麼解
綁 Python只有 Python 讀得回來。前端、行動裝置、Java 服務要用,只能再包一層 API存的是運算圖不是 Python 物件;C++/Java/C#/JavaScript 都有 onnxruntime
綁版本scikit-learn 1.7 存的檔在 1.4 的機器上可能載不起來,或更糟——不報錯但算錯圖裡只有算子與 opset 版本,執行端不需要 scikit-learn
每筆預測穿過一長串 Python 函式;單筆延遲以毫秒計算子是編譯好的 C++,實測單筆 約 6–7 ms → 0.008 ms

第三點最容易被低估:批次算 500 列的時候均攤下來很便宜, 但線上 API 是一次來一筆——那時候你付的是「單筆」的價錢,不是「每列」的價錢。 實測同一個 RandomForest,500 列一次要 8.7–10.4 ms,一列也要 5.9–7.1 ms: 幾乎一樣貴,因為時間幾乎全花在跟資料量無關的固定成本上。

到 notebook 的 1️⃣ 節:pickle 的三個代價
02 · 轉換

to_onnx 只有一行,但那一行裡有三個要點

from skl2onnx import to_onnx onx = to_onnx( rf, # ① 必須是已經 fit 過的模型 X_train[:1], # ② 範例輸入:決定型別(float32)與欄數(12) options={id(rf): {"zipmap": False}}, # ③ 機率回 ndarray,不要 list of dict ) (WORK / "rf.onnx").write_bytes(onx.SerializeToString())

① 已經 fit 過。拿沒訓練的 estimator 去轉,會得到 RuntimeError: No known ways to retrieve the number of classes…

② 範例輸入就是對外合約。轉換器不猜你的資料,它從這一筆讀出「12 個欄位、float32」, 把形狀寫死進圖裡。所以範例的 dtype 就是上線時必須用的 dtype。 忘了給範例會直接說:NotImplementedError: Initial types must be specified.

zipmap=False 幾乎每次都要加。sklearn 分類器的預設會把機率包成 「每列一個 {類別: 機率} 字典」(那個算子叫 ZipMap),拿到手是 Python 的 list of dict, 不能切欄、也不好給別的語言用。關掉之後 probabilities 就是乾淨的 (n, 2) 陣列。

轉出來以後先看圖的合約與檔案大小(實測輸出):

轉換耗時: 0.2–0.3 s --- graph 合約 --- input X shape=['?', 12] (elem_type 1 = float32) output label shape=['?'] output probabilities shape=['?', 2] opset: [('ai.onnx.ml', 1), ('ai.onnx', 22)] 節點: {'TreeEnsembleClassifier': 1} --- 檔案大小 --- rf.onnx 548 KB rf.pkl 1215 KB → ONNX 是 pickle 的 0.45 倍

輸入第一維是動態的(幾列都行),第二維 12 寫死。輸出兩個:labelprobabilities。 最值得注意的是整座 100 棵樹的森林在圖裡只有一個節點—— 樹的分支條件與葉值全放在那個 TreeEnsembleClassifier 算子的屬性裡, 所以檔案比 pickle 小一半,執行時也不必在 Python 層走訪 100 次。

到 notebook 的 2️⃣ 節:三個要點與 graph 合約
03 · 對答案

換格式,不可以換答案

轉換是「翻譯」,翻譯可能翻錯:不支援的參數被靜靜忽略、機率欄序顛倒、float32 捨入。 這種故障沒有任何錯誤訊息,只是答案悄悄不一樣了。所以每轉一次就要對一次答案,而且要寫成函式進 CI:

sess = ort.InferenceSession(str(ONNX_PATH), providers=["CPUExecutionProvider"]) IN = sess.get_inputs()[0].name # 這個模型是 "X" label, proba = sess.run(None, {IN: X_test}) # None = 所有輸出都要,回來是 list def assert_same(sk_model, session, in_name, X, atol=1e-5): lab, pr = session.run(None, {in_name: X}) max_diff = float(np.abs(np.asarray(pr) - sk_model.predict_proba(X)).max()) agree = float((np.asarray(lab) == sk_model.predict(X)).mean()) return max_diff < atol and agree == 1.0

500 列 test set 的實測結果:

機率最大差異 1.91e-07(門檻 1e-05)|類別一致率 100.0% → ✅ 通過 第 0 筆:ONNX [0.254004 0.745996] | sklearn [0.254004 0.745996]

1e-07 這個量級的差不是 bug,是 float32 的捨入——ONNX 圖用 32 位元算, sklearn 內部用 64 位元。對「機率大於 0.5 就判正」完全無關痛癢,但你要知道它存在: 閾值卡在 0.500000 附近、或下游要拿機率做精算的話,容許值要自己定。 真正該警覺的是類別不一致,或誤差跳到 1e-2 等級——那就是翻譯出錯了,別上線。

到 notebook 的 3️⃣ 節:InferenceSession 與 assert_same()
04 · 速度

批次快 10 倍上下,單筆快好幾百倍

情境scikit-learnonnxruntime倍數
RandomForest · 500 列一次8.7–10.4 ms0.6–1.2 ms9–14×
RandomForest · 單筆5.9–7.1 ms0.008–0.012 ms500–840×
LogisticRegression · 單筆0.10–0.25 ms0.006–0.012 ms16–20×

為什麼差這麼多?兩件事加起來。 第一,Python 物件的開銷rf.predict_proba(一筆) 要建 numpy 陣列、跑輸入檢查、 在 Python 層迴圈走訪 100 棵樹再平均——這些成本跟資料量幾乎無關,所以一列和五百列花的時間差不多, 單筆時它們就是全部的成本。 第二,編譯好的算子:onnxruntime 那邊整座森林是一個 C++ 實作的算子, 圖在建立 session 時就規劃好了記憶體與執行順序,呼叫一次只是把資料丟進去。

所以請記住這個區別:加速主要來自「每次呼叫的固定成本」,批次越大越被均攤掉。 LogisticRegression 的倍數小很多,也是同一個道理——它在 sklearn 裡本來就只是一次矩陣乘法, Python 開銷占比沒那麼誇張。 永遠在付單筆價錢的線上 API,才是換 ONNX 收益最大的地方。

notebook 的 6️⃣ 節有一個拉桿:自己選 1–500 列與模型,按一下當場量、當場畫圖。 把列數從 1 拉到 500,看那個倍數怎麼一路縮小——這是本課最值得自己跑一次的東西。

到 notebook 的 4️⃣ 與 6️⃣ 節:延遲對照與互動量測
05 · 上線

存進 MLflow、包成函式、守住合約

.onnx 是一個檔案,但別讓它變成「某台機器上的孤兒檔案」—— 跟訓練它的那個 run 綁在一起,三個月後才有人說得出線上跑的是誰:

with mlflow.start_run(run_name="rf-onnx") as run: mlflow.log_params({"n_estimators": 100, "max_depth": 8, "runtime": "onnxruntime"}) info = mlflow.onnx.log_model(onx, name="model_onnx") # → models:/m-0abcaa0c… def predict_proba_onnx(session, in_name, rows): X = np.asarray(rows, dtype=np.float32) # 型別合約:一律轉 float32 if X.ndim == 1: X = X.reshape(1, -1) # 形狀合約:單筆也要是二維 return np.asarray(session.run(None, {in_name: X})[1])[:, 1]

這個函式短得有點可疑,但那兩行防呆正是把三種最常見的上線錯誤擋在門外。 ONNX 的合約很硬,違反了會當場報錯——這是好消息,因為 pickle 的 sklearn 模型在同樣情況下常常照算不誤, 算出一個沒有意義的機率,沒有人會發現。三種實測錯誤原文:

[餵 float64] InvalidArgument: [ONNXRuntimeError] : 2 : INVALID_ARGUMENT : Unexpected input data type. Actual: (tensor(double)) , expected: (tensor(float)) [少一欄] InvalidArgument: [ONNXRuntimeError] : 2 : INVALID_ARGUMENT : Got invalid dimensions for input: X for the following indices index: 1 Got: 11 Expected: 12 [忘了 reshape] InvalidArgument: [ONNXRuntimeError] : 2 : INVALID_ARGUMENT : Invalid rank for input: X Got: 1 Expected: 2

接上線的三條路,都用同一個檔: 把上一課 FastAPI 的 model.predict 換成 predict_proba_onnxInferenceSession 在服務啟動時建立一次,跟「模型載一次」同一個原則); 或把檔案交給 C++/Java/C# 的 onnxruntime,模型不再需要一台 Python 伺服器才能存在; 或用 onnxruntime-web 直接在使用者的瀏覽器裡跑這張圖,資料完全不離開裝置。

到 notebook 的 5️⃣ 節:MLflow、推論函式與合約錯誤
06 · 練習

換你動手

LEVEL 1

logreg 也轉成 ONNX,建 session 後呼叫本課的 assert_same()。它的 .onnx 只有幾百 bytes,RandomForest 是 548 KB——想清楚為什麼,順便解釋它的加速倍數為何小這麼多。

LEVEL 2

拿到一個陌生的 .onnx:用 onnx.load() 讀進來、onnx.checker.check_model() 驗一次,再列出 graph 裡每種節點型別各有幾個、以及 opset 版本。100 棵樹的森林有幾個節點?

LEVEL 3

ort.SessionOptions()intra_op_num_threads 控制單一算子用幾條執行緒。建 1/2/預設三個 session,量單筆與 500 列的延遲。先猜:單筆時執行緒開多會變快還是變慢?

卡住了?每一題在 notebook 末節都有折疊解答——先自己做,再打開對照。

07 · 驗收

情境測驗

離開前試試看:下面的情境都真的會遇到。每題選一個你認為的最佳做法,選了馬上看得到解釋。

Q1 情境題

兩個服務都吃同一個 RandomForest:A 是即時反詐 API,每次一筆、延遲要求 20 ms;B 是每天半夜把 200 萬列跑完的批次評分,跑 40 分鐘。團隊只有時間改一個。先改哪個、為什麼?

ONNX 省下的主要是「每次呼叫的固定成本」——建陣列、輸入檢查、Python 層走訪 100 棵樹。這些成本跟資料量幾乎無關,所以批次時被均攤到幾乎看不見(實測 500 列只快 9–14 倍),單筆時卻是全部的成本(實測約 6–7 ms → 0.008 ms,500–840 倍)。A 的直覺不對:批次 40 分鐘裡真正花在 predict 的往往只是一小段,讀寫資料才是大頭,而且就算全部是推論,倍數也小一個數量級;B 在時間有限的前提下不是「選一個」的答案,而且 A 每快一毫秒都直接影響使用者;D 是錯的,批次一樣可以用 ONNX,只是收益小得多。

Q2 錯誤診斷

模型轉換與本機測試都過了。接上線上服務後,每一筆請求都回 500,日誌裡是這一段。最直接的修法是?

rows = df[FEATURES].values # df 從 JSON 建出來的 DataFrame sess.run(None, {"X": rows}) onnxruntime.capi.onnxruntime_pybind11_state.InvalidArgument: [ONNXRuntimeError] : 2 : INVALID_ARGUMENT : Unexpected input data type. Actual: (tensor(double)) , expected: (tensor(float))

錯誤把兩邊都講明了:收到的是 double(float64),圖要的是 float(float32)。而圖之所以要 float32,是因為當初 to_onnx 的範例輸入就是 float32——那一筆範例把型別寫死成合約了。pandas 的 .values 預設給 float64,本機測試沒炸多半是因為當時直接餵訓練用的 float32 陣列。修法就是在推論函式的入口統一轉型。A 沒有根據,opset 跟 dtype 是兩回事;C 是好習慣但症狀不符——名稱錯會得到 Required inputs (['X']) are missing from input feed;D 版本不合會在 InferenceSession 建立時就失敗,不會等到 run

Q3 錯誤診斷

同事照著文件轉了模型,要取正類機率時炸了。他堅持「形狀我印過,就是 (n, 2)」。最可能的原因是?

onx = to_onnx(rf, X_train[:1]) # 轉換這一行 proba = np.asarray(sess.run(None, {IN: X_test})[1])[:, 1] IndexError: too many indices for array: array is 1-dimensional, but 2 were indexed # 印出來看:sess.get_outputs() → ['output_label', 'output_probability'] # probabilities[0] → {0: 0.2540045380592346, 1: 0.7459954619407654}

兩個線索直接指向 ZipMap:輸出名稱是 output_labeloutput_probability(關掉 zipmap 之後會是 labelprobabilities),而 probabilities[0] 印出來是一個 {類別: 機率} 字典。sklearn 分類器轉 ONNX 的預設行為就是加上 ZipMap 算子,把每列的機率包成字典;np.asarray 一個 list of dict 得到的是一維的 object 陣列,所以切第二維才會炸。他印過的 (n, 2) 是 sklearn 那邊的形狀,不是 ONNX 這邊的。A 是誤解,輸出順序就是 graph outputs 的順序、固定不變;B 症狀不符,錯的是機率陣列不是輸入;C 這是二分類,而且輸出只有兩個。

Q4 情境題

重訓的模型要換到線上。CI 只跑得動一件事,你要讓它抓住「ONNX 轉換出錯」這種故障。加哪一步最有效?

轉換出錯最可怕的形態是安靜的:不支援的參數被忽略、機率欄序顛倒、某個算子行為有細微差異——全都不會拋例外,只是答案變了。唯一抓得到的方法就是拿同一批輸入跑兩邊、逐筆比對輸出。實測的通過長相是機率最大差異 1.9e-07(float32 捨入)、類別一致率 100%;一旦類別不一致、或誤差跳到 1e-2 等級,就是翻譯真的出錯了。B 只驗「這張圖結構合法」,一張結構完全合法的圖照樣可以算出錯的答案;C 檔案大小跟正確性沒有因果關係,重訓後樹的深度不同大小本來就會變;D 方向對但太鈍——AUC 是排序指標,機率整體偏移或少數幾筆算錯,AUC 可能完全看不出來,而且它混淆了「模型品質」與「轉換正確性」這兩件事。

HANDS-ON · MOLAB

實作在 molab 跑(免費)

molab 的登入狀態進不了內嵌框架(瀏覽器的跨站 cookie 保護), 所以 notebook 要在新分頁執行——把它跟本頁並排開,左邊教學照樣對照。

  1. 登入 molab(GitHub / Google)
  2. 開啟課程 notebook,Fork 成自己的副本即可編輯
  3. 從第一格往下全部執行(首次安裝套件約 1–2 分鐘)——免費 CPU 環境即可,不需要 GPU,也不連任何外部服務

不想用 molab?下載 onnx-export_ext.py 後在自己電腦 uvx marimo edit --sandbox onnx-export_ext.py,依賴會自動安裝。

molab 的線上編輯器在手機上體驗有限——動手這一段建議用電腦進行。