iPAS AI 奈米級拆解程式題解說・中級科目二 115-01
Questions 42–44 · scikit-learn / 模型選擇與評估

1、2、3 不是大小,99.95% 不是好成績,0.5 不是定律:
共享乘車題組的後三題

第 41 題的程式碼之後,同一個共享乘車情境還有三題,題目本身沒有附程式,問的卻都是寫程式時一定會碰到的事:地區被編成 1、2、3 以後該換哪種模型(第 42 題)、只有 0.05% 外掛時 99.95% 的準確率可不可信(第 43 題)、「一個都不能放過」時決策門檻該往哪裡調(第 44 題)。這頁把三題的情境寫成一支可以執行的 scikit-learn 程式,逐行拆開:每個模型物件是照哪張設計圖做的、fit 之後身上多了什麼、報告上的每個數字怎麼來,再用三個互動實驗和三支動畫,把四個選項一個一個驗證。

scikit-learn 1.8.0pandas 2.2.3NumPy 2.4.3 LinearRegressionRandomForestRegressorclassification_report predict_proba門檻 thresholdrecall/precision零基礎可讀

讀完這頁,你會知道

  1. scikit-learn 的固定套路:設計圖(類別)→ 做出模型物件 → fit 學習 → predict 預測。LinearRegression() 剛做好時身上沒有 coef_,fit 之後才多了斜率和截距;隨機森林 fit 之後身上掛著 100 棵決策樹物件。
  2. 第 42 題:A、B、C 編成 1、2、3 以後,線性迴歸只能畫一條直線,在本頁的模擬資料上把 C 區高估了 62;樹模型只問「代號 ≤ 1.5?≤ 2.5?」,把三區分開算,幾乎完全猜中。Ridge、線性核 SVM 為什麼救不了,羅吉斯迴歸為什麼根本不能用。
  3. 第 43 題:2000 人只有 1 個外掛時,「全部猜正常」就有 0.9995 的準確率;classification_report 的每一欄怎麼讀,accuracy 為什麼印成 1.00,考卷那張全是 1.00 的圖少了哪一欄(support)。
  4. 第 44 題:模型先算機率(predict_proba),再用門檻變成決定。門檻 0.5 只抓到 30 位外掛中的 11 位;降到 0.0037 才全部抓到,代價是人工覆核 889 筆。recall、precision、特異度怎麼互相拉扯。
§0

題目(三題共用同一個情境)

先看共用情境

三題的開頭都是同一段:「請依照共享乘車平台 driver_daily_stats.csv 情境作答:欄位包含 driver_id、daily_earnings、region、app_version、gps_ping_rate;其中 gps_ping_rate 可能被外掛竄改,外掛司機比例極低。」這是一個題組:程式碼只印在第一題(第 41 題),後面三題沒有程式,但問的都是接下來寫程式、建模型時會遇到的決定。

第 42 題承上題,團隊在建立預測模型前,先將 region(A、B、C 區)轉換為整數 1、2、3,即採用標籤編碼(Label Encoding)。模型上線測試後發現,線性迴歸模型對於 C 區(編碼為 3)的預測收入出現明顯高估,顯示模型可能將編碼值誤解為具有線性大小關係。若主管要求不得修改原有的特徵編碼方式,必須維持 1、2、3 的整數表示,但希望從演算法層面降低此類誤解風險,則下列何者最適合作為替代模型?

先憑直覺選一個,讀完再回來對照:

第 43 題平台安全部門發現,僅有極少數司機(約占總體 0.05%)會利用外掛程式竄改gps_ping_rate,以騙取里程補貼。某新進資料科學家訓練了一個二元分類模型,並在報告中指出:「模型整體準確率(Accuracy)高達 99.95%,因此可直接上線攔截可疑帳號。」然而,資深工程師檢視後認為,僅憑上述結果(如圖)仍不足以支持模型直接上線。下列何者不是其提出質疑的合理理由?
第 43、44 題原卷附圖:classification_report 的輸出,類別 0 和 1 的 precision、recall、f1-score 都是 1.00;accuracy、macro avg、weighted avg 也都是 1.00;圖上沒有 support 欄
原卷附圖(第 43、44 題共用)。注意它只有 precision、recall、f1-score 三欄,沒有 support 欄

先憑直覺選一個:

第 44 題針對上述外掛偵測模型,營運長下達最終的商業決策目標如下: 「對於作弊行為零容忍。即使誤凍結部分正常司機帳號,後續仍可交由人工覆核;但不能放過任何一個真正的外掛司機。」 根據上述目標,團隊在調整模型輸出的決策機率門檻(Threshold,預設為 0.5)時,應採取下列何種策略最為合理?

(附圖同第 43 題)先憑直覺選一個:

先抓三題的關鍵字

第 42 題:① 標籤編碼:A、B、C 換成 1、2、3,只是代號。② C 區被高估:模型把「3 比 2 大」當真了。③ 不得修改編碼:不能改成 One-Hot,只能換模型。④ 替代模型:還是要預測收入(一個數字),所以要的是迴歸模型。

第 43 題:① 0.05%:2000 人裡 1 人。② 準確率 99.95%:剛好等於「全部猜正常」的準確率。③ 如圖:一張全是 1.00 的報告。④ 題目問的是「不是合理的質疑」:找說錯的那一個。

第 44 題:① 不能放過任何一個:漏抓的代價最大。② 誤凍結可以人工覆核:誤報可以忍受。③ 門檻預設 0.5:模型先算機率,機率 ≥ 門檻才判成外掛。④ 要決定:門檻往上還是往下?

關於本頁用的資料與版本

三題都沒有附資料,本頁分成三份來實驗:第 42 題用程式模擬 2000 位司機,三區平均收入 A 約 150、B 約 300、C 約 180(最高的是 B,不是 C),用來重現「C 區被高估」;第 43 題直接用第 41 題那份 driver_daily_stats.csv(2000 位司機,裡面有 1 位外掛,剛好 0.05%);第 44 題模擬 20 萬筆司機資料(外掛 100 筆,也是 0.05%),讓測試資料裡有 30 位外掛可以抓。為什麼第 42 題不用那份 CSV,觀念 2 會說明。

本頁所有數字、輸出、警告和錯誤訊息,都是在 Python 3.13.7、scikit-learn 1.8.0、pandas 2.2.3、NumPy 2.4.3 上實際執行的結果。pandas 3.0.6(NumPy 2.5.4)也跑過造資料的部分:模擬出來的資料完全相同,只有文字欄的 dtype 從 object 變成 str。

§1

先補 Python 基本功:讀懂這三題需要的 7 個觀念

三題的選項裡沒有程式碼,可是每個名詞背後都是一個 scikit-learn 的物件:線性迴歸、隨機森林、分類報告、機率、門檻。完全沒寫過 Python 也沒關係,下面每個觀念都直接用本題的資料舉例,中間有兩支動畫:一支看線性迴歸和隨機森林怎麼對待 1、2、3,一支看 99.95% 的陷阱。

① 套路觀念 1
設計圖 → 物件 → fit
→
② 第 42 題觀念 2~4
編碼、直線、樹
→
③ 動畫 11、2、3 只是代號
→
④ 第 43 題觀念 5~6
混淆矩陣、報告
→
⑤ 動畫 299.95% 的陷阱
→
⑥ 第 44 題觀念 7
機率與門檻

動畫 3(門檻往下調)放在 §4,和門檻滑桿放在一起。

觀念 1|scikit-learn 的套路:設計圖 → 做出模型物件 → fit 學習 → predict 預測

先回顧第 41 題建立的三個畫面,這一頁會一直用到:

記憶體=一大排置物櫃

每一格都有自己的地址(例如 0x28faa9ad6a0)。程式做出來的每樣東西(物件),都住在某一格裡。

變數=一張名牌

程式裡寫的 lin、rf、clf 只是名牌,負責「指著」某個物件。

參照=名牌拉出去的箭頭

名牌用箭頭指向置物櫃裡的物件。沒有任何名牌指著的物件,Python 會把那一格收回來。

再加上三個比喻:模組=工具箱、類別=設計圖、函式=機器。最重要的一句:物件一定是照某張設計圖做出來的。scikit-learn(程式裡寫成 sklearn)是一個很大的工具箱,裡面分成很多子工具箱,這頁用到的東西分成兩種:

名字在哪個子工具箱是什麼怎麼用
LinearRegressionsklearn.linear_model設計圖(類別):線性迴歸模型加括號做出一個模型物件
LogisticRegressionsklearn.linear_model設計圖:羅吉斯迴歸(分類模型)同上
RandomForestRegressorsklearn.ensemble設計圖:隨機森林(迴歸版)同上
accuracy_score、classification_report、recall_score、precision_scoresklearn.metrics機器(函式):拿「正確答案」和「預測」來打分數直接呼叫,交回分數或報告
train_test_splitsklearn.model_selection機器(函式):把資料切成訓練用和測試用直接呼叫,交回 4 份資料

名字的大小寫就是線索:大寫開頭是設計圖(類別),小寫加底線的是機器(函式)。實測 inspect.isclass(LinearRegression) 是 True,type(accuracy_score).__name__ 是 'function'。

所有 scikit-learn 模型都照同一個套路

① 做物件lin = LinearRegression()
只有設定,還沒學
→
② fit(X, y)拿資料和答案學習
身上多了學到的東西
→
③ predict(X)拿新資料
交回預測
① 剛做好(實測): print(lin) → LinearRegression() ← 只印出設計圖名字和「改過的設定」,全用預設就是空括號 hasattr(lin, 'coef_') → False ← 還沒有斜率 lin.coef_ → AttributeError: 'LinearRegression' object has no attribute 'coef_' lin.predict(X) → NotFittedError: This LinearRegression instance is not fitted yet. Call 'fit' with appropriate arguments before using this estimator. ② lin.fit(X, y) is lin → True ← fit 交回的就是同一個物件(所以可以接著寫 .predict) fit 之後多了 → feature_names_in_、n_features_in_、coef_、rank_、singular_、intercept_ lin.coef_ → array([27.96929924]) ← 斜率 lin.intercept_ → np.float64(157.90396942660698) ← 截距

注意這些名字都以底線結尾:scikit-learn 的規定是,fit 學到的東西名字後面加 _。看到 coef_、estimators_、classes_,就知道它們是 fit 之後才有的。

深入一點:設計圖的家譜(可以先跳過)

三種模型的設計圖都繼承自同一張 BaseEstimator(實測 MRO):LinearRegression → … → LinearModel → BaseEstimator → … → object;RandomForestRegressor → ForestRegressor → … → BaseEstimator;LogisticRegression → LinearClassifierMixin → ClassifierMixin → … → BaseEstimator。get_params()(列出所有設定)和 print 時的顯示方式,這些共同的本事都寫在 BaseEstimator 上;fit、predict 則寫在各自那一支家譜上,實測 LinearRegression.fit 寫在 LinearRegression 自己、predict 寫在上一層的 LinearModel;隨機森林的 fit 寫在 BaseForest、predict 寫在 ForestRegressor。名字都一樣,所以換模型時只要換掉設計圖的名字,後面的 fit、predict 都不用改。

觀念 2|標籤編碼:把 A、B、C 換成 1、2、3,數字只是代號

模型只會算數字,看不懂文字 A、B、C。最簡單的轉法就是給每一區一個號碼,這叫標籤編碼(Label Encoding)。在 pandas 裡用 Series 的 map 方法,給它一本「對照表」(字典):

sim['region_code'] = sim['region'].map({'A': 1, 'B': 2, 'C': 3})

前 6 列實測長這樣(收入四捨五入到小數 2 位):

  region  daily_earnings  region_code
0      C          139.02            3
1      B          336.32            2
2      C          131.89            3
3      B          288.55            2
4      A          126.56            1
5      C          186.57            3

1、2、3 就像球衣號碼:3 號球員不會比 2 號「多」一點什麼,號碼只是用來分辨是誰。問題是,模型拿到的是 int64 的整數,它不知道這些數字只是代號。

本頁的模擬資料:收入不是照 1 → 2 → 3 增加

地區代號司機人數真正的平均收入
A 區1836149.9
B 區2678302.6
C 區3486179.9

收入最高的是 B(代號 2),不是代號最大的 C。這份資料是照這個設定模擬的(每人的收入=該區的基準 150/300/180,再加上平均 0、標準差 40 的隨機起伏),§3 會逐行拆開造資料的程式。

為什麼不用第 41 題那份 CSV?

那份 driver_daily_stats.csv 是為了重現第 41 題的 describe 數字而造的,地區和收入沒有關係:實測三區平均是 A 233.1、B 214.5、C 218.9,差不多一樣高。這樣不管用什麼模型都看不出「C 區被高估」,所以第 42 題另外模擬一份「三區收入明顯不同」的資料。

  • 對照表漏了一區:.map({'A': 1, 'B': 2}) 不會報錯,C 區 486 列全變成 NaN(空值),整欄還被改成小數 float64。之後丟進模型才會出錯。

觀念 3|線性迴歸只能畫一條直線:代號每加 1,收入就固定加一樣多

線性迴歸假設:收入 = 截距 + 斜率 × 代號。fit 的工作就是找出一組截距和斜率,讓這條直線離 2000 個點「整體最近」(每個點和直線的上下距離平方加起來最小,叫做最小平方法least squares:把每個點的誤差平方後加總,找讓總和最小的直線。誤差平方讓大錯誤被罰得更重。)。實測學到:

收入 = 157.90 + 27.97 × 代號 代號 1(A 區)→ 157.90 + 27.97 × 1 = 185.9 真正 149.9 多猜 36.0 代號 2(B 區)→ 157.90 + 27.97 × 2 = 213.8 真正 302.6 少猜 88.8 代號 3(C 區)→ 157.90 + 27.97 × 3 = 241.8 真正 179.9 多猜 61.9 ← 題目說的「C 區被高估」

為什麼會這樣?直線只有一個斜率,它想同時靠近 A(150)和 B(300),就被拉成往上斜;可是 C 排在 B 後面(代號 3),直線只能繼續往上走,C 就被抬高了。直線沒辦法「先上去再下來」,因為它把 1、2、3 當成了有大小、而且等距的數字。

換個編碼順序,答案就變了:這就是「誤解為大小關係」

同一份資料,只是把號碼換成 A→1、C→2、B→3(剛好照收入由低到高),實測線性迴歸變成:斜率 75.16,預測 A 139.7、C 214.9、B 290.0。誤差小了一些,但 C 區還是差了 35。同樣三個地區,號碼怎麼取,模型的答案就跟著變,可是號碼本來應該只是名字。

這兩組數字都可以在 §4 的「編碼實驗」自己切換看看。

觀念 4|決策樹與隨機森林:不畫線,而是問問題把資料切開

決策樹的做法完全不同:它不假設任何直線,而是一直問「代號 ≤ 某個數嗎?」把資料切成好幾堆,每一堆各自取平均當預測。隨機森林 fit 完,第一棵樹實測長這樣(用 sklearn.tree.export_text 印出來):

|--- region_code <= 1.5
|   |--- value: [149.4]          ← 代號 1(A 區)這一堆,平均 149.4
|--- region_code >  1.5
|   |--- region_code <= 2.5
|   |   |--- value: [302.1]      ← 代號 2(B 區)
|   |--- region_code >  2.5
|   |   |--- value: [179.9]      ← 代號 3(C 區)

它只問兩個問題:「≤ 1.5?」「≤ 2.5?」,就把三區完全分開。它不在乎 3 比 2 大,只在乎「是不是同一區」,所以 C 區就預測 C 區自己的平均。

森林=100 棵樹投票(迴歸是取平均)

RandomForestRegressor(n_estimators=100) 會做出 100 棵樹。每棵樹拿到的資料是從 2000 列裡隨機抽、可以重複抽的 2000 列(叫 bootstrap自助抽樣:從原本的資料裡抽一樣多筆,每次抽完放回去,所以有些列會被抽到好幾次、有些一次都沒有。),所以每棵樹算出的平均都有一點不同。實測前 5 棵樹對 C 區的預測是 179.9、179.7、179.7、180.5、177.3;森林的預測就是 100 棵的平均:

rf.predict(代號 1、2、3) → 150.0、302.5、179.7 ← 真正平均 149.9、302.6、179.9 100 棵樹的深度 → 全部都是 2 層、3 片葉子 森林預測 = 100 棵樹預測的平均? → True(實測逐筆比對)

只用一棵決策樹(DecisionTreeRegressor,不抽樣)也一樣:實測預測 149.9、302.6、179.9,正好是三區的平均。第 42 題要的是「從演算法層面」避免誤解大小,這類靠切分、不假設直線的樹模型就是答案的方向。

比喻和真實機制對照

比喻:線性迴歸像用一把直尺去貼三個高度不同的點,尺只能斜著放;決策樹像分類收納,把同一區的人放進同一格抽屜,各算各的。真實機制:LinearRegression 物件身上只存兩個數字(coef_ 和 intercept_);RandomForestRegressor 物件身上存了一個 list(estimators_),裡面是 100 個照 DecisionTreeRegressor 設計圖做出來的樹物件,每棵樹記著自己的切點和每片葉子的平均。

看動畫 1 之前:這支要回答什麼

觀念 2~4 說到:region 被編成 1、2、3 以後,線性迴歸只能畫一條直線,決策樹則是用問問題把資料切開。動畫 1 用同一份 2000 位司機的模擬資料,把兩種模型各跑一次,看誰把 C 區猜錯、錯多少,最後把四個選項一個一個驗證。

帶著三個問題看:① 線性迴歸學到的斜率 27.97 是什麼意思?代入 3 為什麼會得到 241.8?② 隨機森林 fit 之後身上多了什麼?第一棵樹問了哪兩個問題?③ Ridge 和線性核 SVM 為什麼救不了 C 區?

看的時候注意顏色:A 區藍色、B 區紅色、C 區綠色;淺綠色的方框是物件,裡面黃色虛線小卡寫著它是照哪張設計圖(類別)做的。

動畫 11、2、3 只是代號:線性迴歸 vs 隨機森林2 分 23 秒・有旁白,也有字幕

資料是本頁自己模擬的(第 41 題那份 CSV 的收入和地區沒有關係,示範不出這題的現象,原因在觀念 2)。斜率、截距、預測值、樹的切點、記憶體位址,都取自一次真實執行(Python 3.13.7、scikit-learn 1.8.0);位址每次執行都不一樣,這是正常的。

看完動畫 1,你應該能回答:斜率 27.97 是什麼意思?
線性迴歸認為「代號每加 1,收入就多 27.97 元」。可是代號只是 A、B、C 的編號,從 B(2)到 C(3)真正的收入其實是少了 122 元,這條直線沒辦法表達。
C 區為什麼被高估 62?
把 3 代入直線:157.9 + 3 × 27.97 ≈ 241.8;C 區真正的平均是 179.9,多了約 62。直線被 A(約 150)和 B(約 303)拉成往上斜,C 排在 B 後面,就被抬得更高。
隨機森林 fit 之後,身上多了什麼?
estimators_:一個 list,裡面有 100 個照 DecisionTreeRegressor 設計圖做出來的樹物件。實測每一棵都只有 2 層、3 片葉子,問的都是「代號 ≤ 1.5?」「代號 ≤ 2.5?」。
Ridge 和線性核 SVM 為什麼救不了?
它們仍然是「代號 × 一個係數」的直線,只是學係數的方式不同(Ridge 加了正則化)。實測 Ridge 預測和線性迴歸一樣是 185.9、213.8、241.8;線性核 SVM 是 170.1、195.3、220.5,C 區還是高估 40 以上。

觀念 5|分類與混淆矩陣:抓外掛是「二元分類」,每個人只有 0 或 1

第 42 題預測的是收入(一個數字),叫迴歸。第 43、44 題要判斷的是「這位司機是不是外掛」,答案只有兩種,叫二元分類。習慣上把要抓的那一類記成 1(外掛),其他記成 0(正常)。

第 41 題那份資料裡,有一位司機(第 1521 列,D1522)的 gps_ping_rate 被改成 6.4,其他 1999 位都在 0.74~1.5 之間。用一行程式把他標出來:

is_cheat = (df['gps_ping_rate'] > 2).astype(int)    # 外掛 1 位、正常 1999 位

判斷一個分類模型好不好,最基本的工具是混淆矩陣:把「真正是什麼」和「模型說是什麼」交叉成 4 格。以一個最偷懶的模型為例,它不管是誰一律說「正常」(程式寫成 guess = [0] * len(df)):

模型說:正常(0)
模型說:外掛(1)
真的正常(0)
1999說對了(TN)
0
誤報(FP)
真的外掛(1)
1漏抓(FN)
0
抓到(TP)

實測 confusion_matrix(is_cheat, guess) 交回 [[1999, 0], [1, 0]]:第一列是真的正常、第二列是真的外掛;左欄是模型說正常、右欄是模型說外掛。TP、FP、FN、TN 是英文縮寫:T/F 是「說對/說錯」,P/N 是「模型說外掛/說正常」。

四個常用分數,全部從這 4 格算出來

分數在問什麼算法偷懶模型
準確率 Accuracy全部的人裡,說對幾成?(TP+TN) ÷ 全部1999 ÷ 2000 = 0.9995
召回率 Recall真正的外掛裡,抓到幾成?TP ÷ (TP+FN)0 ÷ 1 = 0
精確率 Precision被說成外掛的人裡,真的是外掛的有幾成?TP ÷ (TP+FP)0 ÷ 0:沒有定義
特異度 Specificity正常司機裡,被說成正常的有幾成?TN ÷ (TN+FP)1999 ÷ 1999 = 1

這就是第 43 題的核心:一個外掛都沒抓到的模型,準確率卻是 0.9995(99.95%),和那位新進資料科學家報告的數字一模一樣。外掛只有 0.05% 時,準確率幾乎只反映「正常司機有沒有說對」,完全看不出外掛有沒有被抓到。要知道外掛抓到幾個,得看召回率。

  • precision 的分母是 0(模型一個外掛都沒說)時,scikit-learn 會跳警告,實測 UndefinedMetricWarning: Precision is ill-defined and being set to 0.0 due to no predicted samples. Use `zero_division` parameter to control this behavior.,並把它當成 0.0。加上 zero_division=0 就是明白告訴它「0 除以 0 就算 0」,警告就不會出現。

觀念 6|classification_report:一次印出每一類的 precision、recall、f1 和 support

classification_report(正確答案, 預測) 是一台機器,交回一個字串(實測 type 是 str),印出來是一張排好的表。偷懶模型的報告(實測):

              precision    recall  f1-score   support

           0       1.00      1.00      1.00      1999
           1       0.00      0.00      0.00         1

    accuracy                           1.00      2000
   macro avg       0.50      0.50      0.50      2000
weighted avg       1.00      1.00      1.00      2000
位置意思這張報告
0、1 兩列把每一類各當一次「要抓的那一類」,各算一組分數0(正常)全對;1(外掛)全是 0
precision、recall觀念 5 的精確率、召回率外掛那一列 recall 0.00:一個都沒抓到
f1-scoreprecision 和 recall 的調和平均2 × P × R ÷ (P+R)。兩個數字只要有一個很低,f1 就會很低;兩個都高,f1 才高。:兩個都要高才會高外掛那一列 0.00
support這一類實際有幾筆正常 1999、外掛 1
accuracy準確率,只有一個數字,所以只填在 f1-score 那欄0.9995 → 印成 1.00
macro avg兩類的分數直接平均(每類一樣重要)(1.00+0.00) ÷ 2 = 0.50
weighted avg照 support 加權平均(人多的類佔比重)幾乎全是正常司機的分數 → 1.00

兩個容易看走眼的地方:① accuracy 印成 1.00:報告預設 digits=2,只印到小數 2 位,0.9995 四捨五入就是 1.00;改成 digits=4 實測會印出 0.9995。② weighted avg 也是 1.00:因為正常司機有 1999 位、外掛只有 1 位,加權平均幾乎被正常司機決定。只看最下面兩行,會以為模型很完美。

回頭看考卷那張圖:全部 1.00,而且沒有 support 欄

考卷的圖每一格都是 1.00,連外掛那一列也是。在第 41 題那份資料上,用一條規則「gps_ping_rate > 2 就是外掛」,就能做出一模一樣的報告(實測,加上 support 欄):

              precision    recall  f1-score   support

           0       1.00      1.00      1.00      1999
           1       1.00      1.00      1.00         1

    accuracy                           1.00      2000
   macro avg       1.00      1.00      1.00      2000
weighted avg       1.00      1.00      1.00      2000

關鍵在圖上被拿掉的那一欄:外掛那一列的 support 只有 1。「1 個外掛、抓到 1 個」就是 1.00,可是只憑 1 個樣本,說明不了模型遇到下一個外掛時抓不抓得到。資深工程師會想看的,正是 support、測試資料怎麼切、混淆矩陣這些圖上沒有的資訊。

看動畫 2 之前:這支要回答什麼

觀念 5、6 介紹了混淆矩陣、準確率、召回率和 classification_report。動畫 2 用第 41 題那份資料(2000 位司機、1 位外掛),先做一個「全部說正常」的偷懶模型,看它怎麼拿到 99.95%;再回頭看考卷那張全是 1.00 的圖,找出圖上看不到的關鍵。

帶著三個問題看:① 一個外掛都沒抓到,準確率為什麼還有 0.9995?② 報告的 accuracy 那一格為什麼顯示 1.00?③ 考卷那張圖少了哪一欄?少了它為什麼看不出問題?

看的時候注意:2000 個小格子就是 2000 位司機,紅色那一格是唯一的外掛(標籤 1),其他都是正常司機(標籤 0)。

動畫 299.95% 的陷阱:準確率、召回率與 support2 分 12 秒・有旁白,也有字幕

報告文字是 scikit-learn 1.8.0 實際印出來的(zero_division=0)。「gps_ping_rate 大於 2 就是外掛」這條規則,在第 41 題那份資料上剛好能把唯一的外掛分出來,所以報告全是 1.00,用來重現考卷那張圖;它不代表真實世界的外掛這麼好抓。

看完動畫 2,你應該能回答:全部猜「正常」,準確率為什麼有 0.9995?
2000 人裡只有 1 個外掛。全部猜正常,猜對的是那 1999 位正常司機:1999 ÷ 2000 = 0.9995。一個外掛都沒抓到,準確率卻幾乎滿分。
這個偷懶模型的 recall 是多少?
0。recall(召回率)問的是「真正的外掛裡,抓到幾個」:唯一的那 1 位沒抓到,0 ÷ 1 = 0。
報告的 accuracy 為什麼顯示 1.00?
classification_report 預設 digits=2,只印到小數點後 2 位,0.9995 四捨五入就是 1.00。改成 digits=4 會印出 0.9995。
考卷那張圖少了哪一欄?為什麼重要?
support(每一類有幾筆)。外掛那一列的 support 如果只有 1,「1 個抓到 1 個」就是 1.00,這種滿分說明不了模型真的會抓外掛。

觀念 7|機率與門檻:模型先算「有多像外掛」,再用門檻做決定

分類模型(例如 LogisticRegression)其實不是直接說「是」或「不是」。它先對每一筆算出一個機率,再拿機率和門檻比較。用 predict_proba 可以把機率拿出來:

P = clf.predict_proba(X_test)     # NumPy 陣列,形狀 (60000, 2)
print(P[:3].round(4))             # 前 3 筆,四捨五入到小數 4 位
[[1.    0.   ]
 [0.996 0.004]
 [1.    0.   ]]
  • 每一筆有兩個機率:第 0 欄「是 0(正常)」、第 1 欄「是 1(外掛)」,兩個加起來是 1(實測 6 萬筆全部成立)。欄的順序照 clf.classes_,實測是 [0 1]。
  • P[:, 1]:冒號是「每一列都要」,1 是「第 1 欄」,取出 6 萬個「是外掛的機率」,形狀 (60000,)。
  • proba >= 0.5:每個機率和 0.5 比,交回 6 萬個 True/False;.astype(int) 再把 True 變 1、False 變 0,這就是「判成外掛」的名單。

平常寫的 clf.predict(X_test),就是門檻 0.5 的結果:實測 predict 和 (proba >= 0.5) 逐筆完全相同。0.5 只是預設值,模型本身並沒有規定一定要用 0.5。

門檻往下調會怎樣?

門檻越低,越多人會被判成外掛:真的外掛抓到更多(recall 上升),但也會把更多正常司機誤判(precision 下降)。本頁 6 萬筆測試資料(裡面 30 位外掛)的實測:

門檻抓到外掛放過外掛誤判正常司機要人工覆核recallprecision
0.5(預設)11190110.3671.000
0.119112210.6330.905
0.012822122400.9330.117
0.00373008598891.0000.034

營運長說「不能放過任何一個」:漏抓的代價最大,誤凍結可以靠人工覆核補救。所以要選讓 recall 最大的門檻,也就是往下調。§4 有門檻滑桿和動畫 3,可以自己拖拖看。

把 7 個觀念合起來:程式跑完,記憶體裡有什麼

① 類別(設計圖)LinearRegression、RandomForestRegressor、LogisticRegression 在 scikit-learn 的子工具箱裡
→
② 建立物件(照設計圖做)LinearRegression() 做出 lin;fit 在物件身上加上 coef_、estimators_、classes_
→
③ 叫物件做事.predict() 交回預測;.predict_proba() 交回機率,再用門檻做決定
§3 那支程式跑完之後(只列這頁的主角): 名牌 置物櫃裡的物件 lin ───→ LinearRegression 物件:coef_ → array([27.97])、intercept_ → 157.90 rf ───→ RandomForestRegressor 物件:estimators_ ───→ list ───→ 100 個 DecisionTreeRegressor 物件 (每棵 2 層:≤ 1.5?≤ 2.5?) clf ───→ LogisticRegression 物件:coef_ → [[14.393, 0.247]]、intercept_ → [-29.041]、classes_ → [0 1] proba ───→ NumPy 陣列:60000 個「是外掛的機率」 pred ───→ NumPy 陣列:60000 個 0/1(迴圈最後一圈,門檻 0.0037 的結果)

三個模型物件各自是照自己的設計圖做的;同樣叫 fit,它們學到、存下來的東西完全不同。這就是物件導向的好處:用的方法名稱一樣,裡面怎麼做由各自的設計圖決定。

§2

認識這次登場的框架:scikit-learn 的模型與評分工具

scikit-learn
Python 最常用的機器學習套件(本頁 1.8.0)

安裝時叫 scikit-learn(pip install scikit-learn;Google Colab、Anaconda 已經裝好),程式裡寫成 sklearn。2007 年開始的開源專案,迴歸、分類、分群、前處理、評分都有。

它最大的特色是統一介面:不管哪種模型,都是「做物件 → fit → predict」。

sklearn.metrics
評分工具:拿正確答案和預測打分數

本頁用到 accuracy_score、recall_score、precision_score、classification_report、confusion_matrix。它們都是機器(函式),不是設計圖:不用先做物件,直接把 (正確答案, 預測) 丟進去。

參數順序很重要:正確答案在前、預測在後,寫反了 precision 和 recall 會互換。

三份資料
第 42、44 題程式模擬;第 43 題用第 41 題的 CSV

第 42 題:2000 位司機 × 地區、收入(程式裡現造,叫 sim)。第 43 題:driver_daily_stats.csv,2000 列 × 5 欄,1 位外掛。第 44 題:20 萬筆 × 2 個特徵(gps_ping_rate、當天趟數),外掛 100 筆。

模擬資料都用固定的亂數種子,每次執行結果完全相同。

第 42 題的四個選項:先分清楚「迴歸」和「分類」,再分清楚「直線」和「切分」

題目要預測的是 收入(一個連續的數字),所以替代模型必須是迴歸模型。再來看它怎麼使用代號 1、2、3:

選項scikit-learn 的設計圖迴歸還是分類怎麼用代號實測預測 A、B、C(真正 149.9、302.6、179.9)
ALogisticRegression分類—不能拿來預測收入:fit 直接報錯
BRidge迴歸收入 = 截距 + 斜率 × 代號(直線)185.9、213.8、241.8(和線性迴歸一樣)
CSVR(kernel='linear', C=100)迴歸(SVM 的迴歸版)也是一條直線170.1、195.3、220.5
DRandomForestRegressor迴歸問「代號 ≤ 1.5?≤ 2.5?」切開150.0、302.5、179.7
  • 羅吉斯迴歸名字有「迴歸」,其實是分類模型:它輸出的是「屬於某一類的機率」。拿收入這種連續數字去 fit,實測 ValueError: Unknown label type: continuous. Maybe you are trying to fit a classifier, which expects discrete classes on a regression target with continuous values.
  • Ridge 是「線性迴歸+正則化」:在找直線時,額外懲罰太大的斜率(懲罰的力道叫 alpha,預設 1.0)。這份資料有 2000 筆,alpha=1 的懲罰幾乎沒有作用;把 alpha 調到 1000,實測預測變成 196.1、211.7、227.3:直線變平了,但 C 區還是高估 47,B 區低估得更多。正則化只能讓直線變平,不能讓它轉彎。
  • 線性核的 SVM 用另一種方式找直線(只在乎誤差超過某個寬度的點),實測 C 區高估 40.6;用預設的 C=1 也差不多(171.2、195.1、219.1)。核函數換成非線性的 'rbf' 才會彎:實測 SVR(kernel='rbf', C=100) 預測 149.3、302.8、180.6。但題目指定的是線性核。
  • 隨機森林裡的每棵樹只問「≤ 某個切點嗎」,代號的大小順序完全不影響它最後把哪些列分在一起。
樹模型也不是完全不看大小

嚴格說,決策樹的問題是「代號 ≤ 1.5?」,它仍然用到了大小順序:一次切分只能把「小的那一邊」和「大的那一邊」分開。所以如果樹被限制得很淺,順序還是會有影響:實測 max_depth=1(只能問一個問題)的森林預測 150.0、251.1、251.1,B 和 C 被綁在一起。只要允許問兩個問題(預設沒有深度限制),三區就能完全分開。地區只有 3 個時這不是問題;類別很多時,常見的做法還是改用 One-Hot 編碼(第 42 題規定不能改,所以才換模型)。

如果可以改編碼:One-Hot(每一區一欄 0/1)

pd.get_dummies(sim['region'], dtype=int) 把一欄 region 變成 A、B、C 三欄:A 區司機那一列是 1、0、0,B 區是 0、1、0,C 區是 0、0、1。這樣每一區有自己的係數,沒有誰比誰大的問題。實測線性迴歸學到 截距 210.8、係數 A −60.9、B 91.78、C −30.88,預測 A 149.9、B 302.6、C 179.9,三區都猜中平均。§6 程式 2 有完整程式。

第 43 題選項 C:分類模型和「分群/離群值方法」差在哪

做法需要什麼scikit-learn 的例子在本頁資料上實測
監督式:分類模型要有標註:每位司機已知是不是外掛(0/1)LogisticRegression、RandomForestClassifier第 44 題:LogisticRegression 用 14 萬筆有標註的資料訓練,門檻調好後抓到 30 位中的 30 位
非監督式:離群值偵測、分群不需要標註,只看資料「長得怪不怪」IsolationForest(sklearn.ensemble)、KMeans、DBSCAN(sklearn.cluster)IsolationForest(contamination=0.0005, random_state=0) 看 gps_ping_rate,2000 位裡只挑出 1 位:正是 D1522

兩種方法都能用來抓外掛,差在手上有沒有標註。有可靠的標註(例如過去人工查核過的外掛名單),就能訓練分類模型;沒有標註時,才只能靠離群值或分群。選項 C 說異常偵測「不可使用分類模型、僅能用分群或離群值方法」,說得太絕對,是錯的。

四個分數一起看:同一個模型,換門檻就換一組成績

觀念 5 的四個分數都來自混淆矩陣。第 44 題 6 萬筆測試資料(30 位外掛、59,970 位正常)在兩個門檻的實測:

分數算法門檻 0.5門檻 0.0037白話
Accuracy(TP+TN) ÷ 全部0.999680.98568反而下降:多抓的外掛只有 19 位,多誤判的正常司機有 859 位
RecallTP ÷ (TP+FN)11 ÷ 30 = 0.36730 ÷ 30 = 1.000營運長最在乎的:外掛有沒有漏掉
PrecisionTP ÷ (TP+FP)11 ÷ 11 = 1.00030 ÷ 889 = 0.034被凍結的人裡,真的是外掛的比例
SpecificityTN ÷ (TN+FP)1.0000.9857正常司機沒被誤判的比例

如果什麼都不做、全部猜正常,這 6 萬筆的準確率是 0.9995,比門檻 0.0037 的 0.98568 還高。準確率越高不代表越符合需求,要先問清楚「漏抓」和「誤報」哪一個代價大。

深入一點:scikit-learn 沒有 specificity_score(可以先跳過)

特異度就是「把正常(0)當成要抓的那一類」時的召回率,所以可以寫成 recall_score(y_test, pred, pos_label=0)。實測:門檻 0.5、0.2 都是 1.0,門檻 0.1 是 0.999967(誤判 2 位),門檻 0.0037 是 0.985676,和自己用混淆矩陣算的一樣。

門檻放在哪裡:predict 沒有門檻參數

想改門檻,不能寫 clf.predict(X_test, threshold=0.1),實測 TypeError: LinearClassifierMixin.predict() got an unexpected keyword argument 'threshold'(錯誤訊息裡的 LinearClassifierMixin,就是 LogisticRegression 家譜裡寫 predict 的那一層)。常見的兩種寫法:

  • 自己比:(clf.predict_proba(X_test)[:, 1] >= 0.0037).astype(int),本頁 §3 用的就是這種,最容易看懂。
  • 包進模型:scikit-learn 1.5 起有 FixedThresholdClassifier(在 sklearn.model_selection),把模型和門檻包成一個新的模型物件。實測 FixedThresholdClassifier(LogisticRegression(max_iter=1000), threshold=0.0037, response_method='predict_proba') fit 之後 predict,判成外掛 889 筆,和自己比的結果逐筆相同(§6 程式 3)。

不管哪一種,調門檻都不會改到模型本身:實測調門檻前後,clf.coef_ 一直是 [[14.393, 0.247]]。門檻只是「機率算出來以後,在哪裡切一刀」。選項 C 說「調整門檻將破壞演算法原有的訓練平衡」是錯的。

§3

逐行解剖

考卷這三題沒有程式碼。左邊是本頁把三題情境寫成的完整程式(§6 可以複製、下載資料自己跑):第 1~24 行是第 42 題、第 26~32 行是第 43 題、第 34~51 行是第 44 題。點任一行可以跳到解說;右邊往下捲時,左邊會自動標出目前講到哪一行。手機上會改成上下排列。

q42_44.py51 行

把 NumPy(陣列和亂數)、pandas(表格)兩個套件載入記憶體,分別用短名字 np、pd 叫它們。

import numpy as np
套件NumPy:處理一大串數字的工具箱。這支程式用它造亂數(np.random)、做陣列(np.zeros、np.where)。
import pandas as pd
套件pandas:表格工具箱。這支程式用它做表格(pd.DataFrame)和讀 CSV(pd.read_csv)。

import 的四個步驟(查已載入清單 → 執行套件的程式碼 → 登記 → 貼名牌)和常見錯誤,第 41 題的 逐行解剖講得很完整。scikit-learn 本身也是建立在 NumPy 上:模型交回的預測、機率,都是 NumPy 陣列。

scikit-learn 很大,分成很多子工具箱。from 子工具箱 import 名字 會先把 sklearn 和那個子工具箱載入記憶體,然後只把指定的名字貼到這支程式裡,之後可以直接寫 LinearRegression,不用寫一長串 sklearn.linear_model.LinearRegression。

from sklearn.linear_model
子工具箱線性模型:直線類的模型都放這裡(LinearRegression、Ridge、LogisticRegression…)。
import A, B
關鍵字一次拿好幾個名字,用逗號隔開。
行拿出的名字種類這支程式拿它做什麼
3LinearRegression設計圖第 42 題:線性迴歸(第 17 行)
3LogisticRegression設計圖第 44 題:外掛分類模型(第 44 行)
4RandomForestRegressor設計圖第 42 題:隨機森林(第 18 行)。sklearn.ensemble 是「集成」:很多模型合起來用
5accuracy_score、classification_report機器(函式)第 43 題:準確率、報告(第 31、32 行)
5recall_score、precision_score機器(函式)第 44 題:每個門檻的 recall、precision(第 51 行)
6train_test_split機器(函式)第 44 題:切訓練/測試資料(第 42 行)
  • 套件名稱寫成安裝時的名字:import scikit-learn 實測 SyntaxError: invalid syntax(名字裡不能有減號),程式裡一律寫 sklearn。
  • 大小寫打錯:from sklearn.linear_model import linearregression,實測 ImportError: cannot import name 'linearregression' from 'sklearn.linear_model'。

# 開頭的兩行是註解,Python 不會執行,只是寫給人看的:說明下面在模擬什麼資料。第 10 行做出一個亂數產生器物件,貼上名牌 rng,後面要的亂數都跟它要。

np.random.default_rng
函式NumPy 建議的亂數入口,交回一個 Generator 物件(實測 numpy.random._generator.Generator)。
42
位置引數參數 seed(種子)。種子一樣,產生的亂數就一模一樣,所以本頁每個數字你都能重跑出來。預設 None:每次都不一樣。

種子 42 沒有特別意義,只是習慣。第 36 行會用種子 44 再做一台新的,名牌 rng 就改指新的那台(舊的那台沒有名牌了,會被回收)。

從 A、B、C 裡隨機抽 2000 次(抽到 A、B、C 的機率是 42%、35%、23%),把這 2000 個字母放進一張表格的 region 欄,貼上名牌 sim(simulation,模擬)。

rng.choice(…)
方法Generator 的方法:從清單裡隨機抽。交回 NumPy 陣列,實測 dtype <U1(長度 1 的文字)、形狀 (2000,),前 10 個是 C B C B A C B C A B。
{'region': …}
字典「欄名: 一整欄的值」。pd.DataFrame(字典) 照 DataFrame 設計圖做出表格:一個鍵就是一欄。

Generator.choice 參數表

簽名(NumPy 2.4.3):choice(a, size=None, replace=True, p=None, axis=0, shuffle=True)

參數這行的值預設值白話
a['A', 'B', 'C'](必填)從哪裡抽。給一個整數 n 就是從 0~n−1 抽(第 38 行就是這樣用)
size2000None抽幾次。None 只抽一個
replace—True抽完放回去,可以重複抽到同一個(這裡當然要能重複)
p[.42, .35, .23]None(每個一樣機率)每個選項被抽到的機率,加起來必須是 1
實際抽到:A 836 位、B 678 位、C 486 位(不會剛好 42%/35%/23%,因為是隨機的) sim.shape → (2000, 1)
  • 機率加起來不是 1:p=[.4, .35, .23](總和 0.98),實測 ValueError: Probabilities do not sum to 1. See Notes section of docstring for more information.

先用 map 把每位司機的地區換成那一區的基準收入(A 150、B 300、C 180),再加上 2000 個隨機起伏,存成新的一欄 daily_earnings。

sim['region'].map({…})
方法Series 的方法:照對照表一格一格換。交回新的 Series(實測 int64)。
rng.normal(0, 40, 2000)
方法常態分布亂數:平均 loc=0、標準差 scale=40、size=2000 個。交回 NumPy 陣列(float64)。實測前 3 個是 −40.981、36.323、−48.108。
+
運算Series 加陣列:第 1 個加第 1 個、第 2 個加第 2 個……,交回新的 Series(float64)。
sim['daily_earnings'] =
新增一欄方括號裡是還不存在的欄名,等號就是「把右邊這一欄放進表格」。

常態分布的起伏大約有三分之二落在 ±40 之內,所以 A 區司機的收入多半在 110~190 之間,B 區在 260~340 之間。rng 是第 10、11 行那同一台產生器,接著第 11 行用剩的亂數繼續產生,所以結果固定。

再用一次 map,把 A、B、C 換成 1、2、3,存成新的一欄 region_code。這就是題目說的「標籤編碼」,而且主管規定不能改。

{'A': 1, 'B': 2, 'C': 3}
對照表字典:左邊是原本的值、右邊是換成什麼。
# 標籤編碼(不能改)
註解# 後面到行尾都是註解,程式碼後面也可以加。
執行後的 sim(前 3 列): region daily_earnings region_code 0 C 139.02 3 1 B 336.32 2 2 C 131.89 3 sim.dtypes → region object(pandas 3.0.6 是 str)、daily_earnings float64、region_code int64

region_code 是 int64:在模型眼裡,它就是一個可以加減乘除的數字,3 比 2 大 1、2 比 1 大 1。觀念 3 已經看到,這會讓線性迴歸把 C 區抬高。

  • 對照表漏了一區(例如少了 C):不會報錯,C 區 486 列全變成 NaN,整欄變成 float64。

scikit-learn 的習慣:X 放「拿來猜的資料」(特徵),y 放「要猜的答案」(目標)。X 用兩層方括號,拿到的是只有一欄的表格;y 用一層,拿到的是一欄 Series。

sim[['region_code']]
DataFrame外層方括號是「跟 sim 要東西」,裡面那層是清單:要一張只有這一欄的表格。實測形狀 (2000, 1)。
sim['daily_earnings']
Series一層方括號:要一欄。實測形狀 (2000,)。

為什麼 X 要是表格?因為 X 可以有很多欄(很多特徵),scikit-learn 規定 X 一定是「列 × 欄」的二維資料,即使只有一欄也一樣;y 是每一列一個答案,所以是一維。

  • X 只用一層方括號:LinearRegression().fit(sim['region_code'], y),實測 ValueError: Expected a 2-dimensional container but got <class 'pandas.core.series.Series'> instead. Pass a DataFrame containing a single row (i.e. single sample) or a single column (i.e. single feature) instead.

先照 LinearRegression 設計圖做出一個空的模型物件,馬上叫它 fit(用 X 和 y 找出最好的直線);fit 交回的就是那個物件自己,最後貼上名牌 lin。

LinearRegression()
類別設計圖加括號 = 做出一個物件。括號裡沒寫,全部用預設值。
.fit(X, y)
方法學習:找出讓誤差平方和最小的截距和斜率,存在物件身上,再交回物件自己(實測 lin.fit(X, y) is lin 是 True)。
lin =
名牌右邊全部做完,名牌貼在那個已經學好的物件上。

LinearRegression 完整參數表

簽名(scikit-learn 1.8.0):LinearRegression(*, fit_intercept=True, copy_X=True, tol=1e-06, n_jobs=None, positive=False)。開頭的 * 代表全部參數都只能用「名字=值」寫。

參數預設值白話+實測
fit_interceptTrue要不要截距。False 時直線被迫通過原點:實測斜率變成 100.72,預測 A 100.7、B 201.4、C 302.2,錯得更離譜
copy_XTrue計算前先複製一份 X,不會改到你的資料
tol1e-06只在資料是稀疏矩陣、而且用迭代法求解時才用到的精度;本頁用不到
n_jobsNone同時用幾個 CPU 核心;資料小時沒差別
positiveFalseTrue 時強迫所有係數 ≥ 0
fit 之後,lin 身上多了(實測,都以底線結尾): coef_ → array([27.96929924]) ← 斜率:代號每 +1,收入 +27.97 intercept_ → np.float64(157.90396942660698) ← 截距 n_features_in_ → 1 ← fit 時看過幾個特徵 feature_names_in_ → array(['region_code'], dtype=object) ← 特徵的名字(因為 X 是有欄名的表格) rank_、singular_ → 解方程式時的中間結果,一般用不到
  • 還沒 fit 就問 lin.coef_:AttributeError: 'LinearRegression' object has no attribute 'coef_'。
  • 還沒 fit 就 lin.predict(X):NotFittedError: This LinearRegression instance is not fitted yet. Call 'fit' with appropriate arguments before using this estimator.
  • X、y 寫反:fit(y, X) 第一個位置要 X(二維),會出現上一張卡的「Expected a 2-dimensional container」錯誤。

照 RandomForestRegressor 設計圖做出一個森林物件(要 100 棵樹、亂數種子 0),馬上 fit:每棵樹各自抽一份資料、各自長成一棵決策樹,全部掛在森林物件身上。

RandomForestRegressor(…)
類別隨機森林的迴歸版(分類版叫 RandomForestClassifier)。實測 print(rf) 是 RandomForestRegressor(random_state=0):n_estimators=100 本來就是預設值,所以不顯示。
n_estimators=100
關鍵字引數種幾棵樹。
random_state=0
關鍵字引數亂數種子,讓每棵樹抽到的資料固定,結果可以重現。

RandomForestRegressor 參數表(本頁相關的部分)

簽名(1.8.0):RandomForestRegressor(n_estimators=100, *, criterion='squared_error', max_depth=None, min_samples_split=2, min_samples_leaf=1, min_weight_fraction_leaf=0.0, max_features=1.0, max_leaf_nodes=None, min_impurity_decrease=0.0, bootstrap=True, oob_score=False, n_jobs=None, random_state=None, verbose=0, warm_start=False, ccp_alpha=0.0, max_samples=None, monotonic_cst=None)

參數這行預設值白話+實測
n_estimators100100樹的數目。實測只種 1 棵:預測 149.4、302.1、179.9(就是第一棵樹)
criterion—'squared_error'怎麼評估一刀切得好不好:切完後兩邊的誤差平方和越小越好
max_depth—None樹最多幾層,None 不限制。實測 max_depth=1(只能問一個問題):預測 150.0、251.1、251.1,B、C 分不開
min_samples_split、min_samples_leaf—2、1一堆至少幾筆才准再切、每片葉子至少幾筆。本題每區都有幾百筆,不受影響
max_features—1.0每次切之前,隨機拿幾成的特徵來挑;1.0 是全部。本題只有一個特徵
bootstrap—True每棵樹用「可以重複抽」的方式抽一份資料。實測改成 False(每棵都用全部資料):預測 149.9、302.6、179.9,正好是三區平均
max_samples—None每棵樹抽幾筆,None 是和原資料一樣多(2000 筆)
oob_score—FalseTrue 時用「沒被抽到的那些列」順便打分數
n_jobs—None同時用幾個 CPU 核心種樹;-1 是全部
random_state0None不給種子,每次結果都有一點點不同:實測連跑兩次是 150.01/302.67/180.07 和 150.1/302.66/179.8
fit 之後,rf 身上多了(實測): estimators_ → list,長度 100 ← 100 棵樹 estimators_[0] → DecisionTreeRegressor(max_features=1.0, random_state=209652396) estimator_ → DecisionTreeRegressor() ← 種樹用的「樣板」 n_features_in_ → 1 feature_names_in_ → array(['region_code'], dtype=object)

森林在 fit 時,照 DecisionTreeRegressor 設計圖做出 100 個樹物件,每棵拿到不同的 random_state(第一棵是 209652396),所以抽到的資料不同。每一棵都只長到 2 層、3 片葉子(實測),第一棵的樣子在觀念 4 看過。

做一張 3 列的小表格,欄名也叫 region_code,三列分別是 1、2、3,代表 A、B、C 三區。等一下拿它問兩個模型「這三區各會賺多少」。

'region_code'
欄名必須和 fit 時 X 的欄名一樣。模型記得 feature_names_in_,會比對。
  • 直接給清單 lin.predict([[3]]):答案一樣是 241.8,但實測會跳 UserWarning: X does not have valid feature names, but LinearRegression was fitted with feature names。fit 時用有欄名的表格,predict 時最好也用。

照地區分組、每組收入取平均,四捨五入到小數 1 位,變成一般的清單印出來,當作標準答案。

印出:各區真正的平均: [149.9, 302.6, 179.9]

groupby 分組、mean 聚合的細節,第 45 題的 觀念 4、5 用收據和籃子講過;.round(1) 四捨五入到 1 位;.tolist() 把 Series 的值變成一般的 Python 清單(印出來比較短)。

同樣三個代號,分別交給線性迴歸和隨機森林預測,四捨五入後印出來比較。

.predict(codes)
方法每一列交回一個預測值,交回 NumPy 陣列。線性迴歸實測 array([185.87326867, 213.84256791, 241.81186715])。
.round(1).tolist()
方法串接陣列的 round 四捨五入到 1 位,再用 tolist 變成清單。
印出: 線性迴歸預測: [185.9, 213.8, 241.8] ← C 區真正 179.9,高估 61.9 隨機森林預測: [150.0, 302.5, 179.7] ← 每一區都差不到 0.3

線性迴歸的 predict 其實就是代公式:截距 + 斜率 × 代號(實測手算 157.904 + 27.969 × 3 = 241.812,和 predict 一樣)。隨機森林的 predict 是:把代號交給 100 棵樹,每棵樹沿著自己的問題走到一片葉子,拿到那片葉子的平均,再把 100 個答案平均(實測和逐棵平均完全相同)。

如果拿沒見過的代號問呢?代號 4:線性迴歸實測說 269.8(直線繼續往上延伸),隨機森林說 179.7(4 > 2.5,走到和 C 區同一片葉子)。

從線性迴歸物件身上拿出 fit 學到的斜率和截距,四捨五入到小數 2 位印出來。

lin.coef_[0]
屬性coef_ 是陣列(每個特徵一個係數),這裡只有一個特徵,用 [0] 拿第一個。
round(…, 2)
內建函式四捨五入到 2 位。實測 round(lin.coef_[0], 2) 是 np.float64(27.97),print 出來是 27.97。
印出:線性迴歸的斜率: 27.97 截距: 157.9

截距本來是 157.90,print 一個小數時,尾巴的 0 不會印出來,所以看到 157.9。

第 26 行是分隔用的註解。第 27 行用 read_csv 讀進第 41 題的模擬資料:2000 位司機、5 欄。

df.shape → (2000, 5) df.columns → driver_id、daily_earnings、region、app_version、gps_ping_rate df['gps_ping_rate'].describe() → 平均 1.003、中位數 1.001、最小 0.736、最大 6.4

gps_ping_rate 幾乎都在 1 附近,只有一位是 6.4:第 1521 列的 D1522(B 區、app 5.2.0)。read_csv 的參數和這份資料怎麼造的,見 第 41 題。

  • CSV 不在執行的資料夾:FileNotFoundError。§6 有下載連結,和程式放在同一個資料夾。

每位司機的 gps_ping_rate 和 2 比大小,得到 2000 個 True/False,再把 True 變 1、False 變 0。這一欄就是「正確答案」:誰是外掛。

df['gps_ping_rate'] > 2
比較Series 和一個數字比,每一格各比一次,交回 bool(True/False)的 Series。實測只有 1 個 True。
( … )
括號先把比較做完,再對結果叫 .astype。沒有括號會變成 2.astype(int),意思完全不同。
.astype(int)
方法換型別:True → 1、False → 0。實測交回 int64 的 Series:0 有 1999 個、1 有 1 個。

真實世界不會有這麼明顯的規則(不然就不用模型了)。這裡只是借第 41 題那位 6.4 的司機,造出「2000 人裡 1 個外掛」的正確答案。

做一個清單,裡面有 2000 個 0。這就是「不管是誰,一律說正常」的預測。

[0]
list只有一個 0 的清單。
* len(df)
清單乘整數把清單重複幾次接起來。實測 [0] * 3 是 [0, 0, 0];len(df) 是表格的列數 2000。

同樣的偷懶模型,scikit-learn 有現成的設計圖:DummyClassifier(strategy='most_frequent')(在 sklearn.dummy)。實測 fit 之後它永遠只說 0,score(準確率)也是 0.9995。動畫 2 用的就是它。這種「什麼都沒學」的模型叫基準線:真正的模型至少要贏過它,才有意義。

先印出外掛有幾位,再用 accuracy_score 算偷懶模型的準確率。

is_cheat.sum()
方法把 0/1 加起來=1 的個數=外掛人數。
accuracy_score(is_cheat, guess)
函式正確答案在前、預測在後。交回 Python 的 float(實測 0.9995)。

accuracy_score 參數表

簽名:accuracy_score(y_true, y_pred, *, normalize=True, sample_weight=None)

參數預設值白話+實測
y_true、y_pred(必填)正確答案、預測。Series、清單、陣列都可以,長度要一樣
normalizeTrueTrue 交回比例;False 交回「說對幾個」,實測 1999.0
sample_weightNone每一筆的權重,None 是每筆一樣重要
印出: 外掛人數: 1 / 2000 Accuracy: 0.9995

這就是第 43 題報告裡的 99.95%。選項 A 說的「即使模型永遠預測所有司機皆為正常,整體準確率仍可能高達 99.95%」,這兩行程式直接證明了。

把正確答案和預測交給 classification_report,交回一整張排好的報告(字串),印出來。

classification_report 完整參數表

簽名:classification_report(y_true, y_pred, *, labels=None, target_names=None, sample_weight=None, digits=2, output_dict=False, zero_division='warn')

參數這行預設值白話+實測
labels—None要列出哪幾類;None 是資料裡出現的全部(這裡是 0、1)
target_names—None每一類顯示的名字。實測給 ['正常', '外掛'],最左欄的 0、1 就換成中文
digits—2印到小數幾位。預設 2:0.9995 印成 1.00。實測 digits=4 印出 0.9995
output_dict—FalseTrue 時交回字典,方便程式取用。實測外掛那一類是 {'precision': 0.0, 'recall': 0.0, 'f1-score': 0.0, 'support': 1.0}
zero_division0'warn'遇到 0 除以 0 怎麼辦。預設 'warn':當成 0 並跳警告;寫 0 就安靜地當成 0
印出(實測): precision recall f1-score support 0 1.00 1.00 1.00 1999 1 0.00 0.00 0.00 1 accuracy 1.00 2000 macro avg 0.50 0.50 0.50 2000 weighted avg 1.00 1.00 1.00 2000

每一欄怎麼讀,觀念 6 有完整的表。最重要的兩件事:外掛那一列 recall 是 0.00(一個都沒抓到);support 欄寫著外掛只有 1 位。

  • 不寫 zero_division:結果一樣,但實測會跳 3 次 UndefinedMetricWarning: Precision is ill-defined and being set to 0.0 in labels with no predicted samples. Use `zero_division` parameter to control this behavior.
  • 答案和預測寫反:classification_report(guess, is_cheat) 會把 precision 和 recall 互換,數字全錯。

兩行註解說明要模擬什麼;第 36 行用種子 44 做一台新的 Generator,名牌 rng 改指它。

為什麼要模擬一份新資料?第 41 題那份只有 1 個外掛,切成訓練、測試以後,不是訓練裡沒有外掛、就是測試裡沒有外掛,根本沒辦法練習「門檻調到哪裡能全部抓到」。所以這裡造 20 萬筆、外掛 100 筆(一樣是 0.05%),測試資料裡才會有 30 位外掛可以數。

先做 20 萬個 0 的陣列(每個人先當正常),再從 0~199999 裡隨機挑 100 個不重複的位置,把那些位置改成 1(外掛)。

np.zeros(200_000, dtype=int)
函式20 萬個 0,整數型別(實測 int64)。200_000 的底線只是方便閱讀,等於 200000。
rng.choice(200_000, 100, replace=False)
方法從 0~199999 抽 100 個,replace=False 不放回,保證 100 個都不同(實測 100 個不重複,最小的幾個是 3534、3891、10688…)。
cheat[ … ] = 1
花式索引方括號裡放一整串位置,就是「這些位置全部」,一次改成 1。實測 cheat.sum() 是 100。
  • 忘了 replace=False:預設可以重複抽,同一個位置被抽兩次,外掛就會少於 100 個。

每個人兩個特徵:gps(gps_ping_rate,外掛平均 1.40、正常 1.0)、trips(當天跑了幾趟,外掛平均 30、正常 20)。外掛的數字偏高,但兩群有重疊,模型不可能完美分開。

np.where(cheat == 1, A, B)
函式逐格挑:條件是 True 的位置拿 A 的那一格、False 拿 B 的那一格。實測 np.where(np.array([1, 0, 1]) == 1, [10, 20, 30], [-1, -2, -3]) 交回 [10 -2 30]。注意前面要是 NumPy 陣列:一般清單 [1, 0, 1] == 1 不會逐格比,整個只是一個 False,實測會交回 [-1 -2 -3]。
rng.normal(1.40, 0.10, 200_000)
方法先為 20 萬人都產生一個「外掛版」的數字,再產生一個「正常版」的,np.where 每格只留一個。
實測各群平均: gps 外掛 1.402、正常 1.0 trips 外掛 30.45、正常 20.0

把 gps 和 trips 兩串數字左右並排,變成一個二維陣列:每一列是一位司機,第 0 欄 gps、第 1 欄 trips。

X2.shape → (200000, 2) X2[:3] → [[1.038, 15.519], [1.07, 20.736], [1.038, 21.022]](四捨五入到 3 位)

和第 15 行一樣,X 必須是二維的「列 × 欄」。這次用的是 NumPy 陣列,沒有欄名,所以之後的模型身上不會有 feature_names_in_。

把 20 萬筆隨機切成兩份:14 萬筆給模型學(訓練),6 萬筆留著考試(測試)。stratify=cheat 保證兩份裡的外掛比例都和原本一樣。

X_train, X_test, y_train, y_test =
一次貼 4 張名牌函式交回 4 份資料,順序固定是:X 的訓練、X 的測試、y 的訓練、y 的測試。
train_test_split(X2, cheat, …)
函式可以一次切好幾份資料,每份都用同一種切法,所以 X 和 y 的每一列還是對得上。

train_test_split 參數表

簽名:train_test_split(*arrays, test_size=None, train_size=None, random_state=None, shuffle=True, stratify=None)

參數這行預設值白話+實測
*arraysX2, cheat(必填)要切的資料,可以給好幾份;給 2 份就交回 4 份
test_size0.3None測試佔幾成(小數)或幾筆(整數)。兩個 size 都沒給時是 0.25
train_size—None訓練佔幾成;None 就是「剩下的全部」
random_state0None亂數種子,固定切法
shuffle—True切之前先洗牌
stratifycheatNone照這一欄的比例分層切。實測有寫:訓練 70 位外掛、測試 30 位。不寫的話,random_state 0~4 測試裡的外掛分別是 29、28、27、25、24 位,每次不同
交回的 4 份(實測形狀): X_train (140000, 2) X_test (60000, 2) y_train (140000,) y_test (60000,)
  • 名牌數目不對:交回 4 份,只寫 2 張名牌(a, b = train_test_split(X2, cheat, …))實測 ValueError: too many values to unpack (expected 2)。
  • 順序寫錯:寫成 X_train, y_train, X_test, y_test 不會報錯,但名牌全貼錯,後面 fit 會拿到錯的資料。

照 LogisticRegression 設計圖做出一個分類模型物件,用 14 萬筆訓練資料 fit,貼上名牌 clf(classifier,分類器)。

LogisticRegression
類別名字有「迴歸」,其實是分類模型:先算 gps、trips 的加權總分,再用一條 S 形曲線把總分變成 0~1 的機率。
max_iter=1000
關鍵字引數求解時最多反覆修正幾次(預設 100)。實測這份資料 20 次就收斂,寫不寫結果都一樣;保險起見寫大一點。

LogisticRegression 參數表

簽名(1.8.0):LogisticRegression(penalty='deprecated', *, C=1.0, l1_ratio=0.0, dual=False, tol=0.0001, fit_intercept=True, intercept_scaling=1, class_weight=None, random_state=None, solver='lbfgs', max_iter=100, verbose=0, warm_start=False, n_jobs=None)

參數這行預設值白話+實測
penalty—'deprecated'1.8 版起不建議再用:實測寫 penalty='l2' 會跳 FutureWarning: 'penalty' was deprecated in version 1.8 and will be removed in 1.10.,改用下面的 l1_ratio 和 C
C—1.0正則化強度的倒數:越小,係數被壓得越小
l1_ratio—0.0正則化裡 L1 佔幾成;0 就是傳統的 L2
solver—'lbfgs'求解的演算法
max_iter1000100最多反覆幾次。實測設成 5:ConvergenceWarning: lbfgs failed to converge after 5 iteration(s) (status=1): STOP: TOTAL NO. OF ITERATIONS REACHED LIMIT
tol—0.0001每次修正小於這個量,就當作收斂、停下來
fit_intercept—True要不要截距
class_weight—None每一類的重要性。'balanced' 會讓人數少的外掛變得很重要:實測不調門檻、直接 predict 就抓到 28 位、判成外掛 217 筆(recall 0.933)。這是另一種對付不平衡的方法
random_state—None只有部分 solver 會用到亂數;lbfgs 不用
fit 之後,clf 身上多了(實測): classes_ → array([0, 1]) ← 兩類,照大小排;predict_proba 的欄也照這個順序 coef_ → [[14.393, 0.247]] ← gps 每 +1,總分 +14.393;trips 每 +1,總分 +0.247 intercept_ → [-29.041] n_iter_ → [20] ← 實際反覆了 20 次

總分 = −29.041 + 14.393 × gps + 0.247 × trips,機率 = 1 ÷ (1 + e−總分)。總分是 0 時機率剛好 0.5,總分越大越接近 1。

讓模型對 6 萬筆測試資料算機率,交回 6 萬列 × 2 欄的陣列;[:, 1] 只取第 1 欄(是外掛的機率),貼上名牌 proba。

.predict_proba(X_test)
方法交回 NumPy 陣列,實測形狀 (60000, 2)、float64,每列兩個數加起來是 1。欄的順序照 classes_:第 0 欄是 0(正常)、第 1 欄是 1(外掛)。
[:, 1]
二維切片逗號前面管「列」、後面管「欄」。: 是全部的列,1 是第 1 欄。實測交回形狀 (60000,)。
proba 的樣子(實測): 前 5 筆 → 0.00001、0.00396、0.00001、0.00118、0.00006 ← 正常司機的機率都非常小 最大 → 0.97196 最小 → 0.00000001(約 1.02 × 10⁻⁸) 正常司機的中位數 0.000061;99% 的正常司機在 0.0048 以下;最高的那位 0.168 30 位外掛的機率 → 從 0.0037 到 0.972,有 19 位低於 0.5
  • 忘了 [:, 1]:(P >= 0.1).astype(int) 變成 6 萬 × 2 的陣列,拿去算 recall 實測 ValueError: Classification metrics can't handle a mix of binary and multilabel-indicator targets。
  • 寫成 [:, 0]:拿到的是「正常」的機率,門檻判斷就整個反過來。

len(y_test) 是測試資料有幾筆,y_test.sum() 是裡面有幾個 1(外掛)。

印出:測試資料: 60000 筆,外掛 30 筆

第 47 行讓 th 依序是 0.5、0.1、0.01、0.0037,每一個都執行一次縮排裡的程式。第 48 行:機率大於等於門檻的判成 1(外掛),其他判成 0。

for th in [ … ]:
迴圈清單裡有 4 個數,縮排的 4 行(48~51)就跑 4 次,每次 th 換下一個。
proba >= th
比較6 萬個機率逐一和門檻比,交回 6 萬個 True/False(實測 dtype bool;門檻 0.1 時有 21 個 True)。
.astype(int)
方法True → 1、False → 0,交回 int64 陣列,名牌 pred。

門檻 0.5 的 pred,和 clf.predict(X_test) 實測逐筆完全相同:predict 就是「門檻 0.5」的特例。這個迴圈做的,就是把 0.5 換成別的數字。

三段 f 字串會自動接成一行。大括號裡的運算先做完,再照冒號後面的格式排好。

pred[y_test == 1].sum()
布林索引y_test == 1 標出 30 位真正的外掛,pred[…] 只拿他們的預測(實測 30 個),加起來=抓到幾位。
pred.sum()
方法判成外掛的總數=要人工覆核的筆數。
recall_score(y_test, pred)
函式真正外掛裡抓到的比例。預設 pos_label=1:把 1 當成要抓的那一類。
precision_score(y_test, pred)
函式被判成外掛的人裡,真的是外掛的比例。
{th:<6}
格式靠左、寬 6 格。實測 f'{0.5:<6}' 是 '0.5 ',讓四行對齊。
{…:>2}、{…:>3}
格式靠右、寬 2 格/3 格。實測 f'{11:>3}' 是 ' 11'。
{…:.3f}
格式小數點後 3 位。實測 f'{0.36667:.3f}' 是 '0.367'。

recall_score/precision_score 參數表(兩個一樣)

簽名:recall_score(y_true, y_pred, *, labels=None, pos_label=1, average='binary', sample_weight=None, zero_division='warn')

參數預設值白話+實測
pos_label1哪一類算「要抓的」。改成 0,recall 就變成特異度:實測門檻 0.0037 時 recall_score(y_test, pred, pos_label=0) 是 0.985676
average'binary'二元分類只算 pos_label 那一類;多類別時改成 'macro'、'weighted' 等
zero_division'warn'0 除以 0 時怎麼辦(見第 32 行)
labels、sample_weightNone指定要算的類別、每筆的權重
印出(實測): 門檻 0.5 抓到 11 / 30 位外掛 要人工覆核 11 筆 recall 0.367 precision 1.000 門檻 0.1 抓到 19 / 30 位外掛 要人工覆核 21 筆 recall 0.633 precision 0.905 門檻 0.01 抓到 28 / 30 位外掛 要人工覆核 240 筆 recall 0.933 precision 0.117 門檻 0.0037 抓到 30 / 30 位外掛 要人工覆核 889 筆 recall 1.000 precision 0.034

門檻越往下,recall 一路升到 1.000,precision 一路掉到 0.034。營運長要的是「一個都不能放過」,所以選門檻 0.0037 這一行;889 筆裡有 859 筆是正常司機,交給人工覆核。

§4

互動實驗:換編碼、換模型、拖門檻

三個工具分別對應三題,都用 JavaScript 照 scikit-learn 的算法即時計算。本頁用 Node.js 跑過同一段計算程式,和 scikit-learn 1.8.0 的實際結果逐一比對:實驗 1 比了 300 組隨機的收入和編碼(預測、截距、係數),實驗 2 比了 1,200 份報告的每一個字(600 種情況,小數 2 位、4 位各印一次),實驗 3 比了滑桿上全部 1,001 個門檻和每個按鈕的計數,全部一致。

實驗 1|第 42 題:1、2、3 只是代號

換編碼順序、拖動各區收入:直線和樹各猜多少?
圓點是各區真正的平均收入(橫軸是它被編成的代號);橘色斜線是線性迴歸學到的直線,方塊是它的預測,虛線是猜錯的距離。決策樹不畫線:它把三區分開,各自預測自己的平均,所以樹的預測就落在圓點上。
司機人數固定是 A 836、B 678、C 486 位
可以試試看

① 預設(題目的編碼):直線往上斜,C 區被高估 61.9、B 區被低估 88.8。② 按 A→1 C→2 B→3:編碼剛好照收入由低到高,直線比較貼近,但 C 區還是差了 35。③ 按 One-Hot:每一區有自己的係數,線性迴歸也三區全中。④ 回到題目的編碼,把 C 區拖到 400(比 B 區還高):收入剛好照 1、2、3 遞增時,直線就沒那麼糟。⑤ 不管怎麼拖、怎麼換,決策樹的預測永遠是各區平均:它只在乎「是不是同一區」。

實驗 2|第 43 題:99.95% 的陷阱

同一份 2000 人的資料,三個模型的混淆矩陣和報告
拖動外掛人數,切換模型;報告的格式和 classification_report(…, zero_division=0) 印出來的一字不差。打勾可以像考卷那張圖一樣,把 support 欄藏起來。

    
可以試試看

① 預設(1 位外掛、偷懶模型):準確率 0.9995,報告的 accuracy 印成 1.00,外掛那一列全是 0。② 按 完美規則、打勾藏起 support:就是考卷那張全是 1.00 的圖;取消打勾,看見外掛只有 1 筆。③ 把外掛拖到 200 位(10%),再按偷懶模型:準確率掉到 0.90,「全部猜正常」的把戲就藏不住了。資料越不平衡,準確率越會騙人。④ 按 普通模型:外掛只有 1 位時一個都沒抓到,還誤報了 10 位,準確率卻仍有 0.9945。

實驗 3|第 44 題:門檻往下調

門檻滑桿:6 萬筆測試資料、30 位外掛
紅點是 30 位真正的外掛,位置就是模型給他們的機率(§3 那支程式實測的 proba);灰色長條是正常司機的機率分布。門檻線右邊的全部判成外掛、送去人工覆核。橫軸是對數刻度,每往右一大格,機率大 10 倍。

機率低於 0.003 的 58,817 位正常司機沒有畫出來:滑桿最低只到 0.003,他們在這個範圍內永遠不會被判成外掛。拖動時門檻取 3 位有效數字。

可以試試看

① 按 0.5:只抓到 11 位,19 位紅點在門檻線左邊被放過;這時 precision 是 1(B 想要的)、特異度也是 100%(D 想要的)。② 慢慢往左拖:誤判的正常司機一開始是 0,過了 0.168(正常司機的最高機率)才開始出現。③ 按 0.0037:30 位全部抓到,要覆核 889 筆。④ 按 0.0037 之後,用鍵盤的 → 鍵往右移兩格(0.00374):最左邊那位外掛(機率 0.003729)又被放過了。門檻要剛好壓在「最不像外掛的那位外掛」之下。

看動畫 3 之前:這支要回答什麼

上面的滑桿讓你自己調門檻。動畫 3 從頭說一次:模型先算出機率(predict_proba),再用門檻把機率變成「是/不是外掛」。接著在 6 萬筆測試資料上,把門檻從預設的 0.5 一路往下調,數每一步抓到幾位、要人工覆核幾筆,最後對照四個選項。

帶著三個問題看:① predict_proba 交回的陣列有幾欄?我們要的是哪一欄?② 門檻 0.5 為什麼放過了 19 位外掛?③ 選項 D 的「特異度 100%」為什麼不符合營運長的要求?

看的時候注意:紅點是 30 位真正的外掛,灰色是正常司機;門檻線右邊的會被判成外掛、送去人工覆核。

動畫 3門檻往下調:機率、門檻、recall 與 precision2 分 18 秒・有旁白,也有字幕

20 萬筆資料是本頁模擬的(外掛 100 筆=0.05%,和題目情境相同),模型是 scikit-learn 1.8.0 的 LogisticRegression;每個門檻抓到幾位、覆核幾筆,都是實際跑出來的數字,§6 可以自己重跑。

看完動畫 3,你應該能回答:predict_proba 交回什麼?
一個 NumPy 陣列,形狀 (60000, 2):每一筆兩個機率,加起來是 1。欄的順序照 clf.classes_([0 1]),所以第 0 欄是「正常」、第 1 欄是「外掛」,用 [:, 1] 取出第 1 欄。
門檻 0.5 為什麼放過了 19 位外掛?
外掛只占 0.05%,模型很少給到 0.5 以上的機率:30 位外掛裡只有 11 位超過 0.5,其餘 19 位的機率都在 0.5 以下(最低只有 0.0037),預設門檻就把他們判成正常。
門檻調到 0.0037,代價是什麼?
30 位全部抓到(recall 1.000),但要人工覆核 889 筆,其中 859 筆是正常司機(precision 只剩 0.034)。營運長說誤凍結可以交給人工覆核,所以這個代價可以接受。
選項 D「特異度 100%」為什麼不行?
特異度 100% 是「正常司機一個都不能誤判」。正常司機拿到的最高機率是 0.168,門檻必須高過它,最多只抓到 18 位外掛,還有 12 位被放過,違反「不能放過任何一個」。
§5

回到題目:三題逐項對答案

第 42 題不能改編碼,換哪種模型?答案 D

四個模型在本頁模擬資料上的實測(真正平均 A 149.9、B 302.6、C 179.9):

選項模型A 區B 區C 區結果
ALogisticRegressionfit 直接報錯:收入是連續數字,不是類別不能用
BRidge(alpha=1)185.9213.8241.8C 高估 61.9
CSVR(線性核,C=100)170.1195.3220.5C 高估 40.6
DRandomForestRegressor150.0302.5179.7三區都差不到 0.3
(A) 羅吉斯迴歸(Logistic Regression) 錯

名字有「迴歸」,其實是分類模型,輸出的是「屬於哪一類」的機率。題目要預測的是收入(連續的數字),實測 LogisticRegression().fit(X, y) 直接 ValueError: Unknown label type: continuous…。就算硬把收入切成「高/低」兩類來用,它的總分仍然是「截距 + 係數 × 代號」,一樣把 1、2、3 當成大小。

(B) Ridge 迴歸 錯

Ridge 是線性迴歸加上正則化(懲罰太大的係數),模型的形狀還是一條直線。實測 alpha=1 時預測和線性迴歸到小數 1 位都一樣(185.9、213.8、241.8);把 alpha 加到 1000,直線只是變平(196.1、211.7、227.3),C 區還是高估 47。正則化處理的是「係數太大、過度擬合」,不是「把代號當成大小」。

(C) 支援向量機搭配線性核函數 錯

線性核的意思就是「用直線(平面)」,對代號的用法和線性迴歸一樣:收入 = 截距 + 係數 × 代號。實測 C 區高估 40.6、B 區低估 107.3。換成非線性的 rbf 核才會彎(實測 149.3、302.8、180.6),但題目指定的是線性核。

(D) 隨機森林(Random Forest) 正解

隨機森林由很多棵決策樹組成,每棵樹只問「代號 ≤ 1.5?」「代號 ≤ 2.5?」這種切分問題,把三區分開、各算各的平均,不假設「代號每加 1 收入就固定增加」。實測預測 150.0、302.5、179.7,幾乎就是三區真正的平均。在「不能改編碼」的限制下,從演算法層面避開大小誤解,樹模型最適合。

第 43 題哪一個「不是」合理的質疑?答案 C

(A) 永遠預測正常,準確率仍可能高達 99.95% 合理

§3 第 29~31 行直接證明:2000 人全部猜正常,準確率 1999 ÷ 2000 = 0.9995,和報告的 99.95% 一模一樣。高準確率可能只是「什麼都沒學」。

(B) 報告未呈現召回率,模型可能無法辨識真正的外掛 合理

召回率回答「真正的外掛抓到幾成」,是外掛偵測最該看的數字;只報 accuracy 的結論,看不出這件事。

老實說一個出入:考卷附圖其實有 recall 欄(全部 1.00),所以「未呈現召回率」和圖不完全吻合。比較說得通的讀法是:新進資料科學家的結論只引用了 accuracy;或者就算圖上有 recall 1.00,圖拿掉了 support,看不出它是幾個外掛算出來的(觀念 6:只有 1 個外掛時,抓到 1 個就是 1.00)。不管哪種讀法,「要看 recall 才知道外掛有沒有被抓到」這個方向是對的,所以 B 仍屬合理質疑。

(C) 異常偵測不可使用分類模型,僅能使用分群或離群值方法 正解(不合理)

說得太絕對。只要有可靠的標註(誰是外掛),外掛偵測完全可以當成監督式分類問題:第 44 題就是用 LogisticRegression 抓外掛。分群、離群值方法(例如 IsolationForest,§2 實測也挑得出 D1522)是沒有標註時的選擇,兩種方法並存,不是只能用其中一種。題目問「不是合理的理由」,所以選 C。

(D) 少數類比例極低時只看 Accuracy,會忽略對異常樣本的偵測能力 合理

和 A 是同一件事的一般說法:外掛只佔 0.05% 時,準確率幾乎全由正常司機決定。§4 實驗 2 把外掛拖到 10%,偷懶模型的準確率就掉到 0.90,可見不平衡越嚴重,準確率越會騙人。

第 44 題作弊零容忍,門檻怎麼調?答案 A

選項策略本頁實測在哪個門檻做到抓到外掛放過要覆核符合「一個都不能放過」?
Arecall 最大 → 降低門檻0.0037300889是
Bprecision 最大 → 提高門檻0.5(precision 已是 1.000)111911否
C固定 0.50.5111911否
D特異度 100%要高過 0.168(例如 0.17)最多 181218否
(A) 選擇使召回率最大化的門檻,通常代表應降低門檻 正解

「不能放過任何一個真正的外掛」就是要 recall = 1;「誤凍結可以人工覆核」代表誤報的代價可以接受。門檻越低,越多人被判成外掛,recall 越高。實測門檻從 0.5 降到 0.0037,抓到的外掛從 11 位升到 30 位全部,代價是覆核從 11 筆變成 889 筆(6 萬筆的 1.5%)。

(B) 選擇使精確率最大化的門檻,通常代表應提高門檻 錯

precision 在乎的是「被凍結的人有沒有冤枉」,方向剛好相反。實測門檻 0.5 時 precision 已經是 1.000,卻放過 19 位;提高到 0.9,只剩 2 位被抓到。這適合「誤凍結代價很大」的情況,不是零容忍。

(C) 固定使用 0.5,調整門檻會破壞訓練平衡 錯

0.5 只是 predict() 的預設切點。門檻是在模型算完機率之後才用的,調門檻不會重新訓練,也不會改到模型:實測調門檻前後 clf.coef_ 都是 [[14.393, 0.247]]。scikit-learn 1.5 起甚至有專門調門檻的 FixedThresholdClassifier、TunedThresholdClassifierCV。

(D) 選擇使特異度達到 100% 的門檻,確保正常司機不被誤判 錯

特異度 100% 是「正常司機一個都不能誤判」,這是在保護正常司機,不是在抓外掛。正常司機拿到的最高機率是 0.168,門檻必須高過它;實測 0.17 只抓到 18 位,放過 12 位。題目已經說誤凍結可以人工覆核,D 把重點放錯了。

常見寫錯與誤解(全部實測)
  1. 把 LogisticRegression 當成迴歸:拿去預測收入,ValueError: Unknown label type: continuous。名字裡的「迴歸」是歷史包袱,它是分類模型。
  2. 只看報告最下面兩行:偷懶模型的 accuracy、weighted avg 都印成 1.00。要看外掛那一列的 recall 和 support。
  3. 以為 predict 可以傳門檻:clf.predict(X_test, threshold=0.1) → TypeError: LinearClassifierMixin.predict() got an unexpected keyword argument 'threshold'。用 predict_proba 自己比,或用 FixedThresholdClassifier。
  4. 機率取錯欄:predict_proba(X)[:, 0] 是「正常」的機率;忘了 [:, 1] 直接比,算 recall 時 ValueError: Classification metrics can't handle a mix of binary and multilabel-indicator targets。
  5. X 只給一層方括號:fit(sim['region_code'], y) → ValueError: Expected a 2-dimensional container but got <class 'pandas.core.series.Series'> instead.…

換個條件,答案就換一個

三題的答案都綁著題目給的條件。條件一換,正確的做法也跟著換(都用本頁資料實測):

如果題目改成做法本頁資料的結果
第 42 題:編碼可以改One-Hot(pd.get_dummies)+線性迴歸預測 149.9、302.6、179.9,三區全中
第 44 題:誤凍結會被客訴,寧可漏抓提高門檻,顧 precision/特異度門檻 0.2:誤判 0 位,抓到 16 位
第 44 題:不想自己調門檻LogisticRegression(class_weight='balanced')直接 predict 就抓到 28 位、判成外掛 217 筆
第 43 題:沒有任何外掛的標註離群值偵測(IsolationForest)2000 位裡挑出 1 位,就是 D1522
§6

動手跑跑看:三支程式,自己重現每一個數字

先下載第 41 題的模擬資料 driver_daily_stats.csv(2000 列 × 5 欄,約 62 KB;第 43 題用它),和下面的程式放在同一個資料夾,在那個資料夾開終端機執行 python 檔名.py。第 42、44 題的資料由程式自己模擬,不用下載。需要 scikit-learn(pip install scikit-learn;Google Colab 已經裝好,記得先從左側「檔案」面板把 CSV 上傳)。本頁用 scikit-learn 1.8.0 實測;程式 3 的 FixedThresholdClassifier 需要 1.5 以上。

程式 1:§3 那支完整程式(三題一起跑)

import numpy as np
import pandas as pd
from sklearn.linear_model import LinearRegression, LogisticRegression
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import accuracy_score, classification_report, recall_score, precision_score
from sklearn.model_selection import train_test_split

# ===== 第 42 題:region 編碼成 1、2、3,線性模型 vs 樹模型 =====
# 模擬 2000 位司機:B 區收入最高、C 區其次、A 區最低(不是 1→2→3 遞增)
rng = np.random.default_rng(42)
sim = pd.DataFrame({'region': rng.choice(['A', 'B', 'C'], 2000, p=[.42, .35, .23])})
sim['daily_earnings'] = sim['region'].map({'A': 150, 'B': 300, 'C': 180}) + rng.normal(0, 40, 2000)

sim['region_code'] = sim['region'].map({'A': 1, 'B': 2, 'C': 3})   # 標籤編碼(不能改)
X = sim[['region_code']]
y = sim['daily_earnings']
lin = LinearRegression().fit(X, y)
rf = RandomForestRegressor(n_estimators=100, random_state=0).fit(X, y)

codes = pd.DataFrame({'region_code': [1, 2, 3]})
print('各區真正的平均:', sim.groupby('region')['daily_earnings'].mean().round(1).tolist())
print('線性迴歸預測:  ', lin.predict(codes).round(1).tolist())
print('隨機森林預測:  ', rf.predict(codes).round(1).tolist())
print('線性迴歸的斜率:', round(lin.coef_[0], 2), '截距:', round(lin.intercept_, 2))

# ===== 第 43 題:外掛只有 0.05%,全部猜「正常」 =====
df = pd.read_csv('driver_daily_stats.csv')                         # 第 41 題那份資料
is_cheat = (df['gps_ping_rate'] > 2).astype(int)                     # gps_ping_rate 被改成 6.4 的那一位
guess = [0] * len(df)                                                # 全部猜 0(正常)
print('外掛人數:', is_cheat.sum(), '/', len(df))
print('Accuracy:', accuracy_score(is_cheat, guess))
print(classification_report(is_cheat, guess, zero_division=0))

# ===== 第 44 題:門檻從 0.5 往下調 =====
# 模擬 200,000 筆司機日資料,外掛 100 筆(0.05%)
rng = np.random.default_rng(44)
cheat = np.zeros(200_000, dtype=int)
cheat[rng.choice(200_000, 100, replace=False)] = 1
gps = np.where(cheat == 1, rng.normal(1.40, 0.10, 200_000), rng.normal(1.0, 0.08, 200_000))
trips = np.where(cheat == 1, rng.normal(30, 6, 200_000), rng.normal(20, 6, 200_000))
X2 = np.column_stack([gps, trips])
X_train, X_test, y_train, y_test = train_test_split(X2, cheat, test_size=0.3, stratify=cheat, random_state=0)

clf = LogisticRegression(max_iter=1000).fit(X_train, y_train)
proba = clf.predict_proba(X_test)[:, 1]                              # 每一筆「是外掛」的機率
print('測試資料:', len(y_test), '筆,外掛', y_test.sum(), '筆')
for th in [0.5, 0.1, 0.01, 0.0037]:
    pred = (proba >= th).astype(int)
    print(f'門檻 {th:<6}  抓到 {pred[y_test == 1].sum():>2} / {y_test.sum()} 位外掛'
          f'  要人工覆核 {pred.sum():>3} 筆'
          f'  recall {recall_score(y_test, pred):.3f}  precision {precision_score(y_test, pred):.3f}')
實際跑出來的結果(scikit-learn 1.8.0;pandas 2.2.3 和 3.0.6 相同)
各區真正的平均: [149.9, 302.6, 179.9]
線性迴歸預測:   [185.9, 213.8, 241.8]
隨機森林預測:   [150.0, 302.5, 179.7]
線性迴歸的斜率: 27.97 截距: 157.9
外掛人數: 1 / 2000
Accuracy: 0.9995
              precision    recall  f1-score   support

           0       1.00      1.00      1.00      1999
           1       0.00      0.00      0.00         1

    accuracy                           1.00      2000
   macro avg       0.50      0.50      0.50      2000
weighted avg       1.00      1.00      1.00      2000

測試資料: 60000 筆,外掛 30 筆
門檻 0.5     抓到 11 / 30 位外掛  要人工覆核  11 筆  recall 0.367  precision 1.000
門檻 0.1     抓到 19 / 30 位外掛  要人工覆核  21 筆  recall 0.633  precision 0.905
門檻 0.01    抓到 28 / 30 位外掛  要人工覆核 240 筆  recall 0.933  precision 0.117
門檻 0.0037  抓到 30 / 30 位外掛  要人工覆核 889 筆  recall 1.000  precision 0.034

整支程式在一般筆電上大約幾秒鐘跑完(20 萬筆的 LogisticRegression 只要反覆 20 次)。

程式 2:打開模型物件看看(第 42 題)

import numpy as np
import pandas as pd
from sklearn.linear_model import LinearRegression
from sklearn.ensemble import RandomForestRegressor
from sklearn.tree import export_text

# 和程式 1 同一份模擬資料
rng = np.random.default_rng(42)
sim = pd.DataFrame({'region': rng.choice(['A', 'B', 'C'], 2000, p=[.42, .35, .23])})
sim['daily_earnings'] = sim['region'].map({'A': 150, 'B': 300, 'C': 180}) + rng.normal(0, 40, 2000)
sim['region_code'] = sim['region'].map({'A': 1, 'B': 2, 'C': 3})
X, y = sim[['region_code']], sim['daily_earnings']

# ① 物件剛做好:只有設定,還沒有學到的東西
lin = LinearRegression()
print(lin)
print('fit 之前有 coef_ 嗎?', hasattr(lin, 'coef_'))

# ② fit 交回的就是同一個物件
print('fit 交回的是 lin 自己嗎?', lin.fit(X, y) is lin)
print('fit 之後多了:', [k for k in vars(lin) if k.endswith('_')])
print('斜率 coef_:', lin.coef_, ' 截距 intercept_:', lin.intercept_)

# ③ 隨機森林:身上掛了 100 棵決策樹
rf = RandomForestRegressor(n_estimators=100, random_state=0).fit(X, y)
print(type(rf.estimators_), len(rf.estimators_))
print(rf.estimators_[0])
print(export_text(rf.estimators_[0], feature_names=['region_code'], decimals=1))

# ④ 如果可以改編碼:One-Hot,每一區一欄 0/1
oh = pd.get_dummies(sim['region'], dtype=int)
print(oh.head(3))
lin_oh = LinearRegression().fit(oh, y)
print('One-Hot 線性迴歸預測 A、B、C:',
      lin_oh.predict(pd.DataFrame({'A': [1, 0, 0], 'B': [0, 1, 0], 'C': [0, 0, 1]})).round(1).tolist())
實際跑出來的結果
LinearRegression()
fit 之前有 coef_ 嗎? False
fit 交回的是 lin 自己嗎? True
fit 之後多了: ['feature_names_in_', 'n_features_in_', 'coef_', 'rank_', 'singular_', 'intercept_']
斜率 coef_: [27.96929924]  截距 intercept_: 157.90396942660698
<class 'list'> 100
DecisionTreeRegressor(max_features=1.0, random_state=209652396)
|--- region_code <= 1.5
|   |--- value: [149.4]
|--- region_code >  1.5
|   |--- region_code <= 2.5
|   |   |--- value: [302.1]
|   |--- region_code >  2.5
|   |   |--- value: [179.9]

   A  B  C
0  0  0  1
1  0  1  0
2  0  0  1
One-Hot 線性迴歸預測 A、B、C: [149.9, 302.6, 179.9]

vars(lin) 列出物件身上存的所有東西,這裡只挑名字以底線結尾的(fit 學到的)。One-Hot 那張表的第 0 列是 0、0、1:第 0 位司機在 C 區。

程式 3:機率、門檻與特異度(第 44 題)

import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, recall_score
from sklearn.model_selection import train_test_split, FixedThresholdClassifier

# 和程式 1 同一份模擬資料(第 44 題)
rng = np.random.default_rng(44)
cheat = np.zeros(200_000, dtype=int)
cheat[rng.choice(200_000, 100, replace=False)] = 1
gps = np.where(cheat == 1, rng.normal(1.40, 0.10, 200_000), rng.normal(1.0, 0.08, 200_000))
trips = np.where(cheat == 1, rng.normal(30, 6, 200_000), rng.normal(20, 6, 200_000))
X2 = np.column_stack([gps, trips])
X_train, X_test, y_train, y_test = train_test_split(X2, cheat, test_size=0.3, stratify=cheat, random_state=0)
clf = LogisticRegression(max_iter=1000).fit(X_train, y_train)

# ① predict_proba 交回 2 欄:第 0 欄「正常」、第 1 欄「外掛」,順序看 classes_
P = clf.predict_proba(X_test)
print(type(P), P.shape, clf.classes_)
print(P[:3].round(4))

# ② predict() 就是門檻 0.5
proba = P[:, 1]
print('predict 和「proba >= 0.5」一樣嗎?', (clf.predict(X_test) == (proba >= 0.5)).all())

# ③ 兩個門檻的完整報告
for th in [0.5, 0.0037]:
    pred = (proba >= th).astype(int)
    print('門檻', th)
    print(classification_report(y_test, pred, digits=3))
    print('特異度(正常司機被判正常的比例):', round(recall_score(y_test, pred, pos_label=0), 4))

# ④ 正常司機拿到的最高機率:門檻只要高過它,就一個都不會誤判
print('正常司機的最高機率:', proba[y_test == 0].max().round(4))
print('門檻 0.17 抓到幾位外掛:', (proba[y_test == 1] >= 0.17).sum())

# ⑤ scikit-learn 1.5 起的寫法:把門檻包進模型
ft = FixedThresholdClassifier(LogisticRegression(max_iter=1000), threshold=0.0037,
                              response_method='predict_proba').fit(X_train, y_train)
print('FixedThresholdClassifier 判成外掛:', ft.predict(X_test).sum(), '筆')
print('模型的權重有沒有變?', clf.coef_.round(3), '(門檻不會改到它)')
實際跑出來的結果
<class 'numpy.ndarray'> (60000, 2) [0 1]
[[1.    0.   ]
 [0.996 0.004]
 [1.    0.   ]]
predict 和「proba >= 0.5」一樣嗎? True
門檻 0.5
              precision    recall  f1-score   support

           0      1.000     1.000     1.000     59970
           1      1.000     0.367     0.537        30

    accuracy                          1.000     60000
   macro avg      1.000     0.683     0.768     60000
weighted avg      1.000     1.000     1.000     60000

特異度(正常司機被判正常的比例): 1.0
門檻 0.0037
              precision    recall  f1-score   support

           0      1.000     0.986     0.993     59970
           1      0.034     1.000     0.065        30

    accuracy                          0.986     60000
   macro avg      0.517     0.993     0.529     60000
weighted avg      1.000     0.986     0.992     60000

特異度(正常司機被判正常的比例): 0.9857
正常司機的最高機率: 0.1681
門檻 0.17 抓到幾位外掛: 18
FixedThresholdClassifier 判成外掛: 889 筆
模型的權重有沒有變? [[14.393  0.247]] (門檻不會改到它)
  • 門檻 0.0037 的報告:外掛那一列 recall 1.000,但 precision 只有 0.034、f1 只有 0.065。這是故意的取捨:營運長要的是 recall。
  • 注意 accuracy 從 1.000 掉到 0.986:調門檻讓「準確率」變差,卻更符合需求。這正好呼應第 43 題:準確率不是唯一的成績。
新出現的語法是什麼白話
hasattr(lin, 'coef_')內建函式物件身上有沒有這個名字,交回 True/False
vars(lin)內建函式物件身上存的所有東西(名字 → 值的字典)
a is b運算子兩張名牌是不是指著同一個物件(不是「長得一樣」)
[k for k in … if …]串列生成式一個一個拿出來,符合條件的才收進新清單
export_text(樹, feature_names=…, decimals=1)sklearn.tree 的函式把一棵決策樹印成文字,看它問了哪些問題
pd.get_dummies(欄, dtype=int)pandas 的函式One-Hot:一欄類別變成好幾欄 0/1,每一類一欄
recall_score(…, pos_label=0)參數把「正常」當成要抓的那一類,算出來就是特異度
FixedThresholdClassifier(模型, threshold=…)sklearn.model_selection 的設計圖把模型和門檻包成一個新模型,predict 時就用這個門檻
X, y = A, B一次貼兩張名牌左邊第一個名牌貼右邊第一個,第二個貼第二個
§7

一頁總結+自我檢測

這三題要帶走的 6 件事
  1. scikit-learn 的套路:設計圖 → 物件 → fit → predict。fit 學到的東西都以底線結尾(coef_、estimators_、classes_),fit 交回物件自己。
  2. 第 42 題:標籤編碼的 1、2、3 只是代號。線性模型(線性迴歸、Ridge、線性核 SVM)都是「截距+係數×代號」的直線,會把代號當成大小;本頁資料上 C 區被高估 62。樹模型靠切分(≤ 1.5?≤ 2.5?)把各區分開,答案是 D 隨機森林。羅吉斯迴歸是分類模型,不能預測收入。
  3. 第 43 題:外掛只佔 0.05% 時,全部猜正常就有 0.9995 的準確率。要看外掛那一類的 recall,以及 support(有幾個樣本)。報告預設只印 2 位小數,0.9995 會印成 1.00。
  4. 第 43 題答案 C:有標註就能用分類模型抓異常,分群、離群值方法是沒有標註時的選擇,「只能」用後者說得太絕對。
  5. 第 44 題:分類模型先算機率(predict_proba(X)[:, 1]),再用門檻決定;predict() 就是門檻 0.5。調門檻不會改到模型。
  6. 第 44 題答案 A:漏抓代價大 → 降低門檻、讓 recall 最大。本頁實測門檻 0.0037 抓到 30/30,代價是覆核 889 筆;precision、特異度、甚至 accuracy 都會下降,這是取捨。
lin = LinearRegression() 執行完,lin.coef_ 是多少?
還沒有:會出現 AttributeError: 'LinearRegression' object has no attribute 'coef_'。這時物件只有設定(fit_intercept=True 等),要等 lin.fit(X, y) 學完,身上才多了 coef_、intercept_。
為什麼線性迴歸會高估 C 區,隨機森林不會?
線性迴歸只能畫一條直線:收入 = 157.90 + 27.97 × 代號,被 A(150)和 B(303)拉成往上斜,代入 3 得到 241.8,比 C 區真正的 179.9 多 62。隨機森林的樹問「代號 ≤ 1.5?≤ 2.5?」,把三區分開各算平均,所以 C 區預測 179.7。
Ridge 不就是「比較穩的線性迴歸」嗎?為什麼不行?
Ridge 只是在找直線時懲罰太大的斜率,形狀還是直線。實測 alpha=1 時和線性迴歸一樣(C 區 241.8);alpha=1000 時直線變平(227.3),C 區仍高估 47。它解決的是過度擬合,不是代號被當成大小。
全部猜「正常」的模型,報告上外掛那一列會是什麼?
precision 0.00、recall 0.00、f1 0.00、support 1。precision 是 0 除以 0,要寫 zero_division=0 才不會跳 UndefinedMetricWarning。而 accuracy 那一格印成 1.00(其實是 0.9995)。
考卷那張全是 1.00 的報告,為什麼還不能直接上線?
它拿掉了 support 欄:如果外掛那一列只有 1 筆,「1 個抓到 1 個」就是 1.00,樣本太少,說明不了模型真的會抓外掛。還要看測試資料怎麼切、有幾個外掛、混淆矩陣,最好在沒看過的資料上驗證。
clf.predict_proba(X_test) 的形狀是什麼?要用哪一欄?
(60000, 2):每一筆兩個機率,加起來是 1。欄的順序照 clf.classes_([0 1]),所以「是外掛」的機率是第 1 欄:[:, 1]。
門檻從 0.5 調到 0.0037,模型的係數會變嗎?
不會。門檻是在機率算出來之後才用的,clf.coef_ 一直是 [[14.393, 0.247]]。所以選項 C「調整門檻會破壞訓練平衡」是錯的。
為什麼選項 D(特異度 100%)不符合營運長的要求?
特異度 100% 要求正常司機一個都不能誤判,門檻就得高過正常司機的最高機率 0.168;實測門檻 0.17 只抓到 18 位,還放過 12 位外掛。營運長要的是「不能放過任何一個」,誤凍結可以人工覆核,所以該顧的是 recall,不是特異度。