7.5 平滑樣條

📖 ISLP §7.5 📄 pp. 309–310 ★★★☆☆ ⏱️ 約 25 分鐘
平滑樣條 懲罰最小平方法 有效自由度 LOOCV 偏差-變異權衡 非線性建模
← 7.4 迴歸樣條 📑 課程首頁 7.6 局部迴歸 →

核心精神

「樣條函數」的前幾節——多項式迴歸、階梯函數、基底函數、迴歸樣條——都有個共同煩惱:節點(knot)的位置和數量要怎麼選? 這不只是技術問題,更是一個「人為決定」的偏誤來源。你放 3 個節點和放 5 個節點,得出的曲線完全不同。

平滑樣條(smoothing spline)直接繞過這個問題:它在每一個訓練資料點上都放一個節點,然後用一個懲罰項(penalty)來控制曲線不能太「扭」。節點數量從「你要決定的參數」變成「不用決定的給定值」,取而代之的是單一超參數 λ 來控制平滑程度。

一句話總結:平滑樣條 = 每個點都是節點的自然三次樣條 + λ 控制扭動程度的正則化版本。

7.5.1 平滑樣條的構造

想像你在畫一條穿過資料點的曲線。你希望這條線:

  1. 貼近資料:殘差平方和(RSS)要小
  2. 夠平滑:不要扭來扭去像心電圖

這兩個目標是矛盾的——貼近資料通常需要扭,平滑通常偏離資料。平滑樣條的解決方案是把它們寫成一個帶懲罰的最小平方問題

\[ \underset{g}{\operatorname{minimize}} \sum_{i=1}^{n}\bigl(y_i - g(x_i)\bigr)^2 \;+\; \lambda \int g''(t)^2\,dt \]
(7.11)平滑樣條的目標函數

兩項的意義:

λ 是 平滑參數(smoothing parameter),控制兩者的權衡:

James, Witten, Hastie & Tibshirani (2023) An Introduction to Statistical Learning with Python, §7.5, pp. 309–310. 平滑樣條的原始定義與 LOOCV 計算公式。

Reinsch (1967) Smoothing by Spline Functions, Numerische Mathematik 10:177–183. 平滑樣條的經典演算法(Reinsch algorithm),至今仍是許多套件的底層實作基礎。

Wahba (1990) Spline Models for Observational Data, SIAM. 對平滑樣條的統計理論做了最完整的數學推導,包括有效自由度的嚴格定義。

📊 應用場景:心律監測器的去雜訊

心電圖(ECG)訊號充滿高頻雜訊,但你不能用移動平均(會模糊真正的 QRS 波峰)。平滑樣條的 λ 可以在「保留醫學診斷所需的波形細節」和「濾除測量雜訊」之間精確調整。λ 用交叉驗證自動選擇,醫師不需要手調。

令人驚豔的數學結論是:最小化 (7.11) 的最優解 g(x) 恰好是一個自然三次樣條,節點在每一個唯一的 xᵢ 上——而且它在兩端極值節點之外的區域是線性的!

但它不是你拿 §7.4.3 的基底函數方法直接套到 n 個節點上得出的那個自然三次樣條。它是那個樣條的收縮版本(shrunken version)——λ 控制了收縮的程度。

7.5.2 選擇平滑參數 λ

有效自由度

乍看之下,n 個資料點就有 n 個節點 → n 個參數 → n 個自由度?那不就嚴重過擬合了嗎?

答案在於有效自由度(effective degrees of freedom)。雖然平滑樣條有 n 個名義參數,但 λ 的懲罰把它們緊緊約束住了,實際的「彈性」遠小於 n。

用矩陣語言來說明。平滑樣條的擬合值可以寫成:

\[ \hat{\mathbf{g}}_\lambda = \mathbf{S}_\lambda \,\mathbf{y} \]
(7.12)平滑矩陣(smoother matrix)

其中 Sλ 是一個 n × n 的矩陣(有明確的計算公式,但很技術性,一般套件幫你處理)。有效自由度就是這個矩陣的跡(trace)

\[ df_\lambda = \sum_{i=1}^{n} \{\mathbf{S}_\lambda\}_{ii} \]
(7.13)有效自由度 = 平滑矩陣對角線元素之和

當 λ 從 0 增加到 ∞,dfλ 從 n 下降到 2(一條直線有 2 個自由度:截距和斜率)。

直覺:你可以把 Sλ 的對角線元素 {Sλ}ii 想像成「第 i 個點對自己預測值的影響力」。不受約束時每個點 ≈ 1 → df = n。懲罰越大,每個點的影響力被鄰近點稀釋 → df 下降。

用 LOOCV 選 λ

迴歸樣條要選節點數,平滑樣條要選 λ。自然的答案又是交叉驗證。但這裡有一個驚人的計算捷徑

\[ \text{RSS}_{cv}(\lambda) = \sum_{i=1}^{n}\Bigl(y_i - \hat{g}_\lambda^{(-i)}(x_i)\Bigr)^2 = \sum_{i=1}^{n} \left[\frac{y_i - \hat{g}_\lambda(x_i)}{1 - \{\mathbf{S}_\lambda\}_{ii}}\right]^2 \]
(7.14)平滑樣條的 LOOCV 公式

這個公式的威力:你只需要擬合一次(用全部 n 筆資料),就能算出 n 次留一交叉驗證的誤差。不需要真的跑 n 次擬合。這跟第五章線性迴歸的 LOOCV 公式(5.2)結構完全一樣——只差在 Sλ 取代了 hat matrix H。

📈 應用場景:薪資 vs. 年齡的非線性效應

用 Wage 資料集來看平滑樣條的實際效果。固定 df = 16(紅色)和使用 LOOCV 自動選擇 λ → df = 6.8(藍色)的兩條平滑樣條幾乎重疊——這說明對於這個資料集,16 和 6.8 自由度的擬合差異不大。但 LOOCV 自動選的模型更簡潔(有效的參數更少),且不會過擬合。

Python 實作

# §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("右:λ 大,接近全域直線擬合(欠擬合)")

平滑樣條 vs. 迴歸樣條

特性 迴歸樣條(§7.4) 平滑樣條(§7.5)
節點選擇 人為指定數量與位置 自動:每個 xᵢ 放一個
複雜度控制 增加/減少節點數 調整 λ(懲罰強度)
自由度 離散(加上節點就增加 df) 連續(dfλ 可以取任何值)
超參數調整 CV 選節點數(離散搜尋) CV 選 λ(連續搜尋)
計算特性 擬合快,但需試多組節點 單次擬合較慢(n×n 矩陣),但 LOOCV 有捷徑
邊界行為 需選自然樣條來約束邊界 自動為自然樣條(邊界外線性)

✅ 優點

⚠️ 缺點

Hermes 系統設計啟發:懲罰性自由

自我內化:平滑樣條的核心設計哲學——「賦予最大彈性,再用懲罰約束」——直接啟發了 Hermes 的子 agent 管理策略。給每個子 agent 完整的工具權限(n 個節點),但用明確的規則與監控(λ)來防止它們偏離目標。就像 λ 將有效自由度從 n 降到 6.8,好的 agent 設計不是限制能力,而是用「後果約束」來控制實際行為。

具體來說:

平滑樣條教我們:自由不是二元的。最好的系統不是「給不給權限」,而是「給全部權限,再用懲罰機制讓行為收斂到合理範圍」。 — ISLP §7.5 內化;設計原則應用於 Hermes 子 agent 治理
← 7.4 迴歸樣條 📑 課程首頁 7.6 局部迴歸 →