§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 | 一次貼兩張名牌 | 左邊第一個名牌貼右邊第一個,第二個貼第二個 |