Day 106 — 不均衡データ対策 — SMOTE・class_weight・閾値調整

2026-07-27 青 / Phase 4 コーディング SMOTE / class_weight / Precision-Recallトレードオフ

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

🔵
Day 105で「外れ値」という値そのものの偏りを扱いました。今日はもう1つの実務・Kaggle頻出の偏り、クラス件数の偏り(不均衡データ)を扱います。クレジットカード不正利用検知なら不正はわずか0.1%、チャーン予測でも解約者は数%〜20%程度——「見つけたいもの」が少数派であるケースは非常に多いです。
⚠️
Accuracyのパラドックス: 100万件中1,000件(0.1%)が不正利用のデータで「全部『正常』と予測する」モデルを作ると、Accuracyは99.9%に達します。数字だけ見れば優秀そうですが、これは不正を1件も検出できない無価値なモデルです。不均衡データではAccuracyでなく、Precision・Recall・F1・PR-AUCで評価する習慣が必須になります。
用語直感的な意味
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/閾値調整)を実装し、性能を比較する。

使用データ不正検知データ(陽性率約3.6%)
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_samples5000取引件数(模擬)
n_features10匿名化された特徴量数(実データのV1〜V10相当・PCA成分などをイメージ)
weights[0.97, 0.03]陰性97%:陽性3%の不均衡比
flip_y0.01ラベルノイズ(現実データの曖昧さを再現)
test_size / stratify0.3 / y層化抽出でtrain/testの不均衡比(約3.6%)を保つ

実行結果: 陽性クラス割合0.0362(181/5000件)、train陽性127件・test陽性54件

タスク

1
ベースライン: LogisticRegressionをそのまま学習し、Accuracy・Precision・Recall・F1・PR-AUCを算出する。「全部陰性と予測するダミー」のAccuracyとも比較する
2
class_weight: 同じモデルにclass_weight="balanced"を指定して学習し、同じ指標で評価する
3
SMOTE: imbalanced-learnSMOTE訓練データのみをオーバーサンプリングしてから学習し、同じ指標で評価する(pip install imbalanced-learnが必要)
4
閾値調整: ベースラインモデルの予測確率に対しprecision_recall_curveを使い、F1を最大化する閾値を探し、その閾値で再評価する
5
比較・考察: 4つの結果を1つの比較表にまとめ、どのアプローチが最も良かったか、なぜそうなったかを考察する

📐 閾値を動かすとPrecision/Recall/F1はどう変わるか

実際にベースラインモデルの予測確率で計算した値(0.00〜0.95、0.05刻み)。横軸は閾値、縦軸は各指標の値。緑の点線が最適閾値(F1最大)。

閾値 threshold(0.00〜0.95、実測値) 0.0 0.5 0.95 1.0 0.0 既定 threshold=0.5 最適 threshold≈0.20 Precision(閾値が上がるほど増加) Recall(閾値が上がるほど減少) F1(0.20付近で最大) 既定の0.5では見逃し(Recall低下)が多く、0.20付近まで下げるとF1が最大化される

📊 実行結果の比較

実際にコードを実行して得た値(乱数シード固定・環境により多少前後する)。

Accuracyのパラドックス

ダミー(全部「陰性」と予測)
0.9640
不正を1件も検出できない
baseline(LogisticRegression)
0.9860
ダミーよりわずか+2.2pt

Accuracyの差はわずか2.2ポイントしかないが、実際にはbaselineは54件中34件の不正を検出できている(後述の混同行列参照)。Accuracyだけでは、この「意味のある差」がほとんど見えない。

4アプローチのF1スコア比較

baseline
0.7640
何もしない状態
class_weight=balanced
0.5028
baselineより悪化
SMOTE
0.5276
baselineより悪化
baseline + 閾値調整
0.8155
4手法中トップ

Precision / Recall の内訳

baseline: Precision
0.9714
確信度は高いが慎重すぎる
baseline: Recall
0.6296
54件中34件しか検出できず
class_weight: Precision
0.3600
誤報が急増
class_weight: Recall
0.8333
見逃しは減った
閾値調整: Precision
0.8571
高いPrecisionを維持
閾値調整: Recall
0.7778
Recallも大幅改善

混同行列(test 1,500件・陽性54件)

baseline (thr=0.5)

予測:陰性予測:陽性
実際:陰性14451
実際:陽性2034

class_weight=balanced

予測:陰性予測:陽性
実際:陰性136680
実際:陽性945

SMOTE

予測:陰性予測:陽性
実際:陰性138066
実際:陽性1143

baseline + 閾値調整

予測:陰性予測:陽性
実際:陰性14397
実際:陽性1242

class_weightとSMOTEは見逃し(左下)を減らせたが、誤報(右上)が大きく増えた。閾値調整は誤報を7件に抑えつつ見逃しも12件まで減らし、最もバランスが良い結果になった。

💡 ヒント

ヒント1方向性

Accuracyだけを見て「良いモデルだ」と判断してはいけない。まず「全部陰性と予測するダミー」のAccuracyを計算し、それと比較する習慣をつける。SMOTEは必ずtrain/test分割の、訓練データにだけ適用する。分割前に適用すると、テストデータの情報が漏れてしまう(データリーク)。

ヒント2アプローチ
  • class_weight="balanced"LogisticRegressionのコンストラクタ引数に渡すだけでよい
  • SMOTEはfrom imblearn.over_sampling import SMOTEsmote.fit_resample(X_train, y_train)で新しい訓練データを作る(テストデータには絶対に使わない)
  • 閾値調整はfrom sklearn.metrics import precision_recall_curveprecisions, 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)で最終予測に変換する
ヒント3コード骨格
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
🧠
考察(タスク5): PR-AUC(モデルが持つ「見分ける力」そのもの)は4手法ともほぼ同じ(0.79〜0.80)です。つまりモデルの性能自体は変わっておらず、class_weightとSMOTEは「Recallを上げる代わりにPrecisionを大きく犠牲にする」方向にシフトさせただけで、F1で見るとむしろベースラインより悪化しました(0.76→0.50, 0.53)。一方、モデルもデータも一切変えず「閾値」という読み取り方だけを最適化した手法が、4つの中で最もF1が高い結果(0.8155)になりました。

🪜 Step-by-Step 解説

1不均衡データを作り、まず「何もしないダミー」と比較する

dummy_acc = 1 - y_test.mean()
print(f"全部「陰性」と予測するダミーのAccuracy: {dummy_acc:.4f}")
# → 0.9640
🔑
なぜこうするか: テストデータの陽性クラスはわずか3.62%(181/5000件)。何も考えずに「常に陰性」と予測するだけでAccuracy=96.4%に達する。この基準値と比較しない限り、後で出てくる「Accuracy=98.6%」という数字が本当に優れているのか判断できない。

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]]
👀
なぜこうするか: 混同行列を見ると、陽性54件中34件しか見つけられていない(Recall=0.63)。逆に「陽性」と予測した35件のうち34件は本当に陽性だった(Precision=0.97)。「確信が持てる分だけ陽性と言う、慎重すぎるモデル」であり、不正検知なら見逃した20件の不正が放置されることになる。

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
🧬
なぜこうするか: SMOTEは訓練データの陽性:陰性比を1:1(50%ずつ)まで引き上げる(3500件弱→6746件)。結果はclass_weightと似た傾向(Recall上昇・Precision急落)になり、F1(0.53)はベースライン(0.76)にすら届かなかった。「オーバーサンプリングすれば必ず良くなる」わけではない、という重要な教訓がここにある。

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
🎯
なぜこうするか: モデルもデータも一切変えず、「陽性と判定する確率のライン」を0.5から0.20に下げただけ。これによりRecallが0.63→0.78に上がりつつ、Precisionも0.86を維持でき、F1は4つの手法の中で最も高い0.8155になった。しかもPR-AUC(0.7984)はどの手法でもほぼ同じ——モデルが持つ「見分ける力」自体は最初から変わっておらず、閾値という「読み取り方」を最適化しただけで一番良い結果が出たことになる。

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

📐
直感的な理解: Precision(適合率)は「陽性と言った中で本当に当たっていた割合」=発言の信頼度。Recall(再現率)は「本当に陽性だったものの中で見つけられた割合」=見逃しの少なさ。F1はこの2つの「掛け算平均(調和平均)」で、どちらか一方が0に近いと全体も0に近くなる、厳しめの平均。
🔥
身近な例: 火災報知器を思い浮かべる。感度を上げすぎると(閾値を下げると)、料理の煙にも反応する誤報だらけの機械になる(Precision低下)。感度を下げすぎると(閾値を上げると)、本物の火災でも反応しない役立たずになる(Recall低下)。「ちょうど良い感度」を探す作業が閾値調整で、今日のデータでは0.5より低い0.20あたりが「ちょうど良い」設定だった。

数式を見たら

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)」 — モデルがどの特徴量を重視しているかを解釈する手法へ進む

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

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