Hayami Kaito Web
~/tech

不均衡データのしくみ:重み付け・サンプリング・SMOTE としきい値の関係

published

不正な取引・製品の不良・病気の発見のように、見つけたいものが 全体のごく一部 しかない問題はたくさんあります。このようなデータを 不均衡データ と呼びます。不均衡データでそのままモデルを作ると、「ほとんど陰性と答えておけば正解率が高い」状態になり、肝心の陽性を見逃しがちです。

対策としては、陽性の重みを上げる・陽性を水増しする・陰性を減らす・SMOTE で陽性を作る、といった方法がよく紹介されます。この記事では、それらを同じデータで比べ、何が起きているのかを確かめます。

結論を先に書くと、次のようになります。

  • 重み付けも、サンプリングも、SMOTE も、このデータでは PR-AUC(見分ける力)をほとんど変えない。主にしているのは、予測の確率を全体に上げて、しきい値 0.5 で陽性と判定される件数を増やすこと で、しきい値を下げるのとほぼ同じ効果になる
  • そのかわり、予測の確率は実際の陽性の割合からずれる。確率そのものを使うなら、補正の式で元に戻す
  • しきい値は、見逃し 1 件と誤報 1 件の損の比 から決めるのが筋がよい
  • 水増しは、学習用と検証用に分けてから、学習用のデータだけに 行う。先に水増しすると、検証の成績が実際よりずっと良く見える

評価指標(適合率・再現率・PR-AUC)の詳しい意味は、機械学習の評価指標のしくみ で説明しました。説明の途中に出てくる計算例は、Python と NumPy で実際に動かした結果です。

環境

計算例を動かした環境です。

項目バージョン
Python3.13
NumPy2.5

陽性が 1% のデータ

データとモデル

5 個の特徴を持つ架空のデータを作ります。陽性は全体の約 1% で、陽性のときは 2 つの特徴の値が平均で 2 だけ大きくなります。陽性と陰性の分布は重なっているので、完璧には見分けられません。

モデルは 深層学習以前の機械学習 で説明したロジスティック回帰です。ここでは、学習をきちんと収束させるためにニュートン法で解き、1 件ごとの重み w を付けられるようにしています。

import numpy as np

def make_data(rng, n, pos_rate=0.01):
    y = (rng.random(n) < pos_rate).astype(int)
    X = rng.normal(size=(n, 5))
    X[y == 1, :2] += 2.0          # 陽性は 2 つの特徴が少し大きい
    return X, y

def fit_logreg(X, y, w=None, steps=30):
    """重み付きのロジスティック回帰(ニュートン法)。w は 1 件ごとの重み"""
    w = np.ones(len(y)) if w is None else w
    Xb = np.column_stack([np.ones(len(X)), X]); beta = np.zeros(Xb.shape[1])
    for _ in range(steps):
        p = 1 / (1 + np.exp(-Xb @ beta))
        grad = Xb.T @ (w * (p - y))
        hess = (Xb * (w * p * (1 - p))[:, None]).T @ Xb
        beta -= np.linalg.solve(hess + 1e-6 * np.eye(len(beta)), grad)
    return beta

def predict(beta, X):
    return 1 / (1 + np.exp(-(beta[0] + X @ beta[1:])))

def pr_auc(y, s):
    order = np.argsort(-s); y = y[order]
    tp = np.cumsum(y); prec = tp / np.arange(1, len(y) + 1)
    return float((prec * y).sum() / y.sum())   # 平均適合率

def report(name, y, p, t=0.5):
    pred = p >= t
    tp = int((pred & (y == 1)).sum()); fp = int((pred & (y == 0)).sum()); fn = int((~pred & (y == 1)).sum())
    acc = np.mean(pred == y)
    prec = tp / (tp + fp) if tp + fp else 0.0
    print(f"{name}: 正解率 {acc:.4f}, 適合率 {prec:.3f}, 再現率 {tp / (tp + fn):.3f}, PR-AUC {pr_auc(y, p):.3f}, 予測確率の平均 {p.mean():.4f}")

report は、しきい値 t 以上を陽性と判定したときの正解率・適合率・再現率と、しきい値によらない PR-AUC(ここでは平均適合率で計算)、予測確率の平均を表示します。

そのまま学習する

学習用と評価用にそれぞれ 50000 件を作り、そのまま学習させます。

rng = np.random.default_rng(0)
X, y = make_data(rng, 50000)
Xte, yte = make_data(rng, 50000)
print("学習用の陽性の数", y.sum(), "評価用の陽性の数", yte.sum())

beta = fit_logreg(X, y)
p = predict(beta, Xte)
report("そのまま", yte, p)
report("いつも陰性と答える", yte, np.zeros(len(yte)))

実行結果:

学習用の陽性の数 509 評価用の陽性の数 471
そのまま: 正解率 0.9936, 適合率 0.790, 再現率 0.431, PR-AUC 0.597, 予測確率の平均 0.0098
いつも陰性と答える: 正解率 0.9906, 適合率 0.000, 再現率 0.000, PR-AUC 0.009, 予測確率の平均 0.0000
  • 正解率は 0.9936 ですが、「いつも陰性と答える」だけでも 0.9906 になります。不均衡データでは、正解率はほとんど役に立ちません
  • しきい値 0.5 では、陽性の 43% しか見つけられていません(再現率 0.431)
  • 一方、予測確率の平均 0.0098 は、評価用のデータの実際の陽性の割合(471 / 50000 = 0.0094)とほぼ同じです。確率としては正しく学習できている ことに注意してください。陽性の確率が 0.5 を超えるのは、よほど陽性らしいデータだけなので、しきい値 0.5 では見逃しが多くなるのです

よく使われる対策を比べる

5 つの方法

方法やること
重み付け陽性 1 件の損失を、陰性の数 ÷ 陽性の数(約 97)倍に数える
オーバーサンプリング陽性を重複させて、陰性と同じ数まで増やす
アンダーサンプリング陰性をランダムに減らして、陽性と同じ数にする
SMOTE陽性とその近くの陽性を結ぶ線分の上に、新しい陽性を作って増やす(Chawla ら, 2002)
しきい値の調整そのまま学習し、陽性と判定するしきい値だけを下げる

実行する

上のコードの続きで、それぞれの方法を試します。

pos, neg = np.where(y == 1)[0], np.where(y == 0)[0]

# 1. 重み付け:陽性の 1 件を、陰性の何件分として数えるか
w = np.where(y == 1, len(neg) / len(pos), 1.0)
report("重み付け", yte, predict(fit_logreg(X, y, w), Xte))

# 2. オーバーサンプリング:陽性を重複させて陰性と同じ数にする
idx = np.concatenate([neg, rng.choice(pos, len(neg))])
report("オーバーサンプリング", yte, predict(fit_logreg(X[idx], y[idx]), Xte))

# 3. アンダーサンプリング:陰性を陽性と同じ数まで減らす
idx = np.concatenate([pos, rng.choice(neg, len(pos), replace=False)])
p_under = predict(fit_logreg(X[idx], y[idx]), Xte)
report("アンダーサンプリング", yte, p_under)

# 4. SMOTE:陽性どうしを結ぶ線分の上に、新しい陽性を作る
def smote(Xp, n_new, k, rng):
    d = ((Xp[:, None] - Xp[None]) ** 2).sum(-1)
    nn = np.argsort(d, 1)[:, 1:k + 1]          # 自分以外の近い k 個
    a = rng.integers(len(Xp), size=n_new)
    b = nn[a, rng.integers(k, size=n_new)]
    lam = rng.random((n_new, 1))
    return Xp[a] + lam * (Xp[b] - Xp[a])
Xs = smote(X[pos], len(neg) - len(pos), 5, rng)
Xa, ya = np.vstack([X, Xs]), np.concatenate([y, np.ones(len(Xs), int)])
report("SMOTE", yte, predict(fit_logreg(Xa, ya), Xte))

# 5. しきい値を下げる:そのまま学習して、しきい値だけ変える
p = predict(fit_logreg(X, y), Xte)
report("しきい値 0.1", yte, p, t=0.1)
report("しきい値 0.03", yte, p, t=0.03)

実行結果:

重み付け: 正解率 0.9233, 適合率 0.103, 再現率 0.921, PR-AUC 0.592, 予測確率の平均 0.1241
オーバーサンプリング: 正解率 0.9231, 適合率 0.102, 再現率 0.921, PR-AUC 0.593, 予測確率の平均 0.1240
アンダーサンプリング: 正解率 0.9103, 適合率 0.090, 再現率 0.936, PR-AUC 0.589, 予測確率の平均 0.1320
SMOTE: 正解率 0.9287, 適合率 0.109, 再現率 0.911, PR-AUC 0.592, 予測確率の平均 0.1110
しきい値 0.1: 正解率 0.9851, 適合率 0.351, 再現率 0.688, PR-AUC 0.597, 予測確率の平均 0.0098
しきい値 0.03: 正解率 0.9638, 適合率 0.184, 再現率 0.830, PR-AUC 0.597, 予測確率の平均 0.0098

見分ける力は変わっていない

  • PR-AUC は、どの方法でも 0.59 前後で、そのまま学習したとき(0.597)より良くなっていない。PR-AUC はしきい値によらない「陽性を上位に並べる力」なので、どの方法も、データの並べ方はほとんど変えていないことが分かります
  • 変わったのは、しきい値 0.5 での判定です。重み付け・サンプリング・SMOTE では、再現率が 0.43 から 0.91〜0.94 に上がり、そのかわり適合率が 0.79 から 0.09〜0.11 に下がりました
  • 予測確率の平均が 0.11〜0.13 に上がっている。実際の陽性の割合は 0.0094 なので、10 倍以上も高い確率を出していることになります。陽性と陰性を同じ数にして学習したので、モデルは「陽性は半分くらいある」と思い込んでいるのです

アンダーサンプリングは、学習に使ったデータが 1018 件(陽性 509 件と、同じ数の陰性)しかないのに、PR-AUC は 0.589 とほとんど落ちていません。このデータでは、陰性の大部分は見分けるのにあまり役立っておらず、データを減らして学習を速くする方法としても使えることが分かります。

しきい値をずらすのとほぼ同じ

重み付けしたモデルがしきい値 0.5 で陽性と判定した件数と同じ件数になるように、そのまま学習したモデルのしきい値を決めてみます。

p_w = predict(fit_logreg(X, y, w), Xte)

# 重み付けしたモデルの「しきい値 0.5」と同じ順位で区切ると?
t = np.sort(p)[::-1][(p_w >= 0.5).sum() - 1]
print("同じ件数を陽性とするしきい値", round(float(t), 4))
report("そのまま + そのしきい値", yte, p, t=t)

実行結果:

同じ件数を陽性とするしきい値 0.0103
そのまま + そのしきい値: 正解率 0.9233, 適合率 0.102, 再現率 0.919, PR-AUC 0.597, 予測確率の平均 0.0098

しきい値を 0.0103 まで下げると、重み付けしたモデルとほぼ同じ正解率・適合率・再現率になりました。ロジスティック回帰では、陽性の重みを r 倍にすることは、理論的にも、切片を log r だけずらすこととほぼ同じになります。つまり 重み付けやサンプリングは、しきい値を下げることを学習の側でやっている と言えます。

決定木やニューラルネットワークのような柔軟なモデルでは、重み付けやサンプリングで分け方そのものが変わることもあります。陽性が数十件しかないような極端な場合には、勾配ブースティングで陽性の重みを上げると、少ない陽性にも分割が作られやすくなります。ただし、その場合でも最終的なしきい値は検証用のデータで決め直します。

確率を元に戻す

予測の確率を、そのまま「不正の確率」として使いたいこともあります。たとえば、確率に取引の金額を掛けて、損の期待値を出すような場合です。その場合、サンプリングでずれた確率は補正する必要があります。

陰性を β の割合だけ残してアンダーサンプリングしたとき、そのデータで学習したモデルの確率 p_s は、次の式で元のデータでの確率 p に戻せます(Dal Pozzolo ら, 2015)。

p = β × p_s / (β × p_s − p_s + 1)
# アンダーサンプリングした確率の補正:陰性を β の割合だけ残したとき
beta_keep = len(pos) / len(neg)
p_fixed = beta_keep * p_under / (beta_keep * p_under - p_under + 1)
report("アンダーサンプリング(確率を補正)", yte, p_fixed, t=0.03)

実行結果:

アンダーサンプリング(確率を補正): 正解率 0.9545, 適合率 0.153, 再現率 0.845, PR-AUC 0.589, 予測確率の平均 0.0118

予測確率の平均が 0.1320 から 0.0118 に戻りました。並べ方は変わらないので、PR-AUC は補正の前と同じ 0.589 です。重み付けやオーバーサンプリングでも、陽性を r 倍にしたなら β = 1 / r として同じ式で補正できます。

確率の正しさ(較正)については、機械学習の評価指標のしくみ の「較正」でも説明しています。

しきい値は損の大きさから決める

見逃しと誤報の損の比

しきい値は「再現率 0.9 以上」のように決めることもありますが、見逃し 1 件の損 c_FN と、誤報 1 件の損 c_FP が見積もれるなら、そこから決めるのが筋がよい方法です。確率 p の陽性を陽性と判定したときの損の期待値は (1 − p) × c_FP、陰性と判定したときは p × c_FN なので、次の条件のときに陽性と判定するのが得になります。

p × c_FN > (1 − p) × c_FP   ⇔   p > c_FP / (c_FP + c_FN)

この式が使えるのは、p が正しく較正された確率のときです。そのまま学習したモデルの確率は較正されていたので、そのまま使えます。

計算してみる

見逃し 1 件の損が、誤報 1 件の損の 100 倍のときを考えます。しきい値は 1 / 101 ≒ 0.0099 です。

# 見逃し 1 件の損が、誤報 1 件の損の 100 倍のとき
c_fn, c_fp = 100, 1
for name, q, th in (("そのまま・しきい値 0.5", p, 0.5),
                    ("そのまま・しきい値 c_fp/(c_fp+c_fn)", p, c_fp / (c_fp + c_fn)),
                    ("重み付け・しきい値 0.5", p_w, 0.5)):
    pred = q >= th
    cost = c_fn * (~pred & (yte == 1)).sum() + c_fp * (pred & (yte == 0)).sum()
    print(f"{name}: しきい値 {th:.4f}, 損の合計 {cost}")

実行結果:

そのまま・しきい値 0.5: しきい値 0.5000, 損の合計 26854
そのまま・しきい値 c_fp/(c_fp+c_fn): しきい値 0.0099, 損の合計 7601
重み付け・しきい値 0.5: しきい値 0.5000, 損の合計 7497

しきい値を損の比から決めると、損の合計は 26854 から 7601 まで減りました。陽性と陰性の比(約 1 : 97)と損の比(100 : 1)がたまたま近いので、陽性の重みを約 97 倍にしたモデルの「しきい値 0.5」も、ほぼ同じ損になっています。

この結果から、重み付けの倍率は「陽性と陰性の数の比」ではなく、「見逃しと誤報の損の比」に合わせるもの だと考えると分かりやすくなります。数の比に合わせるのは、損の比を知らないときの目安にすぎません。

評価で気をつけること

水増しは分けてから

オーバーサンプリングや SMOTE を、学習用と検証用のデータに分ける 前に 行うと、同じ陽性(やその近くに作った陽性)が、学習用と検証用の両方に入ってしまいます。検証用のデータの陽性を、学習用のデータの中にある「そっくりな点」で当てられるので、成績が実際よりずっと良く見えます。

陽性が 2% のデータで、いちばん近い学習用のデータの答えをそのまま返す 1-近傍法の再現率を、水増しの順番を変えて比べます。

rng = np.random.default_rng(1)
X, y = make_data(rng, 6000, pos_rate=0.02)

def knn1_recall(Xtr, ytr, Xva, yva):
    """いちばん近い学習用のデータの答えをそのまま返す(1-近傍法)の再現率"""
    d = (Xva**2).sum(1)[:, None] - 2 * Xva @ Xtr.T + (Xtr**2).sum(1)[None]
    pred = ytr[d.argmin(1)]
    return pred[yva == 1].mean()

def oversample(X, y, rng):
    pos, neg = np.where(y == 1)[0], np.where(y == 0)[0]
    idx = np.concatenate([neg, rng.choice(pos, len(neg))])
    return X[idx], y[idx]

# 間違い:先に水増ししてから、学習用と検証用に分ける
Xo, yo = oversample(X, y, rng)
perm = rng.permutation(len(yo)); cut = len(yo) // 2
print("分ける前に水増し", round(float(knn1_recall(Xo[perm[:cut]], yo[perm[:cut]], Xo[perm[cut:]], yo[perm[cut:]])), 3))

# 正しい:先に分けて、学習用のデータだけを水増しする
perm = rng.permutation(len(y)); cut = len(y) // 2
Xtr, ytr = oversample(X[perm[:cut]], y[perm[:cut]], rng)
print("分けてから水増し", round(float(knn1_recall(Xtr, ytr, X[perm[cut:]], y[perm[cut:]])), 3))

実行結果:

分ける前に水増し 1.0
分けてから水増し 0.424

先に水増しすると、再現率は 1.0、つまり「陽性を 1 件も見逃さない」という結果になりました。検証用のデータの陽性とまったく同じ点が、学習用のデータにも入っているからです。正しい順番では 0.424 で、これが本当の実力です。交差検証をするときも、分割ごとに、学習用の部分だけを水増しする 必要があります。このような、評価に使うデータの情報が学習に漏れ込む問題は 特徴量エンジニアリングと前処理のしくみ でも扱います。

そのほかの注意

注意内容
層化して分ける学習用・検証用・評価用に分けるときは、陽性の割合がそろうように分ける。陽性が少ないと、ランダムに分けただけで割合が大きくぶれる
正解率を使わないPR-AUC・適合率・再現率、または損の合計で評価する
ROC-AUC だけを見ない陽性がまれなときは、ROC-AUC は高く出やすい。PR-AUC も見る
評価用のデータは元の割合のまま評価用のデータは水増しも削減もしない。本番と同じ割合で測る

不均衡データの扱いの流れ

年手法・出来事ポイント
1997片側の選択(Kubat と Matwin)境界の近くや重なっている陰性を選んで減らす
2001コストを考えた学習(Elkan)損の比からしきい値を決め、重み付けとしきい値の関係を整理した
2002SMOTE陽性どうしの間に、新しい陽性を作って増やす
2005Borderline-SMOTE境界の近くの陽性だけを増やす
2008ADASYN見分けにくい陽性ほど多く作る
2015アンダーサンプリングの確率の補正(Dal Pozzolo ら)陰性を減らしたときの確率のずれを補正する式
2017フォーカルロスよく当たっている例の損失を小さくし、難しい例に重点を置く(物体検出の背景だらけ問題への対策)

まとめ

  • 不均衡データでは、正解率は役に立たない。PR-AUC・適合率・再現率、または損の合計で評価する
  • そのまま学習したロジスティック回帰の確率は、正しく較正されていた。しきい値 0.5 で見逃しが多いのは、しきい値が高すぎるため
  • 重み付け・オーバーサンプリング・アンダーサンプリング・SMOTE は、このデータでは PR-AUC を変えず、しきい値を下げるのとほぼ同じ効果だった。そのかわり、確率は実際の割合より大きくずれる
  • 確率を使うときは、補正の式で元に戻す。しきい値は、見逃しと誤報の損の比 c_FP / (c_FP + c_FN) から決める
  • 水増しは、学習用と検証用に分けてから、学習用のデータだけに行う。先に水増しすると、成績が実際よりずっと良く見える

参考

  • M. Kubat and S. Matwin, "Addressing the Curse of Imbalanced Training Sets: One-Sided Selection", ICML 1997
  • C. Elkan, "The Foundations of Cost-Sensitive Learning", IJCAI 2001
  • N. V. Chawla et al., "SMOTE: Synthetic Minority Over-sampling Technique", Journal of Artificial Intelligence Research 2002(arXiv:1106.1813)
  • H. Han, W.-Y. Wang and B.-H. Mao, "Borderline-SMOTE: A New Over-Sampling Method in Imbalanced Data Sets Learning", ICIC 2005
  • H. He et al., "ADASYN: Adaptive Synthetic Sampling Approach for Imbalanced Learning", IJCNN 2008
  • H. He and E. A. Garcia, "Learning from Imbalanced Data", IEEE Transactions on Knowledge and Data Engineering 2009
  • A. Dal Pozzolo et al., "Calibrating Probability with Undersampling for Unbalanced Classification", IEEE SSCI 2015
  • T. Saito and M. Rehmsmeier, "The Precision-Recall Plot Is More Informative than the ROC Plot When Evaluating Binary Classifiers on Imbalanced Datasets", PLOS ONE 2015
  • T.-Y. Lin et al., "Focal Loss for Dense Object Detection", ICCV 2017(arXiv:1708.02002)