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

Optuna 自動調參:
讓超參數搜尋自己跑

第 1 課你用一個 for 迴圈掃了 max_depth 的四個值。 問題是模型從來不只有一個旋鈕——四個超參數、每個試 5 個值,就是 625 次訓練。 下面是一張真實的分數地形(40 格全部訓練過), 兩種搜尋策略在上面花掉同樣的 25 次預算。按按鈕,看它們各自跑去哪裡——

OPTUNA(TPE)
試了 0 次 · 最佳
格點:照順序掃
試了 0 次 · 最佳

橫軸 max_depth 2 → 16(左到右)、縱軸 min_samples_leaf 1 → 10(上到下); 顏色越深=交叉驗證 AUC 越高,綠框是全場最高的那一格。格子裡的數字是第幾次試到它。

40 格的分數、Optuna 那 25 次的落點順序,都是 notebook 的實測輸出 (scikit-learn RandomForest、3 折交叉驗證、TPESampler(seed=0))——不是示意圖。 本頁提到的「幾秒」都是同一台機器上的實測,你自己跑會不一樣——看比例,不要看絕對值。 這一課的實作在 molab 執行,不需要 GPU。

01 · 組合爆炸

手動掃參數,掃到第幾個旋鈕會斷掉?

先算一筆很簡單的帳。假設一次訓練+評估要 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 格的地形跑出來
02 · 三個名詞

study、trial、objective:Optuna 的全部語彙

def objective(trial): x = trial.suggest_float("x", -10, 10) # 「x 可以在 -10 到 10 之間挑」 return (x - 2) ** 2 # 回傳分數(這一題越小越好) study = optuna.create_study(direction="minimize") study.optimize(objective, n_trials=20) study.best_params # → {'x': 接近 2 的某個數}

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 各落在哪裡
03 · 真實任務

一個 trial = 一個 MLflow nested run

def objective(trial): params = { "n_estimators": trial.suggest_int("n_estimators", 20, 200, step=20), "max_depth": trial.suggest_int("max_depth", 2, 16), "min_samples_leaf": trial.suggest_int("min_samples_leaf", 1, 10), "max_features": trial.suggest_categorical("max_features", ["sqrt", "log2", None]), } with mlflow.start_run(run_name=f"trial-{trial.number}", nested=True): mlflow.log_params(params) score = cv_auc(**params) # 3 折交叉驗證,只用訓練集 mlflow.log_metric("cv_auc", score) trial.set_user_attr("mlflow_run_id", mlflow.active_run().info.run_id) return score # ← Optuna 只看這一個數字 with mlflow.start_run(run_name="optuna-tpe-25"): # ← 整輪搜尋是 parent run study.optimize(objective, n_trials=25)

第 1 課的 parent/nested 結構,在這裡剛好就是「一次搜尋/一個 trial」。 實測 25 個 trial 約 17–20 秒,最佳 cv_auc 0.9683n_estimators=80, max_depth=9, min_samples_leaf=3, max_features='sqrt'), 出現在第 16 號 trial。

有一件事一定要先講清楚:調參的分數要用交叉驗證,不能用測試集。 測試集只准在最後看一次。你如果拿測試集當搜尋目標,跑幾十個 trial 之後選出來的「最佳參數」, 其實是「最會迎合那幾百筆的參數」——那個好看的分數不會出現在正式環境。 這是調參最常見、也最貴的一個錯,而且它不會報錯

notebook 那張「每個 trial 的分數(點)+目前最佳(階梯線)」的圖有兩層資訊。 階梯線只會往上,而且越走越平——那就是「什麼時候可以停」的依據。 但真正好看的是點的分佈:實測第 10 號之後最差的一次是 0.9619, 而前 10 個裡最差的是 0.9277TPE 不是每一發都更好,它是不再往爛區丟。

到 notebook 的 3️⃣ 節:25 個 trial、25 個 nested run
04 · 誠實的對照

TPE 真的比亂猜好嗎?答案沒那麼漂亮

「聰明地挑下一組」聽起來很棒,但值得懷疑。所以直接量:同一個 objective、同一個空間、同樣 25 個 trial, 只把取樣器換成 RandomSampler(seed=0)(純隨機亂挑)。實測:

TPESamplerRandomSampler
最佳 cv_auc0.96830.9694 ←贏
25 個 trial 的平均0.96400.9590
第 10 號之後最差的一次0.96190.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️⃣ 節:對照跑一次,順便把地形當免費模擬器
05 · 重要度與第二輪

跑完一輪,最值錢的不是那組參數

evaluator = optuna.importance.FanovaImportanceEvaluator(seed=0) optuna.importance.get_param_importances(study, evaluator=evaluator) # → {'max_depth': 0.88, 'min_samples_leaf': 0.07, 'n_estimators': 0.03, 'max_features': 0.02}

做法(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️⃣ 節:算重要度、縮小空間、跑第二輪、最後看一次測試集
06 · PRUNING

沒希望的 trial,不用跑完

for step in (1, 2, 3): # 先長 1/3 的樹、再 2/3、再全部 ...訓練到這個階段、算出 score... trial.report(score, step) # ① 回報「我現在幾分」 if trial.should_prune(): # ② 問 pruner「我還有救嗎」 raise optuna.TrialPruned() # 沒救就自首,這個 trial 標成 PRUNED

很多模型可以邊訓練邊報分數:GBDT 每加一輪樹、神經網路每跑完一個 epoch、 隨機森林每多長一批樹——中間都有暫時的成績。有了中間分數,就能做一件很划算的事: 跟別人比,比輸太多就直接放棄MedianPruner 的判準很直白: 在同一個階段,你比其他 trial 的中位數還差,就砍n_startup_trials=5 是「前 5 個一律跑完」,沒有樣本就沒有中位數可比)。

15 個 trial耗時被砍掉最佳驗證 AUC
NopPruner()(不砍)約 9.7–9.9 秒00.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 也會參考它的中間分數, 只是 statePRUNEDvalue 是空的。

到 notebook 的 6️⃣ 節:有/無 pruner 並排跑一次
07 · 對帳

Optuna 負責找,MLflow 負責記

mlflow.search_runs( experiment_names=["churn-hpo"], filter_string=f"tags.mlflow.parentRunId = '{parent_run_id}'", order_by=["metrics.cv_auc DESC"], ) # → 25 列,排第一的是 trial-16,跟 study.best_trial.number 對得上

兩邊都記,是不是多此一舉?不是——它們記的東西不一樣,缺一個都會痛:

Optuna 的 studyMLflow 的 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 對答案
08 · 續跑與分散式

兩個參數,讓搜尋活過 notebook 關掉

study = optuna.create_study( study_name="rf-hpo", storage="sqlite:///optuna.db", # ← 每個 trial 一存檔 direction="maximize", load_if_exists=True, # ← 已經有同名的就接著跑,沒有就新建 ) study.optimize(objective, n_trials=5) # 「這一次再跑 5 個」,不是「總共要有 5 個」

到目前為止的 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️⃣ 節:續跑,然後自己拉桿跑一輪
09 · 實戰

換你動手

LEVEL 1

criterion"gini" / "entropy")加進搜尋空間重跑 25 個 trial,再算一次重要度——它會排到第幾名?兩種 criterion 的平均分數可以直接拿來比嗎?

LEVEL 2

改成多目標搜尋:AUC 越高越好、樹越少越好(directions=["maximize", "minimize"])。這時候 study.best_trial 會炸掉,要改用 study.best_trials——找出「AUC 只掉一點點、樹卻少很多」的那一組。

LEVEL 3

把這一課的搜尋包成第 5 課那條 Dagster 管線裡的一個資產best_params 成為下游訓練資產的輸入,品質閘照舊。不用真的裝 dagster,先把資產的邊界、metadata 與「重跑會發生什麼」設計出來。

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

10 · 驗收

情境測驗

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

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。最可能的問題與修法是?

def objective(trial): model = RandomForestClassifier(**suggest_params(trial)).fit(X_train, y_train) return roc_auc_score(y_test, model.predict_proba(X_test)[:, 1]) # 用測試集當分數

這是調參最貴、也最常見的錯,而且它完全不會報錯:測試集被搜尋看過 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 都沒有報錯,但最後一行炸了。最可能的原因是?

def objective(trial): params = {...} with mlflow.start_run(run_name=f"trial-{trial.number}", nested=True): mlflow.log_params(params) score = cv_auc(**params) mlflow.log_metric("cv_auc", score) study.optimize(objective, n_trials=25) print(study.best_value) ValueError: No trials are completed yet.

這是 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,執行同一支腳本卻直接失敗。最好的修法是?

study = optuna.create_study(study_name="rf-hpo", storage="sqlite:///optuna.db", direction="maximize") optuna.exceptions.DuplicatedStudyError: Another study with name 'rf-hpo' already exists. Please specify a different name, or reuse the existing one by setting `load_if_exists` (for Python API) or `--skip-if-exists` flag (for CLI).

錯誤訊息自己就寫了答案:「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 一行就能做到的事。

HANDS-ON · MOLAB

實作在 molab 跑(免費)

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

  1. 登入 molab(GitHub / Google)
  2. 開啟課程 notebook,Fork 成自己的副本即可編輯
  3. 從第一格往下全部執行(首次安裝套件約 1–2 分鐘)——免費 CPU 環境即可,不需要 GPU;整份跑完約 2 分鐘,因為它真的在訓練幾百棵森林

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

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