想像你是一位人力資源分析師:薪資會隨年資非線性增長(初期快、後期趨緩),年齡對薪資是倒 U 型(中年最高),而學歷是離散的類別變數。線性迴歸把三者都當成直線關係,顯然不夠準確。但若要用一個巨大的非線性模型一次捕捉所有變數的交互作用,模型會複雜到無法解釋。
廣義加法模型(Generalized Additive Model, GAM)正是為這個困境而生:它讓每個變數各自擬合一條非線性函數,再加起來。既能捕捉非線性關係,又保持「每個變數的貢獻可獨立解讀」的優雅特性。
複習一下多元線性迴歸:
這假設每個變數 \(X_j\) 與 \(Y\) 是線性關係。GAM 把這條公式做一個優雅的改造:把每個線性項 \(\beta_j x_{ij}\) 換成一個非線性函數 \(f_j(x_{ij})\):
加性(additive)指的是每個 \(f_j\) 只依賴一個 \(X_j\),然後把貢獻「加總」。這點跟線性迴歸相同,但 \(f_j\) 可以是任何平滑函數——自然樣條、平滑樣條、局部迴歸、多項式都可以。
課本使用 Wage 資料集,以三個變數預測薪資:
課本的擬合結果如 Figure 7.11 所示:\(f_1\) 和 \(f_2\) 用自然樣條(4 和 5 個自由度)、\(f_3\) 用啞變數。所有變數的效應可以獨立解讀——這就是 GAM 最大的魅力:可解釋的非線性模型。
以下用 sklearn 重現 GAM 的核心概念——把多個樣條基底和啞變數合併成一個大設計矩陣,再跑 OLS:
"""GAM 迴歸 — Wage 資料集"""
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from ISLP import load_data
from sklearn.preprocessing import SplineTransformer, OneHotEncoder
from sklearn.linear_model import LinearRegression
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
# 載入資料
wage = load_data('Wage')
X = wage[['year', 'age', 'education']]
y = wage['wage']
# 定義各變數的轉換方式:year→樣條,age→樣條,education→啞變數
preprocessor = ColumnTransformer([
('year_spline', SplineTransformer(n_knots=3, degree=3), ['year']),
('age_spline', SplineTransformer(n_knots=4, degree=3), ['age']),
('edu_dummy', OneHotEncoder(drop='first'), ['education'])
])
model = Pipeline([
('transform', preprocessor),
('reg', LinearRegression())
])
model.fit(X, y)
print(f"R² = {model.score(X, y):.4f}")
print(f"截距 = {model.named_steps['reg'].intercept_:.2f}")
# 繪製各變數的偏效應(概念示範)
fig, axes = plt.subplots(1, 3, figsize=(14, 4))
# year 效應
years = np.linspace(wage['year'].min(), wage['year'].max(), 100)
X_year = pd.DataFrame({'year': years, 'age': wage['age'].mean(),
'education': wage['education'].mode()[0]})
pred_year = model.predict(X_year)
axes[0].plot(years, pred_year, linewidth=2)
axes[0].set_title('f₁(year) 效應'); axes[0].set_xlabel('year')
axes[0].set_ylabel('partial effect')
# age 效應
ages = np.linspace(wage['age'].min(), wage['age'].max(), 100)
X_age = pd.DataFrame({'year': wage['year'].mean(), 'age': ages,
'education': wage['education'].mode()[0]})
pred_age = model.predict(X_age)
axes[1].plot(ages, pred_age, linewidth=2, color='#3fb950')
axes[1].set_title('f₂(age) 效應'); axes[1].set_xlabel('age')
# education 效應
edu_levels = wage['education'].unique()
edu_preds = []
for edu in edu_levels:
X_edu = pd.DataFrame({'year': wage['year'].mean(), 'age': wage['age'].mean(),
'education': [edu]})
edu_preds.append(model.predict(X_edu)[0])
axes[2].bar(range(len(edu_levels)), edu_preds, color='#d2991d')
axes[2].set_title('f₃(education) 效應')
axes[2].set_xticks(range(len(edu_levels)))
axes[2].set_xticklabels(edu_levels, rotation=30)
plt.tight_layout(); plt.show()
print("三個偏效應圖:year 微幅上升 / age 倒U型 / education 階梯遞增")
自然樣條 GAM 可以直接用最小平方法(整個模型就是一個大的線性迴歸)。但如果要用平滑樣條,就沒這麼簡單——平滑樣條本身有懲罰項,不能用 OLS 一步到位。解法是課本介紹的反向擬合演算法(backfitting):
Python 中有專屬的 pygam 套件可以處理平滑樣條 GAM,使用 P-spline 並自動選擇平滑參數。課本 Figure 7.12 顯示:平滑樣條 GAM 和自然樣條 GAM 的結果非常相似——在實務中,兩種基底選擇的差異通常不大。
回顧邏輯迴歸的核心公式:
同樣的改造:把線性項換成非線性函數:
這等於是在說:「log-odds 是各變數非線性貢獻的總和」。分類 GAM 也可以透過反向擬合來擬合——每次更新一個 \(f_j\) 時,用加權最小平方法處理邏輯迴歸的局部二次近似。
"""Logistic GAM — Wage 資料集的高薪分類(示範)"""
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from ISLP import load_data
from sklearn.preprocessing import SplineTransformer, OneHotEncoder
from sklearn.linear_model import LogisticRegression
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.model_selection import train_test_split
# 載入資料,建立二元分類:薪資 > 100K 為「高薪」
wage = load_data('Wage')
X = wage[['year', 'age', 'education']]
y = (wage['wage'] > 100).astype(int) # 二元分類標籤
print(f"高薪比例: {y.mean():.2%} (n={len(y)})")
# 分割資料
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.3, random_state=42)
# Logistic GAM:樣條 + 啞變數 → LogisticRegression
preprocessor = ColumnTransformer([
('year_spline', SplineTransformer(n_knots=2, degree=3), ['year']),
('age_spline', SplineTransformer(n_knots=3, degree=3), ['age']),
('edu_dummy', OneHotEncoder(drop='first'), ['education'])
])
model = Pipeline([
('transform', preprocessor),
('logit', LogisticRegression(max_iter=5000, C=1e8)) # C 很大 = 幾乎無正則化
])
model.fit(X_tr, y_tr)
print(f"訓練準確率 = {model.score(X_tr, y_tr):.3f}")
print(f"測試準確率 = {model.score(X_te, y_te):.3f}")
# 繪製 age 對 log-odds 的偏效應
ages = np.linspace(wage['age'].min(), wage['age'].max(), 100)
X_plot = pd.DataFrame({'year': wage['year'].mean(),
'age': ages,
'education': wage['education'].mode()[0]})
log_odds = model.decision_function(X_plot)
probs = 1 / (1 + np.exp(-log_odds))
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4))
ax1.plot(ages, log_odds, 'b-', linewidth=2)
ax1.set_title('Log-Odds vs Age'); ax1.set_xlabel('Age')
ax1.set_ylabel('log-odds(high wage)'); ax1.axhline(0, color='gray', ls='--')
ax2.plot(ages, probs, 'g-', linewidth=2)
ax2.set_title('Probability vs Age'); ax2.set_xlabel('Age')
ax2.set_ylabel('P(high wage)'); ax2.axhline(0.5, color='gray', ls='--')
plt.tight_layout(); plt.show()
print("非線性 log-odds 效應:中年機率最高,年齡兩端下降")
Logistic GAM 非常適合醫療風險預測。例如預測心臟病風險時,年齡的效應可能是 S 型(年輕時幾乎無風險、中年後急升、高齡趨緩),BMI 可能是 J 型(過輕和過重都增風險),血壓可能是單調遞增。GAM 讓每個生理指標都有自己的非線性形狀,同時醫生可以清楚解讀「控制其他因素後,這個指標如何影響風險」。這比黑箱模型(如神經網路)更適合臨床決策。
在數位行銷中,轉換率(點擊→購買)受多個因素影響:用戶在網站停留時間(對數型:前 30 秒資訊量最大)、過去購買次數(邊際遞減)、電子報開信率(閾值效應)。Logistic GAM 可以同時捕捉這些非線性模式,且行銷團隊能逐個變數解讀貢獻——這對制定策略至關重要。
| 方法 | 非線性 | 可解釋性 | 交互作用 | 適用場景 |
|---|---|---|---|---|
| 多元線性迴歸 | ❌ 強制線性 | ⭐⭐⭐⭐⭐ | 可手動加 | 已知關係接近線性時 |
| 多項式迴歸 | ✅ 整體多項式 | ⭐⭐⭐ | 可手動加 | 單一變數、無分段行為 |
| GAM | ✅ 逐變數非線性 | ⭐⭐⭐⭐ | 可手動加 | 多變數、需獨立解讀 |
| 隨機森林 / Boosting | ✅ 完全非參數 | ⭐⭐ | 自動捕捉 | 純預測優先、不需解釋 |
| 神經網路 | ✅ 任意複雜度 | ⭐ | 自動捕捉 | 極大資料、黑箱可接受 |