📚 背景知識(読んでから問題へ)
| 用語 | 直感的な意味 |
|---|---|
| SMOTE | 少数派クラスのサンプルとその近傍を結ぶ線分上に「合成データ」を作り、水増しする手法。単純コピーではなく「間取りの違う新しい家」を建てるイメージ |
| class_weight | 少数派クラスの見逃しに対するペナルティ(罰)を大きくする学習時の設定 |
| 閾値調整(threshold tuning) | 予測確率に対する「陽性と判定する合格ライン」(既定0.5)を動かし、Precision/Recallのバランスを調整すること |
| PR-AUC(Average Precision) | Precision-Recall曲線の下側面積。不均衡データではROC-AUCより実態を反映しやすい評価指標 |
🧩 不均衡データ対策の3つのアプローチ
🧬 SMOTE(オーバーサンプリング)
少数派サンプル間を補間して合成データを作る
訓練データの少数派クラスを1:1近くまで水増しする。train/test分割の後、訓練データのみに適用しないとテスト情報が漏れる(データリーク)。
⚖️ class_weight
少数派の見逃しに重いペナルティを課す
データ自体は増やさず、学習時の損失関数に「少数派を間違えたら罰則を大きくする」重みを掛ける。class_weight="balanced"で自動計算できる。
🎚️ 閾値調整
モデルは変えず「合格ライン」だけ動かす
予測確率の判定ライン(既定0.5)をPrecision-Recallカーブに基づいて最適化する。実装コストが最も低く、今日の実験では最も効果的だった。
🎯 問題
不正検知を模した合成データ(陽性クラス=約3.6%)に対し、4つのアプローチ(何もしない/class_weight/SMOTE/閾値調整)を実装し、性能を比較する。
import numpy as np from sklearn.datasets import make_classification from sklearn.model_selection import train_test_split RANDOM_STATE = 42 X, y = make_classification( n_samples=5000, n_features=10, n_informative=5, n_redundant=2, n_clusters_per_class=1, weights=[0.97, 0.03], # 陽性クラスを約3%に設定 flip_y=0.01, random_state=RANDOM_STATE, ) X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, stratify=y, random_state=RANDOM_STATE )
| パラメータ(データ生成スキーマ) | 値 | 意味 |
|---|---|---|
| n_samples | 5000 | 取引件数(模擬) |
| n_features | 10 | 匿名化された特徴量数(実データのV1〜V10相当・PCA成分などをイメージ) |
| weights | [0.97, 0.03] | 陰性97%:陽性3%の不均衡比 |
| flip_y | 0.01 | ラベルノイズ(現実データの曖昧さを再現) |
| test_size / stratify | 0.3 / y | 層化抽出でtrain/testの不均衡比(約3.6%)を保つ |
実行結果: 陽性クラス割合0.0362(181/5000件)、train陽性127件・test陽性54件
タスク
LogisticRegressionをそのまま学習し、Accuracy・Precision・Recall・F1・PR-AUCを算出する。「全部陰性と予測するダミー」のAccuracyとも比較するclass_weight="balanced"を指定して学習し、同じ指標で評価するimbalanced-learnのSMOTEで訓練データのみをオーバーサンプリングしてから学習し、同じ指標で評価する(pip install imbalanced-learnが必要)precision_recall_curveを使い、F1を最大化する閾値を探し、その閾値で再評価する📐 閾値を動かすとPrecision/Recall/F1はどう変わるか
実際にベースラインモデルの予測確率で計算した値(0.00〜0.95、0.05刻み)。横軸は閾値、縦軸は各指標の値。緑の点線が最適閾値(F1最大)。
📊 実行結果の比較
実際にコードを実行して得た値(乱数シード固定・環境により多少前後する)。
Accuracyのパラドックス
Accuracyの差はわずか2.2ポイントしかないが、実際にはbaselineは54件中34件の不正を検出できている(後述の混同行列参照)。Accuracyだけでは、この「意味のある差」がほとんど見えない。
4アプローチのF1スコア比較
Precision / Recall の内訳
混同行列(test 1,500件・陽性54件)
baseline (thr=0.5)
| 予測:陰性 | 予測:陽性 | |
| 実際:陰性 | 1445 | 1 |
| 実際:陽性 | 20 | 34 |
class_weight=balanced
| 予測:陰性 | 予測:陽性 | |
| 実際:陰性 | 1366 | 80 |
| 実際:陽性 | 9 | 45 |
SMOTE
| 予測:陰性 | 予測:陽性 | |
| 実際:陰性 | 1380 | 66 |
| 実際:陽性 | 11 | 43 |
baseline + 閾値調整
| 予測:陰性 | 予測:陽性 | |
| 実際:陰性 | 1439 | 7 |
| 実際:陽性 | 12 | 42 |
class_weightとSMOTEは見逃し(左下)を減らせたが、誤報(右上)が大きく増えた。閾値調整は誤報を7件に抑えつつ見逃しも12件まで減らし、最もバランスが良い結果になった。
💡 ヒント
Accuracyだけを見て「良いモデルだ」と判断してはいけない。まず「全部陰性と予測するダミー」のAccuracyを計算し、それと比較する習慣をつける。SMOTEは必ずtrain/test分割の後、訓練データにだけ適用する。分割前に適用すると、テストデータの情報が漏れてしまう(データリーク)。
class_weight="balanced"はLogisticRegressionのコンストラクタ引数に渡すだけでよい- SMOTEは
from imblearn.over_sampling import SMOTE。smote.fit_resample(X_train, y_train)で新しい訓練データを作る(テストデータには絶対に使わない) - 閾値調整は
from sklearn.metrics import precision_recall_curve。precisions, recalls, thresholds = precision_recall_curve(y_test, proba)で各閾値でのPrecision/Recallが得られるので、F1 = 2*P*R/(P+R) を各点で計算し最大となる閾値を選ぶ - 予測確率は
model.predict_proba(X_test)[:, 1]で取得し、(proba >= threshold).astype(int)で最終予測に変換する
from sklearn.linear_model import LogisticRegression from sklearn.metrics import ( accuracy_score, precision_score, recall_score, f1_score, average_precision_score, precision_recall_curve ) from imblearn.over_sampling import SMOTE def evaluate(model, X_te, y_te, threshold=0.5): proba = model.predict_proba(X_te)[:, 1] pred = (proba >= threshold).astype(int) return { "accuracy": accuracy_score(y_te, pred), "precision": precision_score(y_te, pred, zero_division=0), "recall": recall_score(y_te, pred, zero_division=0), "f1": f1_score(y_te, pred, zero_division=0), "pr_auc": average_precision_score(y_te, proba), } # 1. ベースライン base_model = LogisticRegression(max_iter=1000, random_state=RANDOM_STATE) base_model.fit(X_train, y_train) # ここで evaluate() を呼ぶ # 2. class_weight cw_model = LogisticRegression(max_iter=1000, class_weight="balanced", random_state=RANDOM_STATE) # ...fit → evaluate # 3. SMOTE(訓練データのみ!) smote = SMOTE(random_state=RANDOM_STATE) X_train_sm, y_train_sm = smote.fit_resample(X_train, y_train) sm_model = LogisticRegression(max_iter=1000, random_state=RANDOM_STATE) # ...fit(X_train_sm, y_train_sm) → evaluate(Xはテストのまま) # 4. 閾値調整(ベースラインモデルの確率を使う) proba_test = base_model.predict_proba(X_test)[:, 1] precisions, recalls, thresholds = precision_recall_curve(y_test, proba_test) f1s = 2 * precisions * recalls / (precisions + recalls + 1e-12) best_idx = np.argmax(f1s[:-1]) # 最後の要素はthresholdに対応しないため除外 best_threshold = thresholds[best_idx] # evaluate(base_model, X_test, y_test, threshold=best_threshold)
✅ 模範解答
import numpy as np import pandas as pd from sklearn.datasets import make_classification from sklearn.model_selection import train_test_split from sklearn.linear_model import LogisticRegression from sklearn.metrics import ( accuracy_score, precision_score, recall_score, f1_score, average_precision_score, precision_recall_curve, confusion_matrix ) from imblearn.over_sampling import SMOTE RANDOM_STATE = 42 # ====== 1. 不均衡データ生成(陽性クラス約3.6%) ====== X, y = make_classification( n_samples=5000, n_features=10, n_informative=5, n_redundant=2, n_clusters_per_class=1, weights=[0.97, 0.03], flip_y=0.01, random_state=RANDOM_STATE, ) X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, stratify=y, random_state=RANDOM_STATE ) def evaluate(name, model, X_te, y_te, threshold=0.5): proba = model.predict_proba(X_te)[:, 1] pred = (proba >= threshold).astype(int) return dict( name=name, threshold=threshold, accuracy=accuracy_score(y_te, pred), precision=precision_score(y_te, pred, zero_division=0), recall=recall_score(y_te, pred, zero_division=0), f1=f1_score(y_te, pred, zero_division=0), pr_auc=average_precision_score(y_te, proba), ) results = [] # ====== 2. ベースライン ====== base_model = LogisticRegression(max_iter=1000, random_state=RANDOM_STATE) base_model.fit(X_train, y_train) results.append(evaluate("baseline", base_model, X_test, y_test)) # ====== 3. class_weight='balanced' ====== cw_model = LogisticRegression(max_iter=1000, class_weight="balanced", random_state=RANDOM_STATE) cw_model.fit(X_train, y_train) results.append(evaluate("class_weight=balanced", cw_model, X_test, y_test)) # ====== 4. SMOTE(訓練データのみ) ====== smote = SMOTE(random_state=RANDOM_STATE) X_train_sm, y_train_sm = smote.fit_resample(X_train, y_train) sm_model = LogisticRegression(max_iter=1000, random_state=RANDOM_STATE) sm_model.fit(X_train_sm, y_train_sm) results.append(evaluate("SMOTE", sm_model, X_test, y_test)) # ====== 5. 閾値調整(ベースラインモデルの確率をそのまま使う) ====== proba_test = base_model.predict_proba(X_test)[:, 1] precisions, recalls, thresholds = precision_recall_curve(y_test, proba_test) f1s = 2 * precisions * recalls / (precisions + recalls + 1e-12) best_idx = np.argmax(f1s[:-1]) best_threshold = thresholds[best_idx] results.append(evaluate("baseline+threshold_tuning", base_model, X_test, y_test, threshold=best_threshold)) print(pd.DataFrame(results).to_string(index=False))
出力(実際に実行して確認した値。乱数・環境により多少前後する):
陽性クラス割合: 0.0362 件数: 181 / 5000
train陽性件数: 127 test陽性件数: 54
name threshold accuracy precision recall f1 pr_auc
baseline 0.500000 0.986000 0.971429 0.629630 0.764045 0.798352
class_weight=balanced 0.500000 0.940667 0.360000 0.833333 0.502793 0.797590
SMOTE 0.500000 0.948667 0.394495 0.796296 0.527607 0.791305
baseline+threshold_tuning 0.202182 0.987333 0.857143 0.777778 0.815534 0.798352
🪜 Step-by-Step 解説
1不均衡データを作り、まず「何もしないダミー」と比較する
dummy_acc = 1 - y_test.mean() print(f"全部「陰性」と予測するダミーのAccuracy: {dummy_acc:.4f}") # → 0.9640
2ベースラインモデルを評価する
[baseline] threshold=0.5 Accuracy=0.9860 Precision=0.9714 Recall=0.6296 F1=0.7640 PR-AUC=0.7984 Confusion Matrix: [[1445 1] [ 20 34]]
3class_weight='balanced' を試す
[class_weight=balanced] threshold=0.5 Accuracy=0.9407 Precision=0.3600 Recall=0.8333 F1=0.5028 PR-AUC=0.7976
class_weight="balanced"は「少数派クラス(陽性)を1件間違えるペナルティを、クラス件数の逆比で大きくする」機能。結果Recallは0.63→0.83に上がったが、Precisionが0.97→0.36に急落し、F1(0.76→0.50)はむしろ悪化した。この不均衡比(97:3)に対してはペナルティを強くかけすぎたことが分かる。4SMOTEでオーバーサンプリングする
SMOTE後のtrain件数: 6746 陽性割合: 0.5 [SMOTE] threshold=0.5 Accuracy=0.9487 Precision=0.3945 Recall=0.7963 F1=0.5276 PR-AUC=0.7913
5閾値だけを調整する
閾値0.5でのF1: 0.7640 最適閾値: 0.2022 (この時のF1=0.8155) [baseline+threshold_tuning] threshold=0.2022 Accuracy=0.9873 Precision=0.8571 Recall=0.7778 F1=0.8155 PR-AUC=0.7984
🧮 数学・統計の補足(文系向け)
数式を見たら
F1 = 2 × Precision × Recall / (Precision + Recall)
これは「PrecisionとRecall、どちらも高くないと高得点にならない平均の取り方」を数式にしたもの。普通の平均(算術平均)だとPrecision=1.0・Recall=0.01でも平均は0.5とそこそこの数字になってしまうが、F1(調和平均)だとこの場合ほぼ0.02まで落ち、「片方が壊滅的なら全体も壊滅的」という直感を正しく反映する。
x_new = x_i + λ × (x_zi − x_i), λ ∈ [0, 1]
SMOTEの合成データ生成式。少数派サンプルx_iと、その近くの少数派サンプルx_ziを結ぶ線分上の、ランダムな1点を新データにすることを表している。単なるコピーではなく、2点の「中間」に新しい点を作るイメージ。
🏆 Kaggleでの実践的な使い方
よく使われるコンペカテゴリ: ☑ 表形式データ(Tabular) / ☐ 自然言語処理(NLP) / ☐ 画像認識(CV) / ☐ 時系列(Time Series)
- IEEE-CIS Fraud Detection(Kaggle過去コンペ): 陽性率約3.5%の不正検知コンペ。評価指標はROC-AUCだったが、不均衡データの評価にはPR-AUCも合わせて見るのが定石
- scale_pos_weight(XGBoost)・is_unbalance(LightGBM): GBDT系モデルに内蔵された、class_weightと同じ発想の不均衡対策パラメータ。SMOTEより先に、まずこれらの内蔵オプションを試す方がコストが低く効果的なことが多い
- SMOTEを使うときの鉄則: CVの各foldの中で、訓練foldにのみ適用する。全体に先に適用してからCVを回すと、テスト(検証)foldに漏れた合成データがスコアを過大評価させる
- コンペの評価指標に合わせた閾値最適化: F1やビジネスコストなど閾値依存の指標がコンペの評価基準になっている場合、最後の一手として今日学んだ閾値調整が効く
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| SMOTEは訓練データ全体に、train/test分割の前に適用すればいい | 「データ全体を均等にしてから使う方が自然」という直感 | 分割前に適用すると、テストデータに似た合成サンプルが訓練データに混入し、性能を過大評価する(データリーク)。SMOTEは必ず分割後、訓練データのみに適用する |
| Accuracyが98%を超えていれば良いモデルだ | 数字の大きさだけで判断してしまう思い込み | 今日のデータでは「何もしないダミー」でもAccuracy=96.4%に達する。不均衡データではAccuracyでなくPrecision・Recall・F1・PR-AUCで評価する |
| SMOTEやclass_weightを使えば必ず性能が上がる | 「不均衡対策=万能薬」という思い込み | 今日の実行結果では両方ともF1がベースラインより悪化した(0.76→0.50, 0.53)。まず閾値調整のような安価な方法を試し、それでも不十分な場合の選択肢として扱う方が良い |
| 閾値は0.5で固定するのが標準であり、変えてはいけない | 多くの入門教材で0.5がデフォルト値として使われているため | 0.5はsklearnの初期値に過ぎず、絶対的なルールではない。Precision-Recallのどちらを重視するか(ビジネス上のコスト)に応じて、precision_recall_curveなどで最適な閾値を探すのが正しい |
| SMOTEで少数派クラスの件数を水増しすれば、実質的にデータが増えて情報量も増える | 「件数が増える=情報が増える」という思い込み | SMOTEは既存の少数派サンプルの周辺を補間しているだけで、新しい「情報」を外部から追加しているわけではない。元の少数派サンプルの分布に偏りや外れ値があれば、その偏りごと増幅されるリスクもある |
🚀 次のステップ
- 発展: 今回のロジスティック回帰をLightGBMに置き換え、
is_unbalance=Trueまたはscale_pos_weight(陰性件数/陽性件数の比)を設定して、今日のclass_weight/SMOTE/閾値調整と同じ比較を行ってみる。木構造モデルでも同じ傾向(閾値調整が効きやすい)になるか確認する - 次回予告: Day 107「特徴量選択の高度手法(SHAP・Boruta・PermutationImportance)」 — モデルがどの特徴量を重視しているかを解釈する手法へ進む
📝 自己評価(解いた後に記入)
自分の回答・気づき・メモ: