Day 051 — 交差検証の基礎(K-Fold CV・Stratified K-Fold)

2026-06-01 緑 / Phase 2 理論 交差検証

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

🎯
Day 051 — 評価指標を習得した次のステップ。「どのデータで評価するか」という 評価設計 を学びます。Kaggle上位勢が全員使う必須スキルです。

モデルを評価するとき、データを1回だけ「訓練用・テスト用」に分けると 運に左右される評価 になります。たまたまテストデータが簡単なデータなら高スコアに、難しければ低スコアになるだけかもしれません。

交差検証(Cross-Validation)の核心

データを複数のブロック(fold)に分けて、何度も訓練・評価を繰り返す手法。全データが一度ずつテストに使われるため、より信頼性の高い評価が得られます。

手法いつ使う特徴
KFold回帰 / クラス比率が均等な分類データをK等分してランダムに分割
StratifiedKFold分類(特に不均衡データ)各foldのクラス比率を均等に保つ
TimeSeriesSplit時系列データ未来のデータが訓練に漏れないよう順序を保持
GroupKFoldグループが存在するデータ同じグループが訓練とテストに分かれないよう制御

🗂️ K-Fold の仕組み(K=5)

1000件のデータを5等分(各200件)。1回ずつfoldを変えてテストに使う。

Fold 1
TEST
train
train
train
train
≈ 0.8540
Fold 2
train
TEST
train
train
train
≈ 0.8610
Fold 3
train
train
TEST
train
train
≈ 0.8500
Fold 4
train
train
train
TEST
train
≈ 0.8520
Fold 5
train
train
train
train
TEST
≈ 0.8580
平均
5回のスコアをすべて平均 → より信頼性の高いスコアを算出
0.8550
💡
全1000件のデータが 一度だけ テストに使われる。1回の train_test_split よりはるかに安定した評価が得られます。

🗂️ データスキーマ(合成分類データ 1000件)

項目説明
n_samples1000サンプル数
n_features10特徴量数(feat_0 〜 feat_9)
n_informative5実際に予測に役立つ特徴量の数
n_redundant2informative から線形結合で生成される冗長特徴量
weights[0.8, 0.2]不均衡: クラス0が80%、クラス1が20%
label0 / 1目的変数(2クラス分類)
⚠️
クラス比率が 8:2 と偏っています。これが Stratified K-Fold を使う理由です。通常のK-Foldでは特定のfoldにクラス1が偏る可能性があります。

📝 問題

import pandas as pd
import numpy as np
from sklearn.datasets import make_classification
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import KFold, StratifiedKFold, cross_val_score
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.metrics import accuracy_score

np.random.seed(42)
X, y = make_classification(
    n_samples=1000,
    n_features=10,
    n_informative=5,
    n_redundant=2,
    weights=[0.8, 0.2],   # クラス比率 8:2(不均衡)
    random_state=42
)

X = pd.DataFrame(X, columns=[f"feat_{i}" for i in range(10)])
y = pd.Series(y, name="label")

問1 — K-Fold CVの手動実装

  • KFold(n_splits=5, shuffle=True, random_state=42) を使って手動でCVを実装する
  • 各foldで LogisticRegression を学習し accuracy_score で評価する
  • 5つのスコアと、その 平均 ± 標準偏差 を表示する

問2 — Stratified K-Fold との比較

  • StratifiedKFold(n_splits=5, shuffle=True, random_state=42) で同じ評価を実施する
  • 各foldのクラス1の比率(y[test_idx].mean())を表示する
  • K-Fold と Stratified K-Fold でスコアの 標準偏差 を比較し、どちらが安定しているか確認する

問3 — cross_val_score を使った簡潔な実装

  • cross_val_score 関数を使って問1・問2と同じ評価を1行で書く
  • scoring="accuracy"scoring="roc_auc" の両方で評価する
  • n_jobs=-1(並列処理)を使って実行する

問4 — データリーク防止: Pipeline との組み合わせ

以下の悪い例(データリーク)良い例(Pipeline)を実装し、スコアを比較する:

# 悪い例(データリーク)
scaler = StandardScaler()
X_scaled_leak = scaler.fit_transform(X)  # CV の外でスケーリング!
scores_leak = cross_val_score(LogisticRegression(), X_scaled_leak, y, cv=5)

# 良い例(Pipeline)
pipeline = Pipeline([
    ("scaler", StandardScaler()),
    ("model", LogisticRegression())
])
scores_correct = cross_val_score(pipeline, X, y, cv=StratifiedKFold(5, shuffle=True, random_state=42))

🔍 ヒント(段階的開示)

ヒント1 — 方向性
  • 問1: for train_idx, test_idx in kf.split(X): でfoldをループ
  • 問2: y.iloc[test_idx].mean() がクラス1の比率
  • 問3: cross_val_score(model, X, y, cv=skf, scoring="roc_auc", n_jobs=-1)
  • 問4: Pipeline に前処理を入れると各fold内でfitされる
ヒント2 — アプローチ
kf = KFold(n_splits=5, shuffle=True, random_state=42)
scores = []

for fold, (train_idx, test_idx) in enumerate(kf.split(X), 1):
    X_train, X_test = X.iloc[train_idx], X.iloc[test_idx]
    y_train, y_test = y.iloc[train_idx], y.iloc[test_idx]

    model = LogisticRegression(random_state=42, max_iter=500)
    model.fit(X_train, y_train)
    score = accuracy_score(y_test, model.predict(X_test))
    scores.append(score)

print(f"K-Fold CV: {np.mean(scores):.4f} ± {np.std(scores):.4f}")
ヒント3 — コード骨格(ほぼ答え)
# Stratified K-Fold でクラス比率を確認
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
for fold, (train_idx, test_idx) in enumerate(skf.split(X, y), 1):
    ratio = y.iloc[test_idx].mean()
    print(f"Fold {fold}: クラス1比率 = {ratio:.3f}")

# cross_val_score
cv_strategy = StratifiedKFold(5, shuffle=True, random_state=42)
scores_acc = cross_val_score(
    LogisticRegression(max_iter=500), X, y,
    cv=cv_strategy, scoring="accuracy", n_jobs=-1
)
scores_auc = cross_val_score(
    LogisticRegression(max_iter=500), X, y,
    cv=cv_strategy, scoring="roc_auc", n_jobs=-1
)
print(f"Accuracy: {np.mean(scores_acc):.4f} ± {np.std(scores_acc):.4f}")
print(f"ROC-AUC : {np.mean(scores_auc):.4f} ± {np.std(scores_auc):.4f}")

模範解答

import pandas as pd
import numpy as np
from sklearn.datasets import make_classification
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import KFold, StratifiedKFold, cross_val_score
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.metrics import accuracy_score

np.random.seed(42)
X, y = make_classification(
    n_samples=1000, n_features=10, n_informative=5,
    n_redundant=2, weights=[0.8, 0.2], random_state=42
)
X = pd.DataFrame(X, columns=[f"feat_{i}" for i in range(10)])
y = pd.Series(y, name="label")

# ── 問1: K-Fold CV 手動実装 ──
kf = KFold(n_splits=5, shuffle=True, random_state=42)
scores_kf = []
for fold, (train_idx, test_idx) in enumerate(kf.split(X), 1):
    X_train, X_test = X.iloc[train_idx], X.iloc[test_idx]
    y_train, y_test = y.iloc[train_idx], y.iloc[test_idx]
    model = LogisticRegression(random_state=42, max_iter=500)
    model.fit(X_train, y_train)
    score = accuracy_score(y_test, model.predict(X_test))
    scores_kf.append(score)
    print(f"Fold {fold}: {score:.4f}")
print(f"K-Fold 平均: {np.mean(scores_kf):.4f} ± {np.std(scores_kf):.4f}")

# ── 問2: Stratified K-Fold との比較 ──
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores_skf = []
for fold, (train_idx, test_idx) in enumerate(skf.split(X, y), 1):
    X_train, X_test = X.iloc[train_idx], X.iloc[test_idx]
    y_train, y_test = y.iloc[train_idx], y.iloc[test_idx]
    ratio = y_test.mean()
    model = LogisticRegression(random_state=42, max_iter=500)
    model.fit(X_train, y_train)
    score = accuracy_score(y_test, model.predict(X_test))
    scores_skf.append(score)
    print(f"Fold {fold}: クラス1比率={ratio:.3f}, accuracy={score:.4f}")
print(f"Stratified 平均: {np.mean(scores_skf):.4f} ± {np.std(scores_skf):.4f}")
print(f"標準偏差比較 → K-Fold: {np.std(scores_kf):.4f} / Stratified: {np.std(scores_skf):.4f}")

# ── 問3: cross_val_score ──
cv_strategy = StratifiedKFold(5, shuffle=True, random_state=42)
model = LogisticRegression(random_state=42, max_iter=500)
scores_acc = cross_val_score(model, X, y, cv=cv_strategy, scoring="accuracy", n_jobs=-1)
scores_auc = cross_val_score(model, X, y, cv=cv_strategy, scoring="roc_auc", n_jobs=-1)
print(f"Accuracy: {np.mean(scores_acc):.4f} ± {np.std(scores_acc):.4f}")
print(f"ROC-AUC : {np.mean(scores_auc):.4f} ± {np.std(scores_auc):.4f}")

# ── 問4: Pipeline と データリーク ──
cv5 = StratifiedKFold(5, shuffle=True, random_state=42)
scaler = StandardScaler()
X_scaled_leak = scaler.fit_transform(X)
scores_leak = cross_val_score(
    LogisticRegression(max_iter=500), X_scaled_leak, y, cv=cv5, scoring="accuracy"
)
pipeline = Pipeline([
    ("scaler", StandardScaler()),
    ("model", LogisticRegression(max_iter=500))
])
scores_correct = cross_val_score(pipeline, X, y, cv=cv5, scoring="accuracy")
print(f"[リーク有] {np.mean(scores_leak):.4f} ± {np.std(scores_leak):.4f}")
print(f"[Pipeline] {np.mean(scores_correct):.4f} ± {np.std(scores_correct):.4f}")
📌
期待される結果の傾向:
K-Fold の std > Stratified K-Fold の std(Stratified の方がスコアが安定)
ROC-AUC は Accuracy より不均衡データの評価に適した指標なので注視する
Pipeline のスコアはリーク有とほぼ同じかわずかに低いことが多い(今回はデータ数が多いので差が出にくい)

📊 K-Fold vs Stratified K-Fold の比較

各手法のスコア安定性(概念図)

K-Fold
Fold1〜5 の accuracy
0.870
0.820
0.900
0.850
0.830
std ≈ 0.030
Stratified K-Fold
クラス比率を均等化
0.860
0.850
0.860
0.850
0.870
std ≈ 0.008
💡
Stratified K-Fold は各foldのクラス比率を均等に保つため、スコアのばらつき(std)が小さくなります。不均衡データでは特に差が顕著です。

K値(分割数)とトレードオフ

K=3 K=5 K=10 K=20 バイアス↑ 計算コスト↑ バイアス(↓小さいほど良い) 計算コスト ★推奨 ★推奨

🔗 データリーク vs Pipeline(可視化)

❌ 悪い例(データリーク)
📦 全データ (1000件) で StandardScaler.fit()
スケーリング済みデータを渡す
🔀 K-Fold で split()
⚠️ テストデータの統計が既に漏れている!
全データの平均・標準偏差(テスト含む)でスケーリング → 情報漏洩
✅ 良い例(Pipeline)
🔀 K-Fold で split()
各fold内で独立処理
📦 訓練データだけで StandardScaler.fit()
🔬 テストデータには transform のみ適用
各foldで独立してスケーリング → 情報漏洩なし
⚠️
なぜ差が出にくいことがあるのか? 今回のデータ(1000件)では差が小さいですが、少量データ・複雑な前処理・時系列データでは差が顕著になります。「差が出なかったから OK」ではなく、正しい方法を必ず使う習慣を。

🪜 Step-by-Step 解説

1 KFold の基本的な使い方

from sklearn.model_selection import KFold

kf = KFold(
    n_splits=5,          # データを5等分
    shuffle=True,        # 分割前にシャッフル(必須)
    random_state=42      # 再現性のためにシードを固定
)

# split(X) は (訓練インデックスの配列, テストインデックスの配列) を返す
for train_idx, test_idx in kf.split(X):
    print(f"train: {len(train_idx)}, test: {len(test_idx)}")
    # → train: 800, test: 200 (5等分なので)

    X_train = X.iloc[train_idx]  # pandas では .iloc でインデックスを使う
    X_test  = X.iloc[test_idx]

shuffle=False(デフォルト)のままだとデータの並び順のまま分割されます。時系列以外では必ず shuffle=True を使いましょう。

2 StratifiedKFold でクラス比率を確認する

from sklearn.model_selection import StratifiedKFold

# 分類では split(X, y) に y を渡す(これが Stratified の鍵)
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

for fold, (train_idx, test_idx) in enumerate(skf.split(X, y), 1):
    # テストデータのクラス1の比率を確認
    class1_ratio = y.iloc[test_idx].mean()
    print(f"Fold {fold}: クラス1比率 = {class1_ratio:.3f}")
    # → 全fold で ≈ 0.200 になる(元データの比率を維持)

3 cross_val_score の便利な使い方

from sklearn.model_selection import cross_val_score
import numpy as np

# scoring 文字列一覧(よく使うもの)
# "accuracy"              → 正解率
# "roc_auc"               → ROC-AUC(分類)
# "neg_mean_squared_error" → MSE(符号反転、回帰)
# "neg_root_mean_squared_error" → RMSE(符号反転、回帰)
# "r2"                    → R²(回帰)

scores = cross_val_score(
    LogisticRegression(max_iter=500),
    X, y,
    cv=StratifiedKFold(5, shuffle=True, random_state=42),
    scoring="roc_auc",
    n_jobs=-1   # 全CPUコアで並列実行 → 大量データで高速化
)

print(f"mean: {scores.mean():.4f}")
print(f"std:  {scores.std():.4f}")
print(f"all:  {scores.round(4)}")

4 Pipeline でデータリークを確実に防ぐ

from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

# Pipeline のステップは (名前, オブジェクト) のタプルのリスト
pipeline = Pipeline([
    ("scaler", StandardScaler()),       # Step 1: スケーリング
    ("model",  LogisticRegression())    # Step 2: 学習
])

# cross_val_score に渡すだけで OK
# 内部で各fold ごとに: scaler.fit_transform(X_train) → model.fit(X_train_scaled)
#                              scaler.transform(X_test)  → model.predict(X_test_scaled)
# が自動で行われる

scores = cross_val_score(pipeline, X, y, cv=5, scoring="accuracy")
print(f"Pipeline CV: {scores.mean():.4f} ± {scores.std():.4f}")

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

「平均 ± 標準偏差」の読み方

表記意味良い値は?
0.855 ± 0.0085回の平均が0.855、ばらつきが0.008std が小さい = 安定した評価
0.855 ± 0.0405回の平均は同じでも大きくブレるstd が大きい = 不安定

K値のトレードオフ(直感的説明)

K値訓練データ割合評価回数特徴使い所
K=366.7%3回訓練データが少ない → バイアス高データが非常に少ない時
K=580.0%5回バランスが良い標準的な選択
K=1090.0%10回バイアス低・計算コスト高精度重視の場合
LOOCV(N-1)/NN回最もバイアスが低いが非常に遅いデータが極端に少ない時のみ

🏆 Kaggleでの実践的な使い方

コンペタイプ推奨CV戦略理由
一般的な分類StratifiedKFold(5)クラス比率を均等化
一般的な回帰KFold(5)クラス制約なし
時系列予測TimeSeriesSplit未来情報の漏洩を防ぐ
グループありGroupKFold同グループが訓練・テストに分かれないよう制御
# Kaggle 上位の OOF(Out-of-Fold)予測テンプレート
import numpy as np
from sklearn.model_selection import StratifiedKFold
from sklearn.metrics import roc_auc_score

def run_cv_with_oof(model, X, y, n_splits=5):
    skf = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=42)
    oof_preds = np.zeros(len(y))  # 全サンプルの予測を格納
    scores = []

    for fold, (train_idx, val_idx) in enumerate(skf.split(X, y), 1):
        X_tr, X_val = X.iloc[train_idx], X.iloc[val_idx]
        y_tr, y_val = y.iloc[train_idx], y.iloc[val_idx]

        model.fit(X_tr, y_tr)
        oof_preds[val_idx] = model.predict_proba(X_val)[:, 1]

        score = roc_auc_score(y_val, oof_preds[val_idx])
        scores.append(score)
        print(f"Fold {fold}: AUC = {score:.4f}")

    oof_auc = roc_auc_score(y, oof_preds)
    print(f"\nOOF AUC: {oof_auc:.4f} (CV: {np.mean(scores):.4f} ± {np.std(scores):.4f})")
    return oof_preds  # スタッキング・ブレンディングに使用

# 使用例
from sklearn.linear_model import LogisticRegression
oof = run_cv_with_oof(LogisticRegression(max_iter=500), X, y)
🔑
OOF(Out-of-Fold)予測は各サンプルが一度だけテストになる時の予測値。全サンプルを網羅するため、スタッキング(モデルの積み重ね)の入力として使われます。Kaggle Gold メダリストがほぼ全員使うテクニックです。

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
分類に通常の KFold を使う 種類の違いを知らない 分類では 必ず StratifiedKFold を使う
CV の外でスケーリングする 書きやすさ優先 Pipeline に前処理を入れる
neg_mean_squared_error の符号を見落とす sklearn の符号反転に気づかない -scores.mean() で正の値にする
shuffle=False(デフォルト)のまま使う パラメータを確認しない 時系列以外は shuffle=True, random_state=42 を指定
CV スコアだけを見てモデルを選ぶ CV スコア = 最終評価と思いがち CV スコアと LB(Leaderboard)スコアの乖離も確認する

🚀 次のステップ

  • 発展: TimeSeriesSplit — 時系列データでは未来のデータが訓練に混入しないよう、fold を時間順に設計する必要があります。金融・需要予測コンペで必須の知識です
  • 次回予告 (Day 052): ロジスティック回帰入門 — 「確率」として予測を出す仕組み。シグモイド関数・決定境界・係数の解釈を学びます

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

自分の回答・気づき・メモ: