📚 背景知識(読んでから問題へ)
🎯
Day 059 — 決定木は「フローチャート」です。データを条件で分岐させて予測する、最も直感的なMLモデルです。今日はジニ不純度・情報利得・過学習の制御を体験します。
決定木 = 自動生成されるフローチャート
銀行ローンの審査フローを考えてみてください。「年収 > 500万か?」→「勤続年数 > 3年か?」という決定ルールを、アルゴリズムがデータから自動的に作り上げます。
分岐の基準:なぜ性別から聞くのか
Titanicで「最初に何を聞けば生存/非生存を最もうまく分けられるか?」を考えたとき、性別が最も強力な分割になります。この「有効な分岐」をジニ不純度(Gini Impurity)で測定します。
🌳 決定木の構造(Titanic max_depth=3 イメージ)
根ノードから葉ノードまで条件で分岐。葉ノードの多数決で予測。
📊 ジニ不純度の可視化
Gini = 1 − (p₀² + p₁²)。0 = 純粋(1クラスのみ)、0.5 = 最大不純(50/50)
Gini 曲線(x軸: 陽性割合 p、y軸: Gini値)
⚙️ 主要パラメータ
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 が大きくなると訓練精度は 100% に近づくが、CV精度はある点で低下(過学習)。
🗂️ データスキーマ(Titanic 疑似データ)
| 列名 | 型 | 値の範囲 | 説明 |
|---|---|---|---|
Pclass | int | 1, 2, 3 | 旅客クラス(1=1等、3=3等) |
Sex | str | male / female | 性別 |
Sex_enc | int | 0 / 1 | 性別のラベルエンコード済み |
Age | float | 1〜80 | 年齢(欠損なし・疑似データ) |
SibSp | int | 0〜8 | 同乗している兄弟/配偶者数 |
Fare | float | 0〜∞ | 運賃 |
Survived | int | 0 / 1 | 生存(1=生存、0=死亡)★目的変数 |
クラス分布(生存率は女性・1等客で高く設定)
📝 問題
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 — 過学習の確認
DecisionTreeClassifier(random_state=42)(深さ制限なし)で学習し、訓練精度とCV精度(StratifiedKFold(5))を比較せよmax_depth=3のモデルでも同様に比較せよ- 「訓練精度 - CV精度」を計算し、どちらが過学習しているかを示せ
問2 — 木の可視化と解釈
max_depth=3のモデルを学習させ、export_text()でルールをテキスト出力せよ- 最上位(深さ1)の分岐に使われた特徴量を確認せよ
- その特徴量がなぜ最初に選ばれたか(ジニ不純度の観点から)を説明せよ
問3 — max_depth のチューニング
max_depth を 1〜20 で変化させながら:
- 各深さで訓練精度とCV精度を記録せよ
- CV精度が最大になる
max_depthを特定せよ - 過学習が始まる深さの境界を観察せよ
問4 — 特徴量重要度
- 最適な
max_depthのモデルからfeature_importances_を取得せよ - 特徴量を重要度順に並べて出力せよ
- 最も重要な特徴量の意味を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%)
情報利得の直感
「分岐前のごちゃ混ぜ度」から「分岐後の(加重平均)ごちゃ混ぜ度」を引いた値です。
大きいほど「有益な分割」。決定木はこれを最大化する分岐点を探します。
バイアス・バリアントレードオフ
| 状態 | 訓練精度 | 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_leafやccp_alpha(コスト複雑度剪定)でチューニングしてみる - 次回予告(Day 060): ランダムフォレスト — バギング・特徴量重要度・OOBスコア(決定木の弱点を克服)
📋 自己評価(解いた後に記入)
✍️
理解度: [ ] 完全理解 [ ] おおむね理解 [ ] 要復習
自分の回答:
気づき・メモ: