Optuna 自動調參:
讓超參數搜尋自己跑
第 1 課你用一個 for 迴圈掃了 max_depth 的四個值。 問題是模型從來不只有一個旋鈕——四個超參數、每個試 5 個值,就是 625 次訓練。 下面是一張真實的分數地形(40 格全部訓練過), 兩種搜尋策略在上面花掉同樣的 25 次預算。按按鈕,看它們各自跑去哪裡——
橫軸 max_depth 2 → 16(左到右)、縱軸 min_samples_leaf 1 → 10(上到下); 顏色越深=交叉驗證 AUC 越高,綠框是全場最高的那一格。格子裡的數字是第幾次試到它。
40 格的分數、Optuna 那 25 次的落點順序,都是 notebook 的實測輸出 (scikit-learn RandomForest、3 折交叉驗證、TPESampler(seed=0))——不是示意圖。 本頁提到的「幾秒」都是同一台機器上的實測,你自己跑會不一樣——看比例,不要看絕對值。 這一課的實作在 molab 執行,不需要 GPU。
手動掃參數,掃到第幾個旋鈕會斷掉?
先算一筆很簡單的帳。假設一次訓練+評估要 3 秒:
| 超參數的數量 | 每個試 5 個值 | 總共要跑多久 |
|---|---|---|
| 1 個 | 5 種組合 | 15 秒 |
| 2 個 | 25 種 | 1 分鐘多 |
| 4 個 | 625 種 | 半小時 |
| 6 個 | 15625 種 | 13 小時 |
真實的訓練不會只有 3 秒。而且更氣人的是:那 15625 次裡,一大半在第一眼就看得出沒希望—— max_depth=2 那一整排怎麼配都不會贏,你卻還是老老實實跑完了。
notebook 第 1️⃣ 節就先把這件事做給你看:max_depth 取 8 個值、 min_samples_leaf 取 5 個值,40 次訓練、實測約 13–15 秒, 畫出開場那張地形圖。三個數字值得記住:最高的一格 0.9710(深度 10、葉子 1)、 最低的一格 0.9320、而最左邊那兩排 10 格,怎麼配都追不上——四分之一的預算丟進水裡。
地形圖還告訴你兩件事。第一,兩個軸的份量差很多:橫著走(換深度)最好與最差差 0.0386, 在夠深的那半邊直著走(換葉子大小)只差 0.0079,大約五分之一。 第二,也是最關鍵的一件:格點沒有記憶——它跑第 40 格的時候,知道的跟跑第 1 格時一樣多。 前面 39 次的結果,完全沒有拿來決定下一格去哪裡。
到 notebook 的 1️⃣ 節:把 40 格的地形跑出來study、trial、objective:Optuna 的全部語彙
objective 是你寫的函式,收一個 trial、回傳一個分數。 trial 既是「這次用了哪組參數」的紀錄,也是你向 Optuna 索取參數的入口—— trial.suggest_float(...) 就是在問「這次給我什麼值?」 study 是一整輪搜尋:記著所有 trial、決定下一個試什麼、最後告訴你 best_params。 描述搜尋空間的方式有三種,選錯會白白浪費一半的預算:
| 寫法 | 什麼時候用 | 不這樣寫會怎樣 |
|---|---|---|
| suggest_int("n_estimators", 20, 200, step=20) | 整數,而且差 1 沒有意義 | 沒有 step 就是 181 個值——搜尋空間大 10 倍,換不到任何東西 |
| suggest_float("lr", 1e-5, 1e-1, log=True) | 跨數量級的連續值(學習率、正規化強度) | 不加 log,九成的取樣會落在 0.01 以上,小的那一頭等於沒搜 |
| suggest_categorical("max_features", ["sqrt", "log2", None]) | 選項之間沒有大小順序 | 硬編成 0/1/2 的整數,等於騙 Optuna 說「log2 在 sqrt 和 None 中間」 |
notebook 第 2️⃣ 節用 20 個 trial 找 (x−2)² 的最小值,畫出每一次落在哪裡。 你會看到一件跟直覺相反的事:點並沒有一路收斂到 2,後半段照樣有點跑到 −8、+9 去。 這不是壞掉,是兩個設計:TPE 不是梯度下降(它刻意保留探索,不然遇到有兩個谷的地形會死在第一個谷裡), 而且預設前 10 個 trial 是純隨機暖身(n_startup_trials=10,沒有資料就沒得學)。 所以看搜尋有沒有進展,要看的是「目前為止的最佳」那條線,不是單一個點的位置。
到 notebook 的 2️⃣ 節:20 個 trial 各落在哪裡一個 trial = 一個 MLflow nested run
第 1 課的 parent/nested 結構,在這裡剛好就是「一次搜尋/一個 trial」。 實測 25 個 trial 約 17–20 秒,最佳 cv_auc 0.9683 (n_estimators=80, max_depth=9, min_samples_leaf=3, max_features='sqrt'), 出現在第 16 號 trial。
有一件事一定要先講清楚:調參的分數要用交叉驗證,不能用測試集。 測試集只准在最後看一次。你如果拿測試集當搜尋目標,跑幾十個 trial 之後選出來的「最佳參數」, 其實是「最會迎合那幾百筆的參數」——那個好看的分數不會出現在正式環境。 這是調參最常見、也最貴的一個錯,而且它不會報錯。
notebook 那張「每個 trial 的分數(點)+目前最佳(階梯線)」的圖有兩層資訊。 階梯線只會往上,而且越走越平——那就是「什麼時候可以停」的依據。 但真正好看的是點的分佈:實測第 10 號之後最差的一次是 0.9619, 而前 10 個裡最差的是 0.9277。TPE 不是每一發都更好,它是不再往爛區丟。
到 notebook 的 3️⃣ 節:25 個 trial、25 個 nested runTPE 真的比亂猜好嗎?答案沒那麼漂亮
「聰明地挑下一組」聽起來很棒,但值得懷疑。所以直接量:同一個 objective、同一個空間、同樣 25 個 trial, 只把取樣器換成 RandomSampler(seed=0)(純隨機亂挑)。實測:
| TPESampler | RandomSampler | |
|---|---|---|
| 最佳 cv_auc | 0.9683 | 0.9694 ←贏 |
| 25 個 trial 的平均 | 0.9640 | 0.9590 |
| 第 10 號之後最差的一次 | 0.9619 | 0.9280 |
| 前 10 個 trial | 兩邊一模一樣——TPE 的暖身期就是隨機取樣器,種子也相同 | |
這一局,最佳值是隨機贏的。隨機在第 17 號 trial 矇到一個好組合——這種事很常發生, 25 個 trial 對四維空間來說太少,運氣的份量還很重。 (另外用 seed 1/2/3 各重跑一輪:TPE 0.9716/0.9720/0.9717,隨機 0.9716/0.9704/0.9684, TPE 兩勝一平一敗。這才是誠實的比數。)
但看第二、三列:TPE 的 trial 品質高得多。第 10 號之後隨機還在往 max_depth=2 那種爛區丟,TPE 已經不去了。 TPE 的價值不是「保證找到更好的答案」,是「同樣的預算,浪費得比較少」—— 而這件事在 trial 數多(幾百次以上)、空間大(十幾個超參數)、單次評估很貴(訓練要好幾小時) 的時候會被放大到無法忽視。你的問題如果是「四個參數、跑 20 次就夠」,老實說隨機搜尋也很好用。
開場那個互動就是 notebook 第 4️⃣ 節的另一半:地形已經算好了, 取樣器的比較就變成零成本(毫秒級,而且分數全是真的)。結果同樣不客氣: 第 5 次 TPE 已經站在 0.9681、格點還在 0.9324;第 15 次 TPE 0.9687、格點 0.9656; 但第 25 次格點反而贏了(0.9710 對 0.9687)。 這句話才是重點:空間小到掃得完的時候,格點最後一定會贏,因為它會把每一格都看過。 Optuna 的價值不在終點,在「同樣的預算下,你現在手上有多好的答案」—— 真實任務動輒幾萬、幾百萬格,你永遠掃不到終點。
到 notebook 的 4️⃣ 節:對照跑一次,順便把地形當免費模擬器跑完一輪,最值錢的不是那組參數
做法(fANOVA)是:拿你跑過的所有 trial 當訓練資料,訓練一個「參數 → 分數」的小模型, 再問它「分數的變異,有多少可以歸給每個參數」,加起來是 1。 seed=0 別省——這個評估器本身是隨機的,不給種子同一個 study 每次算出來會差幾個百分點。
實測 max_depth 一個人吃掉約 0.88,其餘三個加起來不到 0.12。 這正是第 1️⃣ 節那張地形圖的數字版。重要度的用途不是拿來炫耀,是決定下一輪怎麼搜:
| 重要度告訴你 | 下一輪就 |
|---|---|
| 某個參數獨大 | 把它的範圍縮到最佳值附近、取樣密一點 |
| 某個參數幾乎是 0 | 固定成常數,把省下來的預算讓給重要的那個 |
| 最佳值貼在範圍邊界 | 把邊界往外推——真正的最佳可能在你的範圍之外 |
照著做一次,效果大得有點誇張。第二輪:max_features 固定成 "sqrt"、max_depth 縮到 8–14、 min_samples_leaf 縮到 1–4、n_estimators 下限拉到 40, 只跑 10 個 trial、約 7 秒:
| trial 數 | 耗時 | 最佳 cv_auc | |
|---|---|---|---|
| 第一輪(大空間) | 25 | 約 17–20 秒 | 0.9683 |
| 第二輪(縮小後) | 10 | 約 7 秒 | 0.9716 |
少了 15 個 trial、少花一半以上的時間,分數卻更高。更值得看的是分佈: 第二輪 10 個 trial 裡有 7 個比第一輪跑 25 次的最佳(0.9683)還高, 最差的一個也有 0.9661——因為它們全落在好區裡。 調參是一個迴圈,不是一次跑很多:大範圍粗搜 → 看重要度與最佳值的位置 → 縮小/平移範圍、固定不重要的參數 → 再搜一輪。兩輪各 25 次,幾乎永遠贏過一輪 50 次。
最後兩個必要的警告。重要度是估計,不是物理常數:它是從你跑過的 trial 推出來的, 而那些 trial 又是 TPE 挑的(集中在高分區),所以它回答的是「在我搜過的那一帶,哪個參數影響大」。 實測第二輪再算一次重要度,三個參數變成幾乎平手(各約 0.32–0.35)—— max_depth 不再獨大,因為它的範圍已經全在平坦區了。 而且換一個評估器答案可能完全不同:同一個 study 改用 PedAnovaImportanceEvaluator(),排第一的會變成 min_samples_leaf。把它當方向盤,不要當排行榜。
最後,整堂課只有一個地方會動到測試集:拿第二輪的最佳參數在完整訓練集上訓練一次、 在測試集上評一次分。這個數字不是拿來繼續調參的(一調就作廢了),它只回答一個問題: 我在交叉驗證上看到的分數,可信嗎?兩個數字接近就是好消息; 實測:交叉驗證 0.9716、測試集 0.9691,只差 0.0025——可信。 如果測試分數低很多(差 0.02 以上),通常代表搜尋已經擬合了交叉驗證切分裡的雜訊—— 這時候該做的不是再多跑幾百個 trial,而是換更穩的評估(折數多一點、或重複幾次不同切分取平均)。
到 notebook 的 5️⃣ 節:算重要度、縮小空間、跑第二輪、最後看一次測試集沒希望的 trial,不用跑完
很多模型可以邊訓練邊報分數:GBDT 每加一輪樹、神經網路每跑完一個 epoch、 隨機森林每多長一批樹——中間都有暫時的成績。有了中間分數,就能做一件很划算的事: 跟別人比,比輸太多就直接放棄。MedianPruner 的判準很直白: 在同一個階段,你比其他 trial 的中位數還差,就砍 (n_startup_trials=5 是「前 5 個一律跑完」,沒有樣本就沒有中位數可比)。
| 15 個 trial | 耗時 | 被砍掉 | 最佳驗證 AUC |
|---|---|---|---|
| NopPruner()(不砍) | 約 9.7–9.9 秒 | 0 | 0.9564 |
| MedianPruner() | 約 6.3–6.6 秒 | 8 個 | 0.9564 |
省下約 33–36% 的時間,最佳值一模一樣。被砍的那些 trial 在只長了 1/3 棵樹的時候 就已經落在中位數之下,再長完剩下 2/3 也追不回來。 (這一節的分數用的是從訓練集再切出來的 375 列驗證集,跟前面幾節的交叉驗證分數是兩把不同的尺, 不要互相比大小。)
三件實務上會咬人的事:① pruning 只對「能分段回報」的模型有意義——你的 objective 如果是一個 cross_val_score(...) 就結束,中間沒有任何可回報的分數, 掛上 pruner 也不會砍到任何東西;要嘛改成「一折報一次」,要嘛就別用。 ② pruning 不是免費的,它在賭「現在落後的最後也贏不了」——遇到先慢後快的訓練曲線 這個賭注會輸,這時候把 n_warmup_steps 調大,讓每個 trial 至少跑幾步再評判。 ③ 被砍掉的 trial 不是白跑的:它照樣進 study,TPE 也會參考它的中間分數, 只是 state 是 PRUNED、value 是空的。
到 notebook 的 6️⃣ 節:有/無 pruner 並排跑一次Optuna 負責找,MLflow 負責記
兩邊都記,是不是多此一舉?不是——它們記的東西不一樣,缺一個都會痛:
| Optuna 的 study | MLflow 的 run | |
|---|---|---|
| 記什麼 | 參數、分數、狀態、取樣器要用的分佈資訊 | 參數、指標、標籤、任何檔案(模型、圖、資料快照) |
| 給誰看 | 演算法(決定下一個 trial 試什麼) | 人(比較、翻舊帳、交接) |
| 跨次搜尋 | 一個 study = 一次搜尋 | 同一個 experiment 裡,這個月的搜尋跟上個月的並排 |
| 能不能存模型 | 不能 | 能——第 2 課的 log_model 直接接到 Registry |
分工很清楚:Optuna 負責找,MLflow 負責記。最佳那組參數在 MLflow 裡有一個 run id, 你可以順手把冠軍模型也 log_model 進去, 第 2 課的 Registry、第 6 課的上線流程就全部接得上了。
官方另有 optuna-integration 套件, 裡面的 MLflowCallback 一行掛上去就自動記 (study.optimize(objective, callbacks=[MLflowCallback(metric_name="cv_auc")]))。 這一課故意手寫,因為手寫你會親眼看見「一個 trial = 一個 nested run」, 而且要記什麼、run 叫什麼名字、要不要順便存模型,全部由你決定。知道有這個 callback 就好, 等記錄需求穩定下來再換過去。
到 notebook 的 7️⃣ 節:撈出 25 個子 run 跟 Optuna 對答案兩個參數,讓搜尋活過 notebook 關掉
到目前為止的 study 都活在記憶體裡——notebook 一關就沒了。調參動輒跑幾小時,這顯然不行。 notebook 第 8️⃣ 節示範「先跑 5 個 → 關掉 → 重新開一個 study 物件接著跑 5 個」: 第二次是全新的 create_study 呼叫,卻看得到前 5 個 trial, 最後累積成 10 個。整個資料庫檔案只有一百多 KB。
三件相關的事:① load_if_exists=True 一定要加, 不加的話同名 study 撞上去會直接 DuplicatedStudyError; 而寫個 optuna.delete_study() 去「解決」它,等於把昨天跑了三小時的結果刪光。 ② 這就是分散式搜尋——多台機器(或多個行程)用同一個 storage、同一個 study_name 各自 optimize(), 每台都把結果寫回同一個資料庫、也都讀得到別台的結果,TPE 的建議因此越來越準; 正式一點的做法是把 sqlite 換成 PostgreSQL/MySQL(sqlite 的檔案鎖在多寫入者下會卡住)。 ③ optuna-dashboard 讀的就是這個檔—— optuna-dashboard sqlite:///optuna.db 就有互動式的重要度圖、 平行座標圖、等高線圖可以看。
到 notebook 的 8️⃣–9️⃣ 節:續跑,然後自己拉桿跑一輪換你動手
把 criterion("gini" / "entropy")加進搜尋空間重跑 25 個 trial,再算一次重要度——它會排到第幾名?兩種 criterion 的平均分數可以直接拿來比嗎?
改成多目標搜尋:AUC 越高越好、樹越少越好(directions=["maximize", "minimize"])。這時候 study.best_trial 會炸掉,要改用 study.best_trials——找出「AUC 只掉一點點、樹卻少很多」的那一組。
把這一課的搜尋包成第 5 課那條 Dagster 管線裡的一個資產:best_params 成為下游訓練資產的輸入,品質閘照舊。不用真的裝 dagster,先把資產的邊界、metadata 與「重跑會發生什麼」設計出來。
卡住了?每一題在 notebook 末節都有折疊解答(含實測輸出)——先自己做,再打開對照。
情境測驗
離開前試試看:下面的情境都真的會遇到。每題選一個你認為的最佳做法,選了馬上看得到解釋。
Q1 情境題
你要調一個模型的 4 個超參數,一次評估約 40 秒,總預算只夠跑 50 次。同事說「那就一次跑 50 個 trial 的 TPE,讓它自己找」。最佳做法是?
調參是一個迴圈,不是一次跑很多。實測就是這樣:第一輪 25 個 trial 在四維空間裡拿到 0.9683,看完重要度(max_depth 獨吃約 0.88)之後固定 max_features、把 max_depth 縮到 8–14、min_samples_leaf 縮到 1–4,第二輪只跑 10 個 trial 就到 0.9716,而且那 10 個裡最差的一個都比第一輪的最佳高。原因很簡單:第二輪的每一次都花在對的地方。A 不是錯,只是把一半的預算浪費在已經知道沒希望的區域(例如 max_depth=2 那一帶);B 格點在小空間終究會贏,但 4 個參數各 3 個值只有 3 段解析度,而且它沒有記憶,前面 49 次的結果不會影響第 50 次;D 把「還沒縮小範圍就看不出進步」當成模型不行,很容易在第一輪的雜訊裡誤判——第一輪本來就有一半 trial 是隨機暖身。
Q2 情境題
同事的 objective 寫成下面這樣,跑了 200 個 trial 拿到 AUC 0.991,開心地把那組參數送上線。兩週後線上實際表現只有 0.94。最可能的問題與修法是?
這是調參最貴、也最常見的錯,而且它完全不會報錯:測試集被搜尋看過 200 次之後就不再是「沒看過的資料」了,那個 0.991 是對這批測試資料的最佳擬合,不是模型的真實能力。正確做法是 objective 只用訓練集(例如 cross_val_score(..., cv=3, scoring="roc_auc").mean()),測試集在整輪搜尋結束後只評一次,當作最後的體檢。A 方向反了:trial 少只是「洩漏得少一點」,錯的是分數的來源不是數量;C 換模型不會改變「用測試集調參」這件事,新模型照樣會過擬合到同一批測試資料;D 固定種子讓數字可重現,但可重現的偏誤還是偏誤。
Q3 情境題
你的 objective 是「訓練一個 RandomForest → 回傳 cross_val_score(...).mean()」,一個 trial 要 3 分鐘。同事建議掛上 MedianPruner 省時間。會發生什麼?
pruning 是「回報中間分數 + 問還有沒有救」兩步組成的:trial.report(score, step) 之後 trial.should_prune() 才有東西可比。objective 如果一路算到底才回傳,pruner 從頭到尾拿不到任何中間值,掛上去也只是靜靜地什麼都不做(實測:一個沒有 report 的 objective 配上 MedianPruner,5 個 trial 全部 COMPLETE)。要在這個情境省時間,得把 cross_val_score 拆開自己跑迴圈,每算完一折就 report 一次——前兩折就明顯落後的組合,第三折不用算了。A 是把「pruner 很聰明」想像成它會自己拆你的函式;B 不設 n_warmup_steps 只是用預設值,不會報錯;C 描述的是 pruning 太積極的症狀,但前提是它真的有在砍。
Q4 錯誤診斷
搜尋跑完 25 個 trial 都沒有報錯,但最後一行炸了。最可能的原因是?
這是 Optuna 最沉默的一種失敗:objective 回傳 None(或 NaN、或值的個數對不上目標數)時,Optuna 不會拋例外,只會把該 trial 標成 FAIL 並記一行 warning,optimize() 照樣「順利」跑完 25 次。等到你去拿 best_value,才會撞上 ValueError: No trials are completed yet.。在這段程式裡,score 算完只餵給了 log_metric,函式結尾沒有 return score——加回去就好。事前的自保方式是跑完看一眼 [t.state.name for t in study.trials],全是 FAIL 就知道出事了。A 完全沒有根據:暖身期的 trial 一樣會 complete;C 是憑空想像,with 區塊不會影響回傳值(真正的問題是根本沒有 return);D direction 不寫預設是 minimize,會照樣算出一個 best,錯誤訊息也會完全不同。
Q5 錯誤診斷
昨天的搜尋跑了三小時、存在 optuna.db 裡。今天想接著再跑 20 個 trial,執行同一支腳本卻直接失敗。最好的修法是?
錯誤訊息自己就寫了答案:「reuse the existing one by setting load_if_exists」。加上 load_if_exists=True 之後,這支腳本第一次跑會建立 study、之後每次跑都會接續——n_trials 是「這一次再跑幾個」而不是「總共要有幾個」,所以昨天三小時的 trial 全部保留,TPE 還會拿它們來決定接下來試什麼。A 是最貴的一個選項:delete_study 會把昨天的結果整個刪掉,而且完全沒有警告。B 能跑,但每天一個新 study 等於每天從零開始暖身(前 10 個 trial 又是隨機),也失去了跨天累積的意義。D 方向對但繞遠路:load_study 在 study 不存在時會丟 KeyError: 'Record does not exist.',用 try/except 去補一個 load_if_exists=True 一行就能做到的事。
實作在 molab 跑(免費)
molab 的登入狀態進不了內嵌框架(瀏覽器的跨站 cookie 保護), 所以 notebook 要在新分頁執行——把它跟本頁並排開,左邊教學照樣對照。
- 登入 molab(GitHub / Google)
- 開啟課程 notebook,Fork 成自己的副本即可編輯
- 從第一格往下全部執行(首次安裝套件約 1–2 分鐘)——免費 CPU 環境即可,不需要 GPU;整份跑完約 2 分鐘,因為它真的在訓練幾百棵森林
不想用 molab?下載 optuna-hpo_ext.py 後在自己電腦
uvx marimo edit --sandbox optuna-hpo_ext.py,依賴會自動安裝。
molab 的線上編輯器在手機上體驗有限——動手這一段建議用電腦進行。