想像你是球隊教練,要選出一個「最佳球員」來面對關鍵賽事。你只有一群表現普通的球員(弱學習者),沒有超級明星。該怎麼辦?集成學習(Ensemble Learning) 給出的答案很直覺:讓每個人都投一票,最後取多數意見。
本節涵蓋四種以決策樹為基礎的集成方法:Bagging(自助抽樣聚合)、隨機森林(Random Forest)、Boosting(提升法)、以及 BART(貝氏加法迴歸樹)。它們的共同核心是:「眾樹成林,集體智慧勝過個體。」
回憶第 5 章的 Bootstrap:從原始資料中反覆抽樣(有放回),得到多個不同的訓練集,藉此估計某個統計量的變異性。Bagging(Bootstrap Aggregating)就是把這個想法應用到預測模型上:
對分類問題,則改為 多數決(majority vote):讓 B 棵樹各自投票,選出最常出現的類別。
Bagging 有一個神奇的特性:你不需要做交叉驗證就能估計測試誤差。每次 Bootstrap 抽樣時,大約有 1/3 的觀測值不會被抽到。這些「沒被選中的觀測值」就叫 Out-of-Bag (OOB) 樣本。
對於第 i 筆觀測值,我們可以只用那些「沒包含第 i 筆資料」的樹來預測它,然後把這些預測平均起來(或多數決)。對所有 n 筆資料都這樣做,就得到 OOB 誤差——這等價於留一交叉驗證(LOOCV),但計算量極小。
Bagging 犧牲了可解釋性(無法畫出一棵「代表樹」),但可以透過 變數重要性 指標來補償:記錄每棵樹中,某個變數被用作分割時,RSS(回歸)或 Gini 指數(分類)下降了多少,然後對所有 B 棵樹取平均。下降越多 → 該變數越重要。
# Bagging on Heart data — 獨立可執行區塊
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 sklearn.ensemble import BaggingClassifier
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score
# 載入 Heart 資料
heart = pd.read_csv(DATA_PATH + 'Heart.csv')
# 移除遺漏值
heart = heart.dropna()
# 類別變數編碼
heart = pd.get_dummies(heart, columns=['ChestPain', 'Thal'], drop_first=True)
X = heart.drop(['AHD', 'Unnamed: 0'], axis=1)
# 將 Yes/No 轉換為 1/0
X['Sex'] = (X['Sex'] == 'Male').astype(int)
y = (heart['AHD'] == 'Yes').astype(int)
# 分割
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42, stratify=y)
# 單棵樹基準
tree = DecisionTreeClassifier(random_state=42)
tree.fit(X_train, y_train)
tree_acc = accuracy_score(y_test, tree.predict(X_test))
print(f"單棵決策樹測試準確率: {tree_acc:.3f}")
# Bagging: 500 棵樹,開啟 OOB
bag = BaggingClassifier(
estimator=DecisionTreeClassifier(random_state=42),
n_estimators=500, oob_score=True, random_state=42, n_jobs=-1)
bag.fit(X_train, y_train)
bag_acc = accuracy_score(y_test, bag.predict(X_test))
print(f"Bagging (500 樹) 測試準確率: {bag_acc:.3f}")
print(f"OOB 分數: {bag.oob_score_:.3f}")
# 變數重要性
importances = pd.Series(
np.mean([tree.feature_importances_
for tree in bag.estimators_], axis=0),
index=X.columns)
importances.sort_values().tail(10).plot.barh(figsize=(8, 5), color='steelblue')
plt.title('Bagging 變數重要性 (Heart 資料)')
plt.xlabel('平均 Gini 下降')
plt.tight_layout()
plt.savefig('/tmp/bagging_importance.png', dpi=100)
plt.show()
print("Top 5 變數:", importances.sort_values(ascending=False).head(5).to_dict())
醫院收集了病人的 13 項指標(年齡、膽固醇、最大心率等),想預測哪些病人有高風險。單一醫生(一棵樹)可能看漏關鍵跡象,但 500 位醫生投票(Bagging)的準確度明顯更高。OOB 誤差讓你不用「再找一批病人驗證」,直接拿原始資料就能評估模型好壞。
Bagging 看似完美,但有一個問題:如果資料中有一個超強的預測變數,幾乎每棵 Bootstrap 樹都會在第一層分割選它。結果是——所有樹長得很像,預測值高度相關。根據統計原理,對高度相關的量取平均,變異下降有限。
隨機森林加了一條規定:每次分割時,只從 p 個變數中隨機抽出 m 個候選,只能從這 m 個中挑最佳分割點。通常取 \(m \approx \sqrt{p}\)(分類)或 \(m \approx p/3\)(回歸)。
這等於「故意不讓最強的變數每次都登場」,強迫其他變數也有表現機會。樹之間被去相關化(decorrelated)了,平均後的變異才能真正被壓低。
| m 的取值 | 效果 | 適用情境 |
|---|---|---|
| m = p | 等價於 Bagging | 變數之間相關性低 |
| m ≈ √p(分類) | 最佳去相關化 | 預設推薦值 |
| m ≈ p/3(回歸) | 適度去相關化 | 回歸問題 |
| m 很小 | 大量去相關化 | 大量相關變數(如基因表現資料) |
# Random Forest vs Bagging on Heart data
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 sklearn.ensemble import RandomForestClassifier, BaggingClassifier
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import train_test_split, cross_val_score
from sklearn.metrics import accuracy_score
# 資料準備(與 Bagging 相同)
heart = pd.read_csv(DATA_PATH + 'Heart.csv').dropna()
heart = pd.get_dummies(heart, columns=['ChestPain', 'Thal'], drop_first=True)
X = heart.drop(['AHD', 'Unnamed: 0'], axis=1)
X['Sex'] = (X['Sex'] == 'Male').astype(int)
y = (heart['AHD'] == 'Yes').astype(int)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42, stratify=y)
# 比較不同 m 值的隨機森林
p = X.shape[1]
m_values = [int(np.sqrt(p)), p//3, p//2, p] # m=p 即 Bagging
results = []
for m in m_values:
is_bagging = (m == p)
name = f'Bagging (m=p)' if is_bagging else f'RF m={m}'
if is_bagging:
rf = BaggingClassifier(
estimator=DecisionTreeClassifier(random_state=42),
n_estimators=300, random_state=42, n_jobs=-1)
else:
rf = RandomForestClassifier(
n_estimators=300, max_features=m, random_state=42, n_jobs=-1)
rf.fit(X_train, y_train)
test_acc = accuracy_score(y_test, rf.predict(X_test))
cv_scores = cross_val_score(rf, X_train, y_train, cv=5, n_jobs=-1)
results.append({'model': name, 'test_acc': test_acc,
'cv_mean': cv_scores.mean(), 'cv_std': cv_scores.std()})
print(f"{name}: Test={test_acc:.3f}, CV={cv_scores.mean():.3f}+/-{cv_scores.std():.3f}")
# 變數重要性
rf_best = RandomForestClassifier(n_estimators=300, random_state=42, n_jobs=-1)
rf_best.fit(X_train, y_train)
imp = pd.Series(rf_best.feature_importances_, index=X.columns).sort_values()
imp.tail(8).plot.barh(figsize=(8, 5), color='darkorange')
plt.title('Random Forest 變數重要性')
plt.tight_layout()
plt.savefig('/tmp/rf_importance.png', dpi=100)
plt.show()
print("\n=== 比較結果 ===")
for r in results:
print(f" {r['model']}: test={r['test_acc']:.3f}, CV={r['cv_mean']:.3f}")
課本使用的基因表現資料有 4,718 個基因(變數),但只有 349 個病人樣本。這種「p ≫ n」場景中,每個基因之間高度相關。普通 Bagging 幾乎每棵樹都用同一批強基因做分割,沒什麼差異。隨機森林強制每層只抽 m = √p ≈ 22 個基因,讓不同基因有機會在不同樹中發揮,最終測試誤差從單棵樹的 45.7% 降到約 25%。
Bagging 和隨機森林是「平行作業」——每棵樹獨立長大,最後投票表決。Boosting 則是「循序漸進」:下一棵樹專門修正前一棵樹犯的錯。想像你在學數學:與其一次做完 100 題,不如每題寫完後檢討哪裡錯、針對弱點加強。
| 參數 | 意義 | 典型值 | 注意事項 |
|---|---|---|---|
| B(樹的數量) | Boosting 迭代次數 | 1000–5000 | ⚠️ 會過擬合(不像 Bagging/RF) |
| λ(收縮率) | 每棵樹的貢獻權重 | 0.01 或 0.001 | λ 越小 → 需要越多棵樹 B |
| d(互動深度) | 每棵樹的分割次數 | d=1(stump)、d=2 | d=1 等價於加法模型 |
# Boosting vs Random Forest on Heart data
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 sklearn.ensemble import GradientBoostingClassifier, RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score
# 資料準備
heart = pd.read_csv(DATA_PATH + 'Heart.csv').dropna()
heart = pd.get_dummies(heart, columns=['ChestPain', 'Thal'], drop_first=True)
X = heart.drop(['AHD', 'Unnamed: 0'], axis=1)
X['Sex'] = (X['Sex'] == 'Male').astype(int)
y = (heart['AHD'] == 'Yes').astype(int)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42, stratify=y)
# Boosting: 嘗試不同互動深度
print("=== Boosting: 不同互動深度 (λ=0.01) ===")
for depth in [1, 2, 3]:
gb = GradientBoostingClassifier(
n_estimators=2000, learning_rate=0.01, max_depth=depth,
random_state=42)
gb.fit(X_train, y_train)
test_acc = accuracy_score(y_test, gb.predict(X_test))
train_acc = accuracy_score(y_train, gb.predict(X_train))
print(f" depth={depth}: Train={train_acc:.3f}, Test={test_acc:.3f}")
# 追蹤不同 n_estimators 下的誤差
gb_track = GradientBoostingClassifier(
n_estimators=1000, learning_rate=0.01, max_depth=1, random_state=42)
gb_track.fit(X_train, y_train)
test_errors = []
train_errors = []
for i, y_pred in enumerate(gb_track.staged_predict(X_test)):
test_errors.append(1 - accuracy_score(y_test, y_pred))
for i, y_pred in enumerate(gb_track.staged_predict(X_train)):
train_errors.append(1 - accuracy_score(y_train, y_pred))
plt.figure(figsize=(8, 5))
plt.plot(range(1, 1001), train_errors, 'b-', alpha=0.5, label='Train')
plt.plot(range(1, 1001), test_errors, 'r-', label='Test')
plt.xlabel('Number of Trees (B)')
plt.ylabel('Error Rate')
plt.title('Boosting Error Rate vs Number of Trees (λ=0.01, depth=1)')
plt.legend()
plt.tight_layout()
plt.savefig('/tmp/boosting_error.png', dpi=100)
plt.show()
# 對比隨機森林
rf = RandomForestClassifier(n_estimators=500, random_state=42, n_jobs=-1)
rf.fit(X_train, y_train)
rf_acc = accuracy_score(y_test, rf.predict(X_test))
print(f"\nRandom Forest (500 trees): Test={rf_acc:.3f}")
銀行想根據客戶的還款紀錄、收入、負債比等指標,預測誰會違約。Boosting 的「慢慢修正錯誤」特性特別適合——前幾棵樹先抓住大方向(高槓桿客戶 → 違約),後續樹逐步修正被誤判的邊緣案例。調參時,d=1(每棵樹只做一次分割 → stump)反而表現最好,因為加法模型不容易過擬合。
BART(Bayesian Additive Regression Trees)結合了 Bagging/RF 和 Boosting 的優點:
| 方法 | 樹之間關係 | 抽樣方式 | 探索策略 |
|---|---|---|---|
| Bagging / RF | 獨立(平行) | Bootstrap / 隨機變數 | 每棵樹從頭建造 |
| Boosting | 依賴(循序) | 原始資料 + 殘差 | 每棵樹專門修正殘差 |
| BART | 依賴(循序) | 原始資料 + 擾動 | 從前一輪的樹做微調,而非從頭建造 |
BART 不像 Boosting 那樣每次都擬合一棵全新的樹去追殘差,而是 拿前一輪的樹,做小幅度修改:
這個「小幅修改」的機制有一個重要的好處:它限制了模型「用力過猛」的程度——不會像 Boosting 那樣在某次迭代突然對殘差過度擬合。BART 的「burn-in」階段(前 L 次迭代)會丟棄,之後的迭代結果取平均作為最終預測。
課本 Figure 8.13 展示了 BART 和 Boosting 在 Heart 資料上的對比:Boosting 的訓練誤差持續下降(過擬合),測試誤差在數百輪後反而上升;BART 的訓練和測試誤差始終非常接近——說明 BART 的樹擾動機制確實有效避免了過擬合。
# BART demo on Heart data using ISLP.bart
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')
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score
from ISLP.bart import BART
# 資料準備
heart = pd.read_csv(DATA_PATH + 'Heart.csv').dropna()
heart = pd.get_dummies(heart, columns=['ChestPain', 'Thal'], drop_first=True)
X_raw = heart.drop(['AHD', 'Unnamed: 0'], axis=1)
X_raw['Sex'] = (X_raw['Sex'] == 'Male').astype(int)
# BART 需要 float64 numpy arrays
X = X_raw.to_numpy(dtype=np.float64)
y = (heart['AHD'] == 'Yes').astype(int).to_numpy(dtype=np.float64)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42)
# BART 擬合(用較少的樹加速 critic 執行)
bart = BART(num_trees=50, burnin=20, random_state=42)
bart.fit(X_train, y_train)
y_prob = bart.predict(X_test)
y_pred = (y_prob > 0.5).astype(int)
test_acc = accuracy_score(y_test, y_pred)
print(f"BART (50 trees, 20 burn-in): Test accuracy = {test_acc:.3f}")
# 變數重要性
var_inc = bart.variable_inclusion_.mean(0)
importance = pd.Series(var_inc, index=X_raw.columns).sort_values(ascending=False)
print("\n變數納入頻率 Top 5:")
for name, val in importance.head(5).items():
print(f" {name}: {val:.3f}")
BART 的一個獨特優勢是可以用預測值的百分位數(percentiles)來量化預測的不確定性——這在臨床決策中非常關鍵。不像 Random Forest 只給你一個點預測,BART 從 burn-in 後的迭代中,可以提供「這個病人對治療 X 的反應有 95% 機率落在 Y 到 Z 之間」的區間估計。
| 特性 | Bagging | 隨機森林 | Boosting | BART |
|---|---|---|---|---|
| 樹的建造方式 | 平行、獨立 | 平行、獨立 | 循序、依賴 | 循序、依賴 |
| 資料來源 | Bootstrap 抽樣 | Bootstrap 抽樣 | 原始資料(修正殘差) | 原始資料(樹的擾動) |
| 變數選擇 | 所有 p 個 | 每層隨機 m 個 | 所有 p 個 | (樹結構隨機擾動) |
| 過擬合風險 | 低(B ↑ 不影響) | 低(B ↑ 不影響) | ⚠️ 中高(B ↑ 會過擬合) | 低(擾動限制過擬合) |
| 可解釋性 | 變數重要性 | 變數重要性(更精確) | 變數重要性 | 變數納入頻率 + 預測不確定性 |
| 計算成本 | 中(可平行) | 中(可平行) | 高(不可平行、需調參) | 高(MCMC 迭代) |
| 核心調參數 | B | B, m | B, λ, d | K, B, L |