入門 · レッスン 6 / 10

嘘をつかない評価

不均衡データで正解率が誤解を招く理由、適合率・再現率・F1・ROC-AUCが測るもの、層化交差検証とダミーベースラインで誠実さを保つ方法、ブートストラップでスコアに区間を付ける方法

入門40 分更新 2026年11月13日無料期間 2027年1月31日

このレッスンで学ぶこと

  • 混同行列を読み、そこから適合率、再現率、F1を手計算できる
  • あるクラスが希少なとき正解率が見出しの数字として不適切な理由と、代わりにROC-AUCが測るものを説明できる
  • 層化k分割交差検証を実行し、すべてのモデルをダミーベースラインと比較できる
  • テストセットのスコアにブートストラップ信頼区間を付け、2つのモデルが区別できるかどうかを述べられる

前提レッスン: 決定木と勾配ブースティング

これまでのレッスンはすべて、1つの数字で終わっていました。正解率、平均二乗誤差、ホールドアウト分割でのスコアです。本レッスンのテーマは、その数字が信頼できるかどうかです。同僚に「正解率96%」を見せて「何と比べて96%なのか」と聞かれたことのある人のためのレッスンです。読み終えるころには、評価がモデルを実際より良く見せてしまうよくあるパターンを捕まえる短いチェックリストが手に入り、その各項目をscikit-learnで実行済みになっています。

レッスン02では、最初にして最悪の失敗、つまり学習データとテストデータの間のリーケージを扱いました。本レッスンでは分割がきれいであることを前提に、次の問いを立てます。指標は適切か、ベースラインは公平か、推定値は安定しているか、そして誤差棒はどれくらい広いか。

希少クラスに対する正解率

一方のクラスが希少なデータセットから始めます。不正取引、設備の故障、希少疾患、よく運営された企業のスパムは、どれもこの形をしています。下の例は合成データで、陽性が約4%になるようにしています。数値が再現可能で、データクリーニングに要点が埋もれないようにするためです。

import numpy as np
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.dummy import DummyClassifier
from sklearn.metrics import accuracy_score

X, y = make_classification(
    n_samples=4000, n_features=20, n_informative=6, n_redundant=2,
    weights=[0.96, 0.04], flip_y=0.01, class_sep=0.9, random_state=0,
)
print("positives:", int(y.sum()), "of", len(y), f"({100 * y.mean():.1f} %)")

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, stratify=y, random_state=0
)

dummy = DummyClassifier(strategy="most_frequent").fit(X_train, y_train)
model = LogisticRegression(max_iter=2000).fit(X_train, y_train)

print(f"dummy accuracy:  {accuracy_score(y_test, dummy.predict(X_test)):.3f}")
print(f"logreg accuracy: {accuracy_score(y_test, model.predict(X_test)):.3f}")
positives: 177 of 4000 (4.4 %)
dummy accuracy:  0.956
logreg accuracy: 0.965

ダミー分類器は特徴量を一切見ていません。毎回「陰性」と答えるだけで、テスト行の95.6%が陰性なので95.6%のスコアになります。特徴量を見たロジスティック回帰は96.5%です。単独で報告すれば、96.5%は完成した製品のように聞こえます。ベースラインの隣に置けば1ポイントに満たない改善であり、この2つの数字からはモデルが希少な事例を1つでも見つけているのかどうかさえ分かりません。

ここから2つの習慣が導かれます。1つ目は、常にDummyClassifier(またはDummyRegressor)を学習させ、モデルと並べて報告することです。2つ目は、クラスが不均衡なときには正解率を見出しの数字にしないことです。train_test_splitのstratify=y引数もここで重要です。両方の分割でクラス比率を同じに保つので、偶然の分割が希少クラスを隠したり誇張したりできなくなります。

混同行列とそこから得られるもの

2クラス問題では、すべての予測が4つのセルのいずれかに入ります。真陽性(TP)はモデルが捕まえた陽性です。偽陽性(FP)はモデルが陽性と判定した陰性です。偽陰性(FN)はモデルが見逃した陽性です。真陰性(TN)は正しく放置された陰性です。正解率は(TP + TN)を全体で割ったものなので、巨大なTNの数に飲み込まれてしまいます。

陽性に関わるセルだけを見る指標が3つあります。

指標 式 答える問い
適合率(precision) TP / (TP + FP) モデルが陽性と言ったとき、どれくらいの頻度で正しいか
再現率(recall) TP / (TP + FN) 実際の陽性のうち、モデルはいくつ見つけたか
F1 2 x 適合率 x 再現率 / (適合率 + 再現率) 上の2つのどちらかが低ければ低くなる1つの数字

F1は適合率と再現率の調和平均です。調和平均は不均衡を罰します。適合率1.0、再現率0.1のモデルは算術平均なら0.55ですが、F1は0.18です。要約スコアに求めたいのはまさにこの振る舞いです。10件に1件しか捕まえられない不正検知器は、半分良いわけではないからです。

import numpy as np
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.dummy import DummyClassifier
from sklearn.metrics import (confusion_matrix, precision_score, recall_score,
                             f1_score, roc_auc_score)

X, y = make_classification(
    n_samples=4000, n_features=20, n_informative=6, n_redundant=2,
    weights=[0.96, 0.04], flip_y=0.01, class_sep=0.9, random_state=0,
)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, stratify=y, random_state=0
)
dummy = DummyClassifier(strategy="most_frequent").fit(X_train, y_train)
model = LogisticRegression(max_iter=2000).fit(X_train, y_train)

for name, clf in [("dummy", dummy), ("logreg", model)]:
    pred = clf.predict(X_test)
    prob = clf.predict_proba(X_test)[:, 1]
    tn, fp, fn, tp = confusion_matrix(y_test, pred).ravel()
    print(f"{name:7s} TP={tp:3d} FP={fp:3d} FN={fn:3d} TN={tn:4d}")
    print(f"        precision={precision_score(y_test, pred, zero_division=0):.3f}"
          f" recall={recall_score(y_test, pred):.3f}"
          f" F1={f1_score(y_test, pred):.3f}"
          f" ROC-AUC={roc_auc_score(y_test, prob):.3f}")
dummy   TP=  0 FP=  0 FN= 53 TN=1147
        precision=0.000 recall=0.000 F1=0.000 ROC-AUC=0.500
logreg  TP= 13 FP=  2 FN= 40 TN=1145
        precision=0.867 recall=0.245 F1=0.382 ROC-AUC=0.862

これで正直な姿が見えました。ダミーは何も見つけないので、適合率、再現率、F1はすべてゼロです。ロジスティック回帰は適合率が高い(15件の警報のうち13件が本物)一方、53件の陽性のうち40件を見逃しています。これが許容できるかどうかは、見逃しのコストと誤警報のコストの比較に完全に依存します。それはモデルではなく、アプリケーションについての問いです。一度は手で数字を確認してください。13 / (13 + 2) は0.867、13 / (13 + 40) は0.245です。

しきい値は選択であり、ROC-AUCはそれを無視する

scikit-learnの分類器のpredictは、予測確率が0.5以上のとき陽性クラスを返します。問題のどこにも0.5が正しいとは書かれていません。しきい値を動かしてみると、トレードオフの全体が見えます。

import numpy as np
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import precision_score, recall_score

X, y = make_classification(
    n_samples=4000, n_features=20, n_informative=6, n_redundant=2,
    weights=[0.96, 0.04], flip_y=0.01, class_sep=0.9, random_state=0,
)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, stratify=y, random_state=0
)
model = LogisticRegression(max_iter=2000).fit(X_train, y_train)
prob = model.predict_proba(X_test)[:, 1]

print("threshold  precision  recall")
for t in [0.05, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8]:
    pred = (prob >= t).astype(int)
    p = precision_score(y_test, pred, zero_division=0)
    r = recall_score(y_test, pred)
    print(f"{t:9.2f}  {p:9.3f}  {r:6.3f}")
00.20.40.60.810.20.40.60.8適合率再現率スコアしきい値
決定しきい値を動かしたときの、テスト分割に対するロジスティック回帰の適合率と再現率。数値は上のブロックの出力。

しきい値0.05では、モデルは陽性の74%を捕まえる代わりに、警報の82%が間違いです。0.7ではすべての警報が正しい代わりに、陽性の87%が素通りします。モデルは変わっていません。変わったのは確率を決定に変えるルールだけで、そのルールは、それぞれの種類の間違いのコストを負う人が決めるものです。

ROC-AUCはしきい値を回避します。モデルが生み出す順位付けを取り、「無作為に陽性を1つ、陰性を1つ選んだとき、モデルが陽性のほうに高いスコアを付ける頻度はどれくらいか」を問います。完璧な順位付けなら1.0、コイン投げなら0.5で、上のダミーはすべての行に同じ確率を与えるのでちょうど0.5でした。ロジスティック回帰の0.862は、デフォルトしきい値での再現率が低くても、順位付けとしては有用だと言っています。ROC-AUCは、しきい値がまだ決まっていない段階でモデル同士を比較するのに適した数字です。一方、「不正取引が何件捕まるのか」を知りたい関係者に見せるには不適切な数字です。それを答えてくれないからです。

1回の分割は1つの標本にすぎない

上の数字は、random_state=0で選んだ1回の70/30分割から得たものです。別のシードなら別のテストセットになり、F1も変わります。テストセットの陽性がわずか53件なので、そのうち3件が「発見」から「見逃し」に移るだけで再現率は約6ポイント動きます。交差検証は、1つのノイズの多い推定値を複数の推定値に変えます。

層化k分割は、データをそれぞれ全体と同じクラス比率を持つk個の部分に分け、毎回異なる部分をホールドアウトしてk回学習します。すべての行がちょうど1回ずつテストセットに入ります。分割(フォールド)間のばらつきは、たまたまどの行でテストしたかにスコアがどれだけ依存するかを示す、最初の大まかな目安です。

import numpy as np
from sklearn.datasets import make_classification
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.dummy import DummyClassifier

X, y = make_classification(
    n_samples=4000, n_features=20, n_informative=6, n_redundant=2,
    weights=[0.96, 0.04], flip_y=0.01, class_sep=0.9, random_state=0,
)
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)
models = {
    "dummy (stratified)": DummyClassifier(strategy="stratified", random_state=0),
    "logreg": LogisticRegression(max_iter=2000),
    "hist gradient boosting": HistGradientBoostingClassifier(random_state=0),
}
for name, clf in models.items():
    scores = cross_val_score(clf, X, y, cv=cv, scoring="f1")
    print(f"{name:23s} F1 per fold: {np.round(scores, 3)}"
          f"  mean {scores.mean():.3f} +/- {scores.std():.3f}")
dummy (stratified)      F1 per fold: [0.098 0.073 0.171 0.048 0.048]  mean 0.088 +/- 0.045
logreg                  F1 per fold: [0.326 0.435 0.286 0.255 0.34 ]  mean 0.328 +/- 0.061
hist gradient boosting  F1 per fold: [0.56  0.549 0.571 0.655 0.44 ]  mean 0.555 +/- 0.069
00.20.40.60.81dummylogregboosting平均F1
5つの層化フォールドにわたるF1の平均。数値は上のブロックの出力。

1回の分割では隠れていた3つのことが見えます。ロジスティック回帰のF1はフォールドによって0.26から0.44まで変わるので、先ほどの分割での0.382は中程度の引きであって、モデルの性質ではありません。レッスン05の勾配ブースティングモデルは、フォールド間のばらつきを超えて明らかに優れています。そして、基準率で陽性を当てずっぽうに予測するダミーは約0.09というゼロでないF1を出しており、これが実際のモデルが越えなければならない床です。

このダミーがmost_frequentではなくstrategy="stratified"を使っているのは、most_frequentのF1がちょうどゼロで、床として役に立たないからです。情報なしで最も打ち負かしにくいベースラインを選んでください。

交差検証が推定するのは、「手順」がどれだけ汎化するかです。最終的なモデルは得られません。5つのモデルを学習したからです。通常の実践では、交差検証でモデルと設定を選び、勝者を学習データ全体で再学習し、一度も触れていないテストセットで1回だけスコアを報告します。評価指標リファレンスに、ここで使ったすべてのスコアリング文字列のscikit-learnでの名前をまとめています。

ブートストラップによる誤差棒

フォールドの標準偏差はばらつきを素早く見るには便利ですが信頼区間ではなく、2つのノイズ源(どの行でテストしたか、どの行で学習したか)が混ざっています。「このテストセットのスコアはどれくらい不確かか」という問いに対しては、ブートストラップが標準的な道具で、分布の仮定を必要としません。

考え方はこうです。テストセットは、関心のある母集団からの標本です。それを同じサイズで復元抽出により何度もリサンプリングします。各リサンプルは、ありえた別のテストセットです。それぞれで指標を計算すれば、その値のばらつきが指標の不確かさです。予測は全体を通して固定なので再学習は不要で、1,000回のリサンプルは数秒で終わります。

import numpy as np
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.metrics import f1_score

X, y = make_classification(
    n_samples=4000, n_features=20, n_informative=6, n_redundant=2,
    weights=[0.96, 0.04], flip_y=0.01, class_sep=0.9, random_state=0,
)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, stratify=y, random_state=0
)
preds = {
    "logreg": LogisticRegression(max_iter=2000).fit(X_train, y_train).predict(X_test),
    "boosting": HistGradientBoostingClassifier(random_state=0).fit(X_train, y_train).predict(X_test),
}

rng = np.random.default_rng(0)
n = len(y_test)
for name, pred in preds.items():
    boot = []
    for _ in range(1000):
        idx = rng.integers(0, n, size=n)   # resample the test set with replacement
        boot.append(f1_score(y_test[idx], pred[idx]))
    lo, hi = np.percentile(boot, [2.5, 97.5])
    print(f"{name:9s} F1 = {f1_score(y_test, pred):.3f}  95 % interval [{lo:.3f}, {hi:.3f}]")

diff = []
for _ in range(1000):
    idx = rng.integers(0, n, size=n)
    diff.append(f1_score(y_test[idx], preds["boosting"][idx]) - f1_score(y_test[idx], preds["logreg"][idx]))
lo, hi = np.percentile(diff, [2.5, 97.5])
print(f"boosting minus logreg: {np.mean(diff):+.3f}  95 % interval [{lo:+.3f}, {hi:+.3f}]")
logreg    F1 = 0.382  95 % interval [0.214, 0.521]
boosting  F1 = 0.507  95 % interval [0.357, 0.641]
boosting minus logreg: +0.126  95 % interval [-0.010, +0.259]

区間はF1にして上下約0.3と広いです。希少クラスの検知器を判定するには、53件の陽性は少なすぎるからです。学ぶべきは最後の行です。このテストセットではブースティングがロジスティック回帰を0.126上回っていますが、その差の区間はわずかにゼロを下回ります。この証拠だけでは、ブースティングのほうが優れていると同僚に約束することはできません。上の交差検証はフォールド全体で177件すべての陽性を使っており、より説得力がありました。2つの方法を合わせれば公平な結論になります。ブースティングのほうがおそらく優れているが、その差を数字として引用するにはもっと大きなテストセットが必要だ、ということです。

差は対応のあるリサンプル(両モデルに同じidx)でブートストラップした点に注意してください。モデルごとに別々にブートストラップして区間を比較するのは誤りです。同じ難しい行が引かれれば2つのスコアは一緒に上下するからです。

チェックリスト

スコアを報告書、スライド、プルリクエストに載せる前に確認します。

  1. テスト行は、学習、スケーリング、特徴量選択、ハイパーパラメータの選択に一切使われていない(レッスン02)。
  2. 同じ分割、同じ指標で、ダミーベースラインがモデルの隣に報告されている。
  3. 指標が問題に合っている。正解率はクラスが均衡していて間違いのコストが同じ場合のみ。あるクラスが希少なら適合率と再現率の組、またはF1。しきい値が固定される前の順位付けの品質にはROC-AUC。
  4. しきい値は、当面のコスト構造に合わせて検証データで選ばれており、デフォルトの0.5のまま放置されていない。
  5. 候補の選択には層化交差検証を使い、最終的な数字は一度も触れていない1つのテストセットから得ている。
  6. 数字にはブートストラップによる区間が付いており、主張するモデル間の差は対応のあるリサンプルでブートストラップされている。

大規模言語モデルのベンチマークはこれらの規則を絶えず破っています。学習コーパスにリークしたテストセット、区間のない1回限りのスコアなどです。その側面は評価とベンチマークのハブで扱います。

演習

  1. 最初のブロックのweights=[0.96, 0.04]を[0.5, 0.5]に変え、最初の2つのブロックを再実行してください。ダミーの正解率とモデルの正解率の差、そして正解率とF1の差はどうなりますか。この場合に正解率が見出しの数字として妥当になる理由を一文で説明してください。
  2. しきい値の掃引を使って、再現率が初めて0.5に達するしきい値を見つけ、そのときの適合率を報告してください。次に、そのしきい値を選ぶ場面と、拒否する場面を1つずつ書いてください。
  3. n_samplesを20000に上げてブートストラップのブロックを再実行してください。2つのモデルの差の新しい区間を報告し、今度はゼロを除外しているかどうかを述べてください。今回テストセットに陽性は何件ありましたか。

まとめ

  • 正解率は多数派クラスに支配されます。陽性4%の問題では何も見つけないモデルが96%を出すので、必ずダミーベースラインを隣に報告してください。
  • 適合率(警報が正しい頻度)と再現率(陽性のうち見つけた割合)は対です。F1はその調和平均で、弱いほうを罰します。
  • しきい値0.5はデフォルトであって決定ではありません。ROC-AUCはしきい値とは独立に順位付けの品質を測り、しきい値を選ぶ前の比較に使う数字です。
  • 層化k分割交差検証は、スコアが分割にどれだけ依存するかを示し、モデル間の選択に使う道具です。最終スコアは一度も触れていない1つのテストセットから得ます。
  • テストセットに対するブートストラップは任意の指標に区間を与え、対応のあるリサンプルは2つのモデルの差に区間を与えます。

さらに読む