Day 058 — 交差検証の深掘り — Stratified K-Fold・繰り返し CV・Leave-One-Out の使い分け

2026-06-08 緑 / Phase 2 コーディング 交差検証・StratifiedKFold・LOOCV・RepeatedCV

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

🎯
Day 058 — Day 051 で K-Fold の基礎を学びました。今日は「場面ごとに CV の種類を使い分ける」上位技術を習得します。特に不均衡データ・小データ・時系列での適切な選択がKaggle上位への近道です。

なぜ CV の種類を使い分けるのか

問題普通の KFold の欠点解決策
不均衡データ(陽性 5%) 一部の Fold に陽性が 0件〜2件しか入らない StratifiedKFold で比率を維持
小データ(〜200件) Fold ごとのスコアのばらつき(分散)が大きい RepeatedKFold で 50回平均化
超小データ(〜100件) 5-Fold でも学習データが少なすぎる LOOCV で最大限のデータを学習に活用
時系列データ 未来データが学習に混入(情報リーク) TimeSeriesSplit で過去→未来の順序を保持

🗂️ CV 種類と使い分け

KFold
sklearn: KFold
用途: 回帰・均衡分類
データ量: 中〜大
特徴: シンプル・高速
LeaveOneOut
sklearn: LeaveOneOut
用途: 超小データ
データ量: ≤ 500件
特徴: 計算コスト O(N)
TimeSeriesSplit
sklearn: TimeSeriesSplit
用途: 時系列データ
データ量: 任意
特徴: 過去→未来の順序を維持

📊 Stratified の効果(陽性 10% の場合)

各 Fold の陽性割合(理想値: 10.0%)。KFold は偏り、StratifiedKFold は均一。

理想 10% 0% 10% 20% Fold 1 Fold 2 Fold 3 Fold 4 Fold 5 15% 7% 12% 6% 10% 10% 10% 10% 10% 10% KFold(偏り大) StratifiedKFold(均一)

📉 繰り返し CV による分散安定化

スコア数が増えるほど std(ばらつき)が小さくなる(√n に反比例)

StratifiedKFold(5) — 5スコア
std ≈ 0.050
高分散
Repeated(5×5) — 25スコア
std ≈ 0.028
中分散
Repeated(5×10) — 50スコア
std ≈ 0.020
低分散
LOOCV — Nスコア (N=500)
極小
最低 (計算コスト大)

std の理論値: std ∝ 1/√n(n = スコア数)。5→50 で約 3.2倍安定化

🗂️ データスキーマ(シミュレーションデータ)

パラメータ説明
n_samples500総サンプル数(小データ想定で LOOCV 比較も可能)
n_features15特徴量数
n_informative8実際に有用な特徴量数
weights[0.9, 0.1]陰性90% / 陽性10%(不均衡)
random_state42再現性確保

クラス分布(陽性 10% / 陰性 90%)

陰性(0) 90% 10%

📝 問題

import numpy as np
import pandas as pd
from sklearn.datasets import make_classification
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.model_selection import (
    KFold, StratifiedKFold,
    RepeatedStratifiedKFold, LeaveOneOut,
    cross_val_score, cross_validate,
)
import warnings
warnings.filterwarnings('ignore')

# 不均衡データの生成(陽性 10% / 陰性 90%)
X, y = make_classification(
    n_samples=500,
    n_features=15,
    n_informative=8,
    weights=[0.9, 0.1],
    random_state=42,
)

pipeline = Pipeline([
    ('scaler', StandardScaler()),
    ('model', LogisticRegression(max_iter=1000, random_state=42))
])

問1 — KFold vs StratifiedKFold の比較

  1. KFold(n_splits=5, shuffle=True, random_state=42) で 5-fold CV を実行し、各フォールドの陽性割合を出力せよ
  2. StratifiedKFold(n_splits=5, shuffle=True, random_state=42) でも同様に実行し、陽性割合を比較せよ
  3. どちらのほうが陽性割合が安定しているか観察せよ

問2 — cross_validate の活用

cross_validate を使って 1回の呼び出しで以下を同時取得せよ:

  1. StratifiedKFold(n_splits=5) を使用
  2. 取得するスコア: roc_aucaverage_precision(AP)
  3. 各スコアの平均・標準偏差を出力せよ
  4. 学習時間(fit_time)の平均も合わせて出力せよ

問3 — RepeatedStratifiedKFold で分散を安定化

  1. RepeatedStratifiedKFold(n_splits=5, n_repeats=10, random_state=42) で AP を評価せよ
  2. 通常の StratifiedKFold(n_splits=5) の結果と分散(std)を比較せよ
  3. 繰り返し CV のほうが std が小さくなることを確認せよ

問4 — LeaveOneOut CV(LOOCV)

  1. LeaveOneOut() を使って AP スコアを求めよ(※直接は計算不可のため代替手法を考えよ)
  2. LOOCV で AP が計算できない理由を説明せよ
  3. 代替として roc_auc スコアを LOOCV で計算し、StratifiedKFold の結果と比較せよ

問5 — 考察(CV 手法の選択)

以下のシナリオで最適な CV 手法を選び、理由を述べよ:

  1. 陽性 5%・サンプル数 10,000 件のKaggle不均衡分類コンペ
  2. 陽性 50%・サンプル数 80 件の医療研究データ
  3. 陽性 15%・サンプル数 300 件・評価指標が AP

💡 ヒント

ヒント1(方向性)
  • cross_val_score は単一スコアのみ。複数スコアが必要なら cross_validate(scoring=[...], ...)
  • LOOCV で AP が計算できない理由: テストデータが 1件なので Precision/Recall の曲線が描けない(2値しかない)
  • 各フォールドの陽性割合は for train_idx, test_idx in cv.split(X, y): y[test_idx].mean() で取得
ヒント2(アプローチ)
# 各フォールドの陽性割合を確認する方法
kf = KFold(n_splits=5, shuffle=True, random_state=42)
for fold, (train_idx, test_idx) in enumerate(kf.split(X, y)):
    pos_ratio = y[test_idx].mean()
    print(f"Fold {fold+1}: 陽性割合 = {pos_ratio:.3f}")

# cross_validate の使い方
results = cross_validate(
    pipeline, X, y,
    cv=StratifiedKFold(n_splits=5, shuffle=True, random_state=42),
    scoring=['roc_auc', 'average_precision'],
    return_train_score=False,
)
ヒント3(コード骨格)
# RepeatedStratifiedKFold
rskf = RepeatedStratifiedKFold(n_splits=5, n_repeats=10, random_state=42)
repeated_scores = cross_val_score(pipeline, X, y, cv=rskf, scoring='average_precision')
print(f"Repeated CV: {repeated_scores.mean():.4f} ± {repeated_scores.std():.4f}  (n={len(repeated_scores)})")

# LOOCV では roc_auc を使う(AP の代替)
loo = LeaveOneOut()
loo_scores = cross_val_score(pipeline, X, y, cv=loo, scoring='roc_auc')
print(f"LOOCV ROC-AUC: {loo_scores.mean():.4f}")

模範解答

import numpy as np
import pandas as pd
from sklearn.datasets import make_classification
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.model_selection import (
    KFold, StratifiedKFold,
    RepeatedStratifiedKFold, LeaveOneOut,
    cross_val_score, cross_validate,
)
import warnings
warnings.filterwarnings('ignore')

# データ生成
X, y = make_classification(
    n_samples=500, n_features=15, n_informative=8,
    weights=[0.9, 0.1], random_state=42,
)

pipeline = Pipeline([
    ('scaler', StandardScaler()),
    ('model', LogisticRegression(max_iter=1000, random_state=42))
])

# ── 問1: KFold vs StratifiedKFold の陽性割合比較 ──
print("=" * 55)
print("問1: KFold vs StratifiedKFold の陽性割合")
print("=" * 55)

for name, cv in [
    ("KFold", KFold(n_splits=5, shuffle=True, random_state=42)),
    ("StratifiedKFold", StratifiedKFold(n_splits=5, shuffle=True, random_state=42))
]:
    ratios = []
    for fold, (train_idx, test_idx) in enumerate(cv.split(X, y)):
        r = y[test_idx].mean()
        ratios.append(r)
        print(f"  [{name}] Fold {fold+1}: 陽性割合 = {r:.3f}")
    print(f"  >> 平均: {np.mean(ratios):.3f}, std: {np.std(ratios):.4f}\n")

# ── 問2: cross_validate で複数スコア取得 ──
print("=" * 55)
print("問2: cross_validate — ROC-AUC & AP の同時取得")
print("=" * 55)

skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
results = cross_validate(
    pipeline, X, y,
    cv=skf,
    scoring=['roc_auc', 'average_precision'],
    return_train_score=False,
)

print(f"ROC-AUC:  {results['test_roc_auc'].mean():.4f} ± {results['test_roc_auc'].std():.4f}")
print(f"AP:       {results['test_average_precision'].mean():.4f} ± {results['test_average_precision'].std():.4f}")
print(f"学習時間: {results['fit_time'].mean()*1000:.2f} ms/fold")

# ── 問3: RepeatedStratifiedKFold で分散を安定化 ──
print("\n" + "=" * 55)
print("問3: RepeatedStratifiedKFold vs 通常 StratifiedKFold")
print("=" * 55)

normal_scores = cross_val_score(
    pipeline, X, y,
    cv=StratifiedKFold(n_splits=5, shuffle=True, random_state=42),
    scoring='average_precision'
)
print(f"StratifiedKFold:         {normal_scores.mean():.4f} ± {normal_scores.std():.4f}  (n={len(normal_scores)})")

rskf = RepeatedStratifiedKFold(n_splits=5, n_repeats=10, random_state=42)
repeated_scores = cross_val_score(
    pipeline, X, y,
    cv=rskf,
    scoring='average_precision'
)
print(f"RepeatedStratifiedKFold: {repeated_scores.mean():.4f} ± {repeated_scores.std():.4f}  (n={len(repeated_scores)})")
print(f">> 分散改善率: std が {normal_scores.std()/repeated_scores.std():.1f}倍 安定化")

# ── 問4: LOOCV ──
print("\n" + "=" * 55)
print("問4: LeaveOneOut CV (LOOCV)")
print("=" * 55)

print("AP は LOOCV で計算不可: テストデータが1件のため Precision-Recall 曲線が定義できない")
print("→ 代替: ROC-AUC(predict_proba の結果が確率値のため1件でも計算可能)")

loo = LeaveOneOut()
loo_scores = cross_val_score(pipeline, X, y, cv=loo, scoring='roc_auc')
print(f"\nLOOCV ROC-AUC:           {loo_scores.mean():.4f} ± {loo_scores.std():.4f}  (n={len(loo_scores)})")
print(f"StratifiedKFold ROC-AUC: {results['test_roc_auc'].mean():.4f} ± {results['test_roc_auc'].std():.4f}  (n=5)")

# ── 問5: 考察 ──
print("""
問5 考察:
1. 陽性5%・10,000件 → StratifiedKFold(n_splits=5)
   理由: データ量が十分。Stratified で陽性割合を維持。繰り返し不要(計算コスト)

2. 陽性50%・80件 → RepeatedStratifiedKFold(n_splits=5, n_repeats=20)
   理由: 小データでフォールド分散が大きい。繰り返しで安定化。均衡なので通常KFoldも可

3. 陽性15%・300件・AP評価 → RepeatedStratifiedKFold(n_splits=5, n_repeats=10)
   理由: 小〜中規模かつ不均衡。Stratified で比率維持 + 繰り返しで AP の分散を抑制
""")

🪜 Step-by-Step 解説

1KFoldStratifiedKFold の内部動作

# KFold はランダムにシャッフルして N 等分するだけ
kf = KFold(n_splits=5, shuffle=True, random_state=42)

# StratifiedKFold は y のクラス比率を各フォールドで維持
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

# split(X, y) に y を渡すことでクラス情報を利用
# KFold は split(X) でも split(X, y) でも動作は同じ
for train_idx, test_idx in skf.split(X, y):
    # test_idx の y の比率 ≈ 全体の y の比率
    pass

2cross_validatescoring パラメータ

# 文字列リストで複数スコアを同時計算
results = cross_validate(
    estimator, X, y,
    cv=5,
    scoring=['roc_auc', 'average_precision', 'f1'],
    return_train_score=True,  # 学習データのスコアも取得(過学習チェック)
)

# results は辞書。キーは test_<score>, train_<score>, fit_time, score_time
print(results.keys())
# dict_keys(['fit_time', 'score_time', 'test_roc_auc', 'test_average_precision', 'test_f1'])

3繰り返し CV のスコア数と信頼区間

rskf = RepeatedStratifiedKFold(n_splits=5, n_repeats=10, random_state=42)
scores = cross_val_score(pipeline, X, y, cv=rskf, scoring='average_precision')

# スコアは n_splits * n_repeats = 50 個
print(f"スコア数: {len(scores)}")  # → 50

# 95% 信頼区間(±1.96 * std / sqrt(n))
n = len(scores)
ci = 1.96 * scores.std() / np.sqrt(n)
print(f"AP = {scores.mean():.4f} ± {ci:.4f} (95% CI)")

4LOOCV が遅い理由と対策(ShuffleSplit)

# LOOCV の計算コスト = N 回の学習
# N=500 なら 500回学習 → 通常の 100倍
# 対策: ShuffleSplit で近似

from sklearn.model_selection import ShuffleSplit

ss = ShuffleSplit(n_splits=100, test_size=0.2, random_state=42)
ss_scores = cross_val_score(pipeline, X, y, cv=ss, scoring='roc_auc')
print(f"ShuffleSplit (100回): {ss_scores.mean():.4f} ± {ss_scores.std():.4f}")
# LOOCV の近似として使えることが多い

5OOF(Out-of-Fold)予測の取り出し

from sklearn.model_selection import cross_val_predict
from sklearn.metrics import average_precision_score

# OOF 予測確率を取得(Kaggle の典型パターン)
oof_proba = cross_val_predict(
    pipeline, X, y,
    cv=StratifiedKFold(5, shuffle=True, random_state=42),
    method='predict_proba'
)[:, 1]

oof_ap = average_precision_score(y, oof_proba)
print(f"OOF AP: {oof_ap:.4f}")
# Public LB と最も相関しやすいスコア

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

「分散を減らす」とは何か(試験の例え)

試験を 1回だけ受けた点数と、5回受けた平均点数、どちらが「本当の実力」に近いでしょうか?もちろん後者です。

繰り返し CV は「試験を 50回受けた平均」を出す手法です。分散(スコアのばらつき)は 1/√n に比例して縮小します。n=5 → n=50 で分散は √10 ≈ 3.2 倍 安定します。

std ∝ 1/√n のイメージ(n = CV スコア数)

n(スコア数) std std ∝ 1/√n n=5 n=25 n=50

LOOCV が高バイアス・低分散の理由

N-1 件で学習するため、学習セットがほぼフルサイズ = 本番モデルに近い(低バイアス)。ただし N 個のテスト予測が強く相関するため、分散の推定精度は意外と低くなるというトレードオフがあります。

🏆 Kaggleでの実践的な使い方

場面推奨 CV理由
タブラー不均衡分類(10万件超)StratifiedKFold(5)高速かつ陽性比率維持
小データ(〜1000件)の分類RepeatedStratifiedKFold(5, 10)分散を安定化
時系列データTimeSeriesSplit(5)未来情報リークを防ぐ
100件以下の医療データLeaveOneOut()最大限のデータを学習に活用
Optuna でハイパラ最適化StratifiedKFold(3)速度優先・繰り返し不要
# Kaggle OOF 予測の典型パターン
from sklearn.model_selection import cross_val_predict, StratifiedKFold
from sklearn.metrics import average_precision_score

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

# OOF(Out-of-Fold)予測確率
oof_proba = cross_val_predict(pipeline, X_train, y_train, cv=cv, method='predict_proba')[:, 1]

# OOF での AP
oof_ap = average_precision_score(y_train, oof_proba)
print(f"OOF AP: {oof_ap:.4f}")

# この OOF スコアが Public LB と最も相関しやすい
# テストデータ全体への予測
pipeline.fit(X_train, y_train)
test_proba = pipeline.predict_proba(X_test)[:, 1]

🚫 よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
不均衡分類に KFold を使う StratifiedKFold の存在を知らない 陽性割合がフォールドごとに大きく変動しスコアが不安定になる
cross_validatescoring に文字列1つを渡す 複数スコアを知らない リストで渡せば複数スコアを1回で取得できる(効率的)
LOOCV で AP を計算しようとする 「使えば正確」という思い込み 1件テストではクラス分布が定義できないため AP は計算不可。ROC-AUC を使う
繰り返し CV は「不正解」と思う 「同じデータを使い回す」という誤解 分割方法を変えて繰り返すのは正当な統計的手法。平均は不偏推定量
大規模データに LOOCV を使う 「精度が高いから良い」という思い込み N 回の学習が必要。N=10,000 なら StratifiedKFold の 2000倍の計算コスト

🚀 次のステップ

  • 発展: TimeSeriesSplit で時系列データの CV を実装(リーク防止)
  • 次回予告(Day 059): Phase 2 総復習 — 線形回帰から PR 曲線まで、Phase 2 全テーマの問題を一括演習

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

✍️

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

自分の回答:

気づき・メモ: