Day 059 — 決定木(Decision Tree)— 情報利得・木の深さ・過学習の直感

2026-06-09 緑 / Phase 2 理論→コーディング DecisionTree・Gini・max_depth・特徴量重要度

📚 背景知識(読んでから問題へ)

🎯
Day 059 — 決定木は「フローチャート」です。データを条件で分岐させて予測する、最も直感的なMLモデルです。今日はジニ不純度・情報利得・過学習の制御を体験します。

決定木 = 自動生成されるフローチャート

銀行ローンの審査フローを考えてみてください。「年収 > 500万か?」→「勤続年数 > 3年か?」という決定ルールを、アルゴリズムがデータから自動的に作り上げます。

分岐の基準:なぜ性別から聞くのか

Titanicで「最初に何を聞けば生存/非生存を最もうまく分けられるか?」を考えたとき、性別が最も強力な分割になります。この「有効な分岐」をジニ不純度(Gini Impurity)で測定します。

🌳 決定木の構造(Titanic max_depth=3 イメージ)

根ノードから葉ノードまで条件で分岐。葉ノードの多数決で予測。

Root Sex_enc ≤ 0.5 ? Yes (female) No (male) Node L Pclass ≤ 2.5 ? Node R Age ≤ 13.0 ? 葉: 生存 1/2/3等・女性 Pred = 1 葉: 死亡 3等・女性 Pred = 0 葉: 生存 男・子供(≤13) Pred = 1 葉: 死亡 男・成人 Pred = 0

📊 ジニ不純度の可視化

Gini = 1 − (p₀² + p₁²)。0 = 純粋(1クラスのみ)、0.5 = 最大不純(50/50)

陽性 100%, 陰性 0% — 完全純粋
0.00
Gini=0.00
陽性 80%, 陰性 20%
0.32
Gini=0.32
陽性 60%, 陰性 40%
0.48
Gini=0.48
陽性 50%, 陰性 50% — 最大不純
0.50
Gini=0.50
陽性 10%, 陰性 90%
0.18
Gini=0.18
陽性 0%, 陰性 100% — 完全純粋
0.00
Gini=0.00

Gini 曲線(x軸: 陽性割合 p、y軸: Gini値)

陽性割合 p Gini 0 0.25 0.50 0 0.5 1.0 最大(p=0.5) p=0.2

⚙️ 主要パラメータ

max_depth
木の最大深さ。最重要パラメータ。None=制限なし(過学習危険)。3〜10 が典型的な範囲。
min_samples_split
ノードを分割するための最小サンプル数。デフォルト=2。大きくするとシンプルな木になる。
min_samples_leaf
葉ノードの最小サンプル数。デフォルト=1。大きくすると過学習防止に有効。
criterion
"gini"(デフォルト)または "entropy"。実用上は gini のほうが計算が速い。
max_features
各分岐で考慮する特徴量数。None=全特徴量。RandomForest では "sqrt" が標準。
ccp_alpha
コスト複雑度剪定の係数。0=剪定なし。大きいほど木が小さくなる(過学習対策)。

📉 max_depth と過学習(訓練精度 vs CV精度)

深さが増えると訓練精度は 100% に近づくが、CV精度はある深さで頭打ちになり低下する。

max_depth 0.60 0.75 0.90 1.00 1 5 10 15 20 訓練精度 CV精度 最適深さ付近

観察: max_depth が大きくなると訓練精度は 100% に近づくが、CV精度はある点で低下(過学習)。

🗂️ データスキーマ(Titanic 疑似データ)

列名値の範囲説明
Pclassint1, 2, 3旅客クラス(1=1等、3=3等)
Sexstrmale / female性別
Sex_encint0 / 1性別のラベルエンコード済み
Agefloat1〜80年齢(欠損なし・疑似データ)
SibSpint0〜8同乗している兄弟/配偶者数
Farefloat0〜∞運賃
Survivedint0 / 1生存(1=生存、0=死亡)★目的変数

クラス分布(生存率は女性・1等客で高く設定)

死亡(0) ≈ 62% 生存(1) ≈ 38%

📝 問題

import numpy as np
import pandas as pd
from sklearn.tree import DecisionTreeClassifier, export_text, plot_tree
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.preprocessing import LabelEncoder
from sklearn.metrics import accuracy_score, classification_report
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore')

# Titanic データの簡易作成(実環境では pd.read_csv('train.csv') を使う)
np.random.seed(42)
n = 891

data = pd.DataFrame({
    'Pclass': np.random.choice([1, 2, 3], n, p=[0.24, 0.21, 0.55]),
    'Sex':    np.random.choice(['male', 'female'], n, p=[0.65, 0.35]),
    'Age':    np.random.normal(29.7, 14.5, n).clip(1, 80),
    'SibSp':  np.random.choice(range(9), n, p=[0.68,0.23,0.04,0.02,0.01,0.01,0.005,0.005,0.01]),
    'Fare':   np.abs(np.random.normal(32, 50, n)),
})
data['Survived'] = (
    (data['Sex'] == 'female') * 0.5 +
    (data['Pclass'] == 1) * 0.3 +
    (data['Age'] < 16) * 0.2 +
    np.random.normal(0, 0.15, n)
) > 0.4
data['Survived'] = data['Survived'].astype(int)

data['Sex_enc'] = LabelEncoder().fit_transform(data['Sex'])
features = ['Pclass', 'Sex_enc', 'Age', 'SibSp', 'Fare']
X = data[features].values
y = data['Survived'].values

問1 — 過学習の確認

  1. DecisionTreeClassifier(random_state=42)(深さ制限なし)で学習し、訓練精度とCV精度(StratifiedKFold(5))を比較せよ
  2. max_depth=3 のモデルでも同様に比較せよ
  3. 「訓練精度 - CV精度」を計算し、どちらが過学習しているかを示せ

問2 — 木の可視化と解釈

  1. max_depth=3 のモデルを学習させ、export_text() でルールをテキスト出力せよ
  2. 最上位(深さ1)の分岐に使われた特徴量を確認せよ
  3. その特徴量がなぜ最初に選ばれたか(ジニ不純度の観点から)を説明せよ

問3 — max_depth のチューニング

max_depth を 1〜20 で変化させながら:

  1. 各深さで訓練精度とCV精度を記録せよ
  2. CV精度が最大になる max_depth を特定せよ
  3. 過学習が始まる深さの境界を観察せよ

問4 — 特徴量重要度

  1. 最適な max_depth のモデルから feature_importances_ を取得せよ
  2. 特徴量を重要度順に並べて出力せよ
  3. 最も重要な特徴量の意味をTitanicの文脈で説明せよ

問5 — ジニ不純度の手計算

以下のグループのジニ不純度を Gini = 1 − (p₀² + p₁²) で手計算せよ:

  • グループA: 陽性 80人, 陰性 20人
  • グループB: 陽性 10人, 陰性 90人

💡 ヒント

ヒント1(方向性)
  • 訓練精度: clf.score(X, y)(全データで評価)
  • CV精度: cross_val_score(clf, X, y, cv=StratifiedKFold(5), scoring='accuracy').mean()
  • 深さ無制限の木は訓練精度 100% 近くになる(データを丸暗記)
  • 根ノードの特徴量: clf.tree_.feature[0]
ヒント2(アプローチ)
# 訓練精度とCV精度の比較
for label, depth in [("制限なし", None), ("max_depth=3", 3)]:
    clf = DecisionTreeClassifier(max_depth=depth, random_state=42)
    clf.fit(X, y)
    tr = clf.score(X, y)
    cv_s = cross_val_score(clf, X, y,
                           cv=StratifiedKFold(5, shuffle=True, random_state=42),
                           scoring='accuracy').mean()
    print(f"[{label}] 訓練: {tr:.4f}, CV: {cv_s:.4f}, 差: {tr-cv_s:.4f}")

# 木のルールを確認
print(export_text(clf3, feature_names=features))
ヒント3(コード骨格)
# max_depth チューニング
depths = range(1, 21)
train_scores, cv_scores = [], []
cv = StratifiedKFold(5, shuffle=True, random_state=42)

for d in depths:
    clf = DecisionTreeClassifier(max_depth=d, random_state=42)
    clf.fit(X, y)
    train_scores.append(clf.score(X, y))
    cv_scores.append(cross_val_score(clf, X, y, cv=cv, scoring='accuracy').mean())

best_depth = list(depths)[np.argmax(cv_scores)]
print(f"最適 max_depth: {best_depth}")

# 特徴量重要度
clf_best = DecisionTreeClassifier(max_depth=best_depth, random_state=42)
clf_best.fit(X, y)
for f, imp in sorted(zip(features, clf_best.feature_importances_), key=lambda x: -x[1]):
    print(f"{f}: {imp:.4f}")

模範解答

import numpy as np
import pandas as pd
from sklearn.tree import DecisionTreeClassifier, export_text
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.preprocessing import LabelEncoder
import warnings
warnings.filterwarnings('ignore')

# データ準備(問題文と同じ)
np.random.seed(42)
n = 891
data = pd.DataFrame({
    'Pclass': np.random.choice([1, 2, 3], n, p=[0.24, 0.21, 0.55]),
    'Sex':    np.random.choice(['male', 'female'], n, p=[0.65, 0.35]),
    'Age':    np.random.normal(29.7, 14.5, n).clip(1, 80),
    'SibSp':  np.random.choice(range(9), n, p=[0.68,0.23,0.04,0.02,0.01,0.01,0.005,0.005,0.01]),
    'Fare':   np.abs(np.random.normal(32, 50, n)),
})
data['Survived'] = (
    (data['Sex'] == 'female') * 0.5 + (data['Pclass'] == 1) * 0.3 +
    (data['Age'] < 16) * 0.2 + np.random.normal(0, 0.15, n)
) > 0.4
data['Survived'] = data['Survived'].astype(int)
data['Sex_enc'] = LabelEncoder().fit_transform(data['Sex'])
features = ['Pclass', 'Sex_enc', 'Age', 'SibSp', 'Fare']
X = data[features].values
y = data['Survived'].values

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

# ── 問1: 過学習の確認 ──
print("=" * 55)
print("問1: 過学習の確認")
print("=" * 55)
for label, depth in [("制限なし", None), ("max_depth=3", 3)]:
    clf = DecisionTreeClassifier(max_depth=depth, random_state=42)
    clf.fit(X, y)
    tr = clf.score(X, y)
    cv_score = cross_val_score(clf, X, y, cv=cv, scoring='accuracy').mean()
    print(f"[{label}] 訓練精度: {tr:.4f}, CV精度: {cv_score:.4f}, 差(過学習度): {tr-cv_score:.4f}")

# ── 問2: 木の可視化 ──
print("\n" + "=" * 55)
print("問2: 決定木のルール(max_depth=3)")
print("=" * 55)
clf3 = DecisionTreeClassifier(max_depth=3, random_state=42)
clf3.fit(X, y)
print(export_text(clf3, feature_names=features))
print(f"最初の分岐特徴量: {features[clf3.tree_.feature[0]]}")
print("理由: ジニ不純度を最も下げる特徴量が根ノードに選ばれる。")
print("Sex_enc(性別)はTitanicで最も生存率に影響するため先に選ばれる。")

# ── 問3: max_depth チューニング ──
print("\n" + "=" * 55)
print("問3: max_depth チューニング")
print("=" * 55)
depths = range(1, 21)
train_scores, cv_scores = [], []
for d in depths:
    clf = DecisionTreeClassifier(max_depth=d, random_state=42)
    clf.fit(X, y)
    train_scores.append(clf.score(X, y))
    cv_scores.append(cross_val_score(clf, X, y, cv=cv, scoring='accuracy').mean())

best_depth = list(depths)[np.argmax(cv_scores)]
print(f"最適な max_depth: {best_depth}, CV精度: {max(cv_scores):.4f}")
print("\ndepth | 訓練精度 | CV精度  | 過学習度")
for d, tr, cv_s in zip(depths, train_scores, cv_scores):
    mark = " <- best" if d == best_depth else ""
    print(f"  {d:2d}  | {tr:.4f}  | {cv_s:.4f} | {tr-cv_s:.4f}{mark}")

# ── 問4: 特徴量重要度 ──
print("\n" + "=" * 55)
print("問4: 特徴量重要度")
print("=" * 55)
clf_best = DecisionTreeClassifier(max_depth=best_depth, random_state=42)
clf_best.fit(X, y)
importances = clf_best.feature_importances_
order = np.argsort(importances)[::-1]
for i, idx in enumerate(order):
    print(f"{i+1}. {features[idx]:10s}: {importances[idx]:.4f}")
print(f"\n最重要特徴量: {features[order[0]]}")
print("解釈: Titanicでは性別(女性優先避難)が生存率を最も大きく左右した")

# ── 問5: ジニ不純度の手計算 ──
print("\n" + "=" * 55)
print("問5: ジニ不純度の手計算")
print("=" * 55)
def gini(pos, neg):
    total = pos + neg
    p_pos, p_neg = pos / total, neg / total
    return 1 - (p_pos**2 + p_neg**2)

ga = gini(80, 20)
gb = gini(10, 90)
print(f"グループA (陽性80, 陰性20): Gini = 1 - (0.8² + 0.2²) = {ga:.4f}")
print(f"グループB (陽性10, 陰性90): Gini = 1 - (0.1² + 0.9²) = {gb:.4f}")
print("どちらも多数派に偏っており純粋に近い(Giniが低い)")
print("A と B は対称なので同じ Gini 値になる(0.32)")

🪜 Step-by-Step 解説

1決定木の学習プロセス

# 決定木は「どの特徴量・どの閾値で分岐すれば不純度が最も下がるか」を全探索する
# 計算量: 特徴量数 × サンプル数 × 深さ

clf = DecisionTreeClassifier(max_depth=3, random_state=42)
clf.fit(X, y)

# 木の内部構造にアクセス
tree = clf.tree_
print(f"ノード数: {tree.node_count}")
print(f"木の深さ: {tree.max_depth}")
print(f"根ノードの特徴量: {features[tree.feature[0]]}")
print(f"根ノードの閾値: {tree.threshold[0]:.3f}")
print(f"根ノードのGini: {tree.impurity[0]:.4f}")

2ジニ不純度を自分で計算して照合

# sklearn が計算したジニ不純度と手計算を照合
node_id = 0  # 根ノード
total = tree.n_node_samples[node_id]
neg_samples = tree.value[node_id][0][0]  # クラス0の数
pos_samples = tree.value[node_id][0][1]  # クラス1の数
p0 = neg_samples / total
p1 = pos_samples / total
gini_manual = 1 - (p0**2 + p1**2)

print(f"sklearn が計算した Gini: {tree.impurity[node_id]:.4f}")
print(f"手動計算の Gini:         {gini_manual:.4f}")
# → 同じ値になれば理解 OK

3depth チューニングのプロット

import matplotlib.pyplot as plt

plt.figure(figsize=(10, 5))
depths = list(range(1, 21))
plt.plot(depths, train_scores, 'o-', color='#ef4444', label='訓練精度')
plt.plot(depths, cv_scores, 's-', color='#22c55e', label='CV精度(5-Fold)')
plt.axvline(x=best_depth, color='#fbbf24', linestyle='--', label=f'最適深さ (d={best_depth})')
plt.xlabel('max_depth')
plt.ylabel('Accuracy')
plt.title('決定木の深さと過学習')
plt.legend()
plt.grid(alpha=0.3)
plt.tight_layout()
plt.show()

# 読み方:
# - 赤線(訓練精度): 深さとともに単調増加し、最終的に 100% に近づく
# - 緑線(CV精度): ある深さで最大になり、それ以降は低下(過学習)
# - 2本の線の差 = 過学習度

4特徴量重要度の棒グラフ

import pandas as pd
import matplotlib.pyplot as plt

imp_df = pd.DataFrame({
    'feature': features,
    'importance': clf_best.feature_importances_
}).sort_values('importance', ascending=True)

plt.figure(figsize=(8, 4))
plt.barh(imp_df['feature'], imp_df['importance'], color='#38bdf8')
plt.xlabel('特徴量重要度')
plt.title(f'特徴量重要度(max_depth={best_depth})')
plt.tight_layout()
plt.show()

# 重要度 = その特徴量を使った分岐による不純度の減少量の合計(全ノード)
# 合計は必ず 1.0 になる

5OOF(Out-of-Fold)予測でモデルを評価

from sklearn.model_selection import cross_val_predict
from sklearn.metrics import classification_report

# OOF 予測(各サンプルは必ず学習に使われていない Fold で予測)
oof_pred = cross_val_predict(
    DecisionTreeClassifier(max_depth=best_depth, random_state=42),
    X, y,
    cv=StratifiedKFold(5, shuffle=True, random_state=42)
)

print(classification_report(y, oof_pred, target_names=['死亡', '生存']))
# Precision, Recall, F1 を確認して、精度だけでなく不均衡への対応を評価

📐 数学・統計の補足(文系向け)

ジニ不純度の直感(コインの例え)

袋からコインを2枚引いたとき、「2枚が異なるクラスになる確率」がジニ不純度に比例します。

  • 全部が陽性(100%): 絶対に同じクラス → 不純度 0(純粋)
  • 50%ずつ: 50% の確率で別クラス → 最大不純度 0.5

数式: Gini = 1 − Σ pᵢ²

手計算例: グループA(陽性80%, 陰性20%)

Gini = 1 − (0.8² + 0.2²) = 1 − (0.64 + 0.04) = 1 − 0.68 = 0.32

情報利得の直感

「分岐前のごちゃ混ぜ度」から「分岐後の(加重平均)ごちゃ混ぜ度」を引いた値です。

大きいほど「有益な分割」。決定木はこれを最大化する分岐点を探します。

バイアス・バリアントレードオフ

状態訓練精度CV精度原因
未学習(高バイアス)低い低いmax_depth が小さすぎる
適切高め高い最適な max_depth
過学習(高バリアンス)非常に高い低いmax_depth が大きすぎる

🏆 Kaggleでの実践的な使い方

場面決定木の役割備考
初期EDA特徴量重要度で「効く変数」を絞り込むGBDTの前段として有効
ルール可視化モデルの判断根拠をドメインエキスパートに説明export_text() で即座に出力
ベースライン最もシンプルな予測モデルとして機能その後 RF/XGBoost で改善
過学習の体験depth なしで「丸暗記」を実感バイアス-バリアンス理解の教材
# Kaggle でよく使うパターン: EDA での特徴量優先度確認
from sklearn.tree import DecisionTreeClassifier
import pandas as pd

clf = DecisionTreeClassifier(max_depth=5, random_state=42)
clf.fit(X_train, y_train)

importance_df = pd.DataFrame({
    'feature': feature_names,
    'importance': clf.feature_importances_
}).sort_values('importance', ascending=False)

print(importance_df.head(10))
# → 重要な上位特徴量を特徴量エンジニアリングで深掘りする

🚫 よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
訓練精度が高いから良いモデルと思う 過学習を知らない CV精度が本当の性能。訓練精度との差を必ず確認する
決定木も StandardScaler が必要と思う 線形モデルと混同 決定木は閾値の比較のみ。スケールに依存しない
max_depth を大きくするほど良くなる 訓練精度だけ見ている 過学習が進み CV 精度は低下。チューニング必須
特徴量重要度 = 因果関係と思う 統計的相関と因果を混同 重要度は「予測に役立つ度合い」。因果関係ではない
決定木をそのまま本番で使う アンサンブルを知らない 単体の決定木は不安定でスコアが低い。RandomForest/XGBoost を使う

🚀 次のステップ

  • 発展: min_samples_leafccp_alpha(コスト複雑度剪定)でチューニングしてみる
  • 次回予告(Day 060): ランダムフォレスト — バギング・特徴量重要度・OOBスコア(決定木の弱点を克服)

📋 自己評価(解いた後に記入)

✍️

理解度: [ ] 完全理解   [ ] おおむね理解   [ ] 要復習

自分の回答:

気づき・メモ: