「樣條函數」的前幾節——多項式迴歸、階梯函數、基底函數、迴歸樣條——都有個共同煩惱:節點(knot)的位置和數量要怎麼選? 這不只是技術問題,更是一個「人為決定」的偏誤來源。你放 3 個節點和放 5 個節點,得出的曲線完全不同。
平滑樣條(smoothing spline)直接繞過這個問題:它在每一個訓練資料點上都放一個節點,然後用一個懲罰項(penalty)來控制曲線不能太「扭」。節點數量從「你要決定的參數」變成「不用決定的給定值」,取而代之的是單一超參數 λ 來控制平滑程度。
想像你在畫一條穿過資料點的曲線。你希望這條線:
這兩個目標是矛盾的——貼近資料通常需要扭,平滑通常偏離資料。平滑樣條的解決方案是把它們寫成一個帶懲罰的最小平方問題:
兩項的意義:
λ 是 平滑參數(smoothing parameter),控制兩者的權衡:
心電圖(ECG)訊號充滿高頻雜訊,但你不能用移動平均(會模糊真正的 QRS 波峰)。平滑樣條的 λ 可以在「保留醫學診斷所需的波形細節」和「濾除測量雜訊」之間精確調整。λ 用交叉驗證自動選擇,醫師不需要手調。
令人驚豔的數學結論是:最小化 (7.11) 的最優解 g(x) 恰好是一個自然三次樣條,節點在每一個唯一的 xᵢ 上——而且它在兩端極值節點之外的區域是線性的!
但它不是你拿 §7.4.3 的基底函數方法直接套到 n 個節點上得出的那個自然三次樣條。它是那個樣條的收縮版本(shrunken version)——λ 控制了收縮的程度。
乍看之下,n 個資料點就有 n 個節點 → n 個參數 → n 個自由度?那不就嚴重過擬合了嗎?
答案在於有效自由度(effective degrees of freedom)。雖然平滑樣條有 n 個名義參數,但 λ 的懲罰把它們緊緊約束住了,實際的「彈性」遠小於 n。
用矩陣語言來說明。平滑樣條的擬合值可以寫成:
其中 Sλ 是一個 n × n 的矩陣(有明確的計算公式,但很技術性,一般套件幫你處理)。有效自由度就是這個矩陣的跡(trace):
當 λ 從 0 增加到 ∞,dfλ 從 n 下降到 2(一條直線有 2 個自由度:截距和斜率)。
迴歸樣條要選節點數,平滑樣條要選 λ。自然的答案又是交叉驗證。但這裡有一個驚人的計算捷徑:
這個公式的威力:你只需要擬合一次(用全部 n 筆資料),就能算出 n 次留一交叉驗證的誤差。不需要真的跑 n 次擬合。這跟第五章線性迴歸的 LOOCV 公式(5.2)結構完全一樣——只差在 Sλ 取代了 hat matrix H。
用 Wage 資料集來看平滑樣條的實際效果。固定 df = 16(紅色)和使用 LOOCV 自動選擇 λ → df = 6.8(藍色)的兩條平滑樣條幾乎重疊——這說明對於這個資料集,16 和 6.8 自由度的擬合差異不大。但 LOOCV 自動選的模型更簡潔(有效的參數更少),且不會過擬合。
# §7.5 平滑樣條:用 5-fold CV 選擇平滑參數(示範用小樣本)
try:
from google.colab import drive
drive.mount('/content/drive')
DATA_PATH = '/content/drive/MyDrive/ISLP_data/'
except ImportError:
DATA_PATH = '/tmp/'
import numpy as np
import pandas as pd
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from scipy.interpolate import UnivariateSpline
from sklearn.model_selection import KFold
# 載入 Wage 資料,取子集加速示範
try:
from ISLP import load_data
wage = load_data('Wage')
except Exception:
wage = pd.read_csv(DATA_PATH + 'Wage.csv')
# 只用 age 在 [20, 60] 的資料,取 200 筆做示範
mask = (wage['age'] >= 20) & (wage['age'] <= 60)
wage_sub = wage[mask].sample(n=200, random_state=1)
X = wage_sub['age'].values.astype(float)
y = wage_sub['wage'].values.astype(float)
# --- 5-fold CV 選擇最佳平滑參數 s ---
# UnivariateSpline 的 s 控制平滑強度:s=0 完全內插,s 越大越平滑
s_candidates = np.logspace(-1, 3, 12)
cv_scores = []
kf = KFold(n_splits=5, shuffle=True, random_state=42)
for s_val in s_candidates:
fold_errors = []
for train_idx, val_idx in kf.split(X):
X_tr, y_tr = X[train_idx], y[train_idx]
X_val, y_val = X[val_idx], y[val_idx]
# UnivariateSpline requires strictly increasing x
sort_i = np.argsort(X_tr)
spl = UnivariateSpline(X_tr[sort_i], y_tr[sort_i], s=s_val)
preds = spl(X_val)
fold_errors.append(np.mean((y_val - preds) ** 2))
cv_scores.append(np.mean(fold_errors))
best_s = s_candidates[np.argmin(cv_scores)]
print(f"Best smoothing parameter s: {best_s:.1f}")
print(f"CV MSE at best s: {min(cv_scores):.1f}")
# --- 擬合三種平滑參數的平滑樣條 ---
sort_idx = np.argsort(X)
X_s, y_s = X[sort_idx], y[sort_idx]
x_grid = np.linspace(X.min(), X.max(), 200)
spl_loose = UnivariateSpline(X_s, y_s, s=best_s * 0.02) # λ≈0
spl_best = UnivariateSpline(X_s, y_s, s=best_s) # CV 選出的
spl_tight = UnivariateSpline(X_s, y_s, s=best_s * 50) # λ 大 → 趨近直線
fig, ax = plt.subplots(figsize=(9, 4.5))
ax.scatter(X, y, s=8, alpha=0.4, color='gray', label='Data')
ax.plot(x_grid, spl_loose(x_grid), 'r-', lw=2, alpha=0.7, label='s small (wiggly)')
ax.plot(x_grid, spl_best(x_grid), 'b-', lw=2.5, label=f's={best_s:.0f} (CV optimal)')
ax.plot(x_grid, spl_tight(x_grid), 'g--', lw=2, label='s large (near-linear)')
ax.set_xlabel('Age'); ax.set_ylabel('Wage (k$)')
ax.set_title('Smoothing Spline: Effect of s (1/s ∝ λ)')
ax.legend(fontsize=9)
plt.tight_layout()
plt.show()
print(f"\nCV 選出的 s={best_s:.0f} 在貼近資料和平滑之間取得平衡。")
print(f"s 很小時曲線貼近每個點(高變異),s 很大時趨近直線(高偏差)。")
# 視覺化:λ 對平滑程度的影響
try:
from google.colab import drive
drive.mount('/content/drive')
DATA_PATH = '/content/drive/MyDrive/ISLP_data/'
except ImportError:
DATA_PATH = '/tmp/'
import numpy as np
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from scipy.interpolate import UnivariateSpline
# 生成示範資料:sin 曲線 + 雜訊
np.random.seed(42)
n_sample = 80
x_demo = np.sort(np.random.uniform(0, 10, n_sample))
y_true = np.sin(x_demo) + 0.3 * x_demo
y_demo = y_true + np.random.normal(0, 0.3, n_sample)
# 三個不同的平滑參數
s_values = [0, 1.0, 50.0]
labels = ['λ≈0 (完全內插)', 'λ 適中 (平滑擬合)', 'λ→∞ (趨近直線)']
colors = ['#f85149', '#3fb950', '#58a6ff']
styles = ['-', '-', '-']
fig, axes = plt.subplots(1, 3, figsize=(15, 4))
for ax, s, label, color in zip(axes, s_values, labels, colors):
spl = UnivariateSpline(x_demo, y_demo, s=s)
x_grid = np.linspace(0, 10, 200)
ax.scatter(x_demo, y_demo, s=12, alpha=0.5, color='gray')
ax.plot(x_grid, spl(x_grid), color=color, linewidth=2)
ax.plot(x_grid, y_true[:len(x_grid)] if len(y_true)==len(x_grid) else np.sin(x_grid) + 0.3*x_grid,
'k--', alpha=0.3, linewidth=1, label='True f(x)' if ax == axes[0] else '')
ax.set_title(label, fontsize=11)
ax.set_xlabel('x'); ax.set_ylabel('y')
plt.suptitle('Smoothing Spline: Effect of λ on Flexibility', fontsize=13)
plt.tight_layout()
plt.show()
print("左:λ=0,完全信任每個資料點,完美內插(過擬合)")
print("中:λ 適中,追隨趨勢但保持平滑(偏差-變異平衡)")
print("右:λ 大,接近全域直線擬合(欠擬合)")
| 特性 | 迴歸樣條(§7.4) | 平滑樣條(§7.5) |
|---|---|---|
| 節點選擇 | 人為指定數量與位置 | 自動:每個 xᵢ 放一個 |
| 複雜度控制 | 增加/減少節點數 | 調整 λ(懲罰強度) |
| 自由度 | 離散(加上節點就增加 df) | 連續(dfλ 可以取任何值) |
| 超參數調整 | CV 選節點數(離散搜尋) | CV 選 λ(連續搜尋) |
| 計算特性 | 擬合快,但需試多組節點 | 單次擬合較慢(n×n 矩陣),但 LOOCV 有捷徑 |
| 邊界行為 | 需選自然樣條來約束邊界 | 自動為自然樣條(邊界外線性) |
具體來說: