評価指標
分類、回帰、ランキング、生成の評価指標を4つの表にまとめ、式、使うべき場面、隠してしまうものを示します。実行できるscikit-learnのブロックとブートストラップ信頼区間も付けています。
このシートは、数値を報告するときにその数値に意味を持たせたい人のためのものです。各表には式、適する状況、そして隠してしまう失敗を示します。ブロックは手計算で確認できるほど小さい配列ですべての指標を計算します。分割とリークに関する考え方は /learn/beginner/06-evaluation-that-does-not-lie/ を参照してください。
分類
記法: TP、FP、TN、FNは正例クラスについての混同行列の4つのセルです。pは予測確率です。
| 指標 | 式 | 使う場面 | 隠してしまうもの |
|---|---|---|---|
| 正解率(Accuracy) | (TP + TN) / N | クラスが均衡し、誤りのコストが同じ | 正例が1%なら、すべて負例と予測しても99%になる |
| 適合率(Precision) | TP / (TP + FP) | 誤報が高くつく(スパム、不正レビューのキュー) | 見逃した正例については何も語らない |
| 再現率(Recall、感度) | TP / (TP + FN) | 見逃しが高くつく(スクリーニング、安全性) | すべてを正例と予測すれば再現率は1になる |
| F1 | 2 PR / (P + R) | 不均衡な二値タスクを1つの数値で表す | 真陰性を無視し、コストに合うかどうかに関係なくPとRを同じ重みで扱う |
| Matthews相関係数(MCC) | (TP TN - FP FN) / sqrt((TP+FP)(TP+FN)(TN+FP)(TN+FN)) | 不均衡データで、4つのセルすべてを考慮したい | 読者になじみが薄く、行または列が空だと定義されない |
| ROC AUC | ランダムな正例がランダムな負例より高いスコアを得る確率 | 閾値に依存せずランカーを比較する | 負例が多いとFPRが小さいままなので、強い不均衡では楽観的になる |
| PR AUC(平均適合率) | 適合率を再現率に対して描いた曲線の下の面積 | 強い不均衡で、関心があるのは正例クラス | 基準率に依存するため、データセット間で比較できない |
| 対数損失(交差エントロピー) | -mean(y log p + (1 - y) log(1 - p)) | キャリブレーションされた確率が必要 | 自信満々の誤った予測1つが全体を支配する |
| キャリブレーション誤差(ECE) | ビンごとのabs(accuracy - confidence)の平均 | 確率が下流の意思決定に使われる | ビンの選び方で数値が変わる |
多クラス分類: average="macro"はすべてのクラスを等しく扱い(小さいクラスも数える)、"micro"はセルを合算し(大きいクラスが支配する)、"weighted"はサポート数でスケールしたmacroです。どれを使ったかを明記してください。
import numpy as np
from sklearn.metrics import (accuracy_score, precision_score, recall_score, f1_score,
roc_auc_score, average_precision_score, log_loss,
matthews_corrcoef, confusion_matrix)
y_true = np.array([1, 1, 1, 1, 0, 0, 0, 0, 0, 0])
y_prob = np.array([0.9, 0.8, 0.4, 0.3, 0.7, 0.2, 0.1, 0.05, 0.3, 0.15])
y_pred = (y_prob >= 0.5).astype(int)
print("confusion [[tn fp] [fn tp]]:", confusion_matrix(y_true, y_pred).tolist())
print(f"accuracy {accuracy_score(y_true, y_pred):.3f}")
print(f"precision {precision_score(y_true, y_pred):.3f}")
print(f"recall {recall_score(y_true, y_pred):.3f}")
print(f"F1 {f1_score(y_true, y_pred):.3f}")
print(f"MCC {matthews_corrcoef(y_true, y_pred):.3f}")
print(f"ROC AUC {roc_auc_score(y_true, y_prob):.3f}")
print(f"PR AUC {average_precision_score(y_true, y_prob):.3f}")
print(f"log loss {log_loss(y_true, y_prob):.3f}")
confusion [[tn fp] [fn tp]]: [[5, 1], [2, 2]]
accuracy 0.700
precision 0.667
recall 0.500
F1 0.571
MCC 0.356
ROC AUC 0.896
PR AUC 0.854
log loss 0.455
閾値ベースの指標(F1 0.571)とランキング指標(ROC AUC 0.896)の差に注目してください。スコアはクラスをうまく順序付けていますが、0.5という閾値の位置が間違っています。閾値は、重視するコストに合わせて検証セットで選びます。テストセットで選んではいけません。
回帰
| 指標 | 式 | 使う場面 | 隠してしまうもの |
|---|---|---|---|
| MAE | mean(abs(y - yhat)) | 誤差のコストが線形で、yの単位で数値が欲しい | 10単位の外れを1単位の外れ10個と同じに扱う |
| MSE | mean((y - yhat)^2) | 学習時の損失、大きな誤差をより重視する | 単位が2乗され、外れ値1つが支配する |
| RMSE | sqrt(MSE) | MSEと同じだがyの単位で読める | 依然として外れ値に左右される |
| MAPE | mean(abs(y - yhat) / abs(y)) | 系列をまたいだスケールに依存しない比較 | y = 0の近くで発散し、過小予測より過大予測を強く罰する |
| 決定係数(R二乗) | 1 - SS_res / SS_tot | 平均を予測する場合と比べてどれだけ分散を説明したかを報告する | テストセットでは負になりうる。学習セットでは特徴量を増やすたびに上がる |
| 分位点(ピンボール)損失 | mean(max(q (y - yhat), (q - 1)(y - yhat))) | 区間やパーセンタイルを予測する | 分位点ごとに報告され、qごとに1つの数値になる |
import numpy as np
from sklearn.metrics import (mean_absolute_error, mean_squared_error, root_mean_squared_error,
r2_score, mean_absolute_percentage_error)
y_true = np.array([3.0, 5.0, 2.5, 7.0, 4.0, 10.0])
y_pred = np.array([2.8, 5.4, 2.0, 6.5, 4.2, 14.0]) # one large miss on the last point
print(f"MAE {mean_absolute_error(y_true, y_pred):.3f}")
print(f"MSE {mean_squared_error(y_true, y_pred):.3f}")
print(f"RMSE {root_mean_squared_error(y_true, y_pred):.3f}")
print(f"MAPE {mean_absolute_percentage_error(y_true, y_pred):.3f}")
print(f"R2 {r2_score(y_true, y_pred):.3f}")
print(f"R2 of predicting the mean: {r2_score(y_true, np.full_like(y_true, y_true.mean())):.3f}")
MAE 0.967
MSE 2.790
RMSE 1.670
MAPE 0.145
R2 0.580
R2 of predicting the mean: 0.000
6つの予測のうち5つは誤差0.5以内です。たった1つの4単位の外れが、RMSEをMAEのほぼ2倍にしています。両方を報告すれば、読者は大きな誤差が少数あるのか、小さな誤差が多数あるのかを見分けられます。
ランキングと検索
1つのクエリに対して、ランク付きリストと関連アイテムの集合があるとします。報告するときはクエリ間で平均します。
| 指標 | 式 | 使う場面 | 隠してしまうもの |
|---|---|---|---|
| Recall at k | 上位k件中の関連アイテム数 / 全関連アイテム数 | 生成器が上位k件しか見ないRAGの検索 | k件の中での順序は無視され、すべての関連アイテムを把握している必要がある |
| Precision at k | 上位k件中の関連アイテム数 / k | 固定サイズの結果ページ | 関連アイテムがk件未満のクエリを罰する |
| 平均逆順位(MRR) | 最初の関連アイテムの順位の逆数のクエリ平均 | 正解が1つあれば十分(質問応答、既知アイテム検索) | 最初以降の関連アイテムをすべて無視する |
| 平均適合率の平均(MAP) | 各関連位置での適合率の平均のクエリ平均 | 関連アイテムが複数あり、順序が重要 | 二値の関連性のみ |
| nDCG at k | DCG / 理想DCG、DCG = sum of gain / log2(rank + 1) | 段階的な関連性があり、順序が重要 | 段階付きラベルが必要で、それは高価 |
| Hit rate at k | 上位k件に関連アイテムが1つでもあれば1 | ユーザーごとに1件をホールドアウトする推薦システム | 粗い |
import numpy as np
from sklearn.metrics import ndcg_score
# One query. Ranked list of 6 document ids and which of them are relevant.
ranked = ["d3", "d7", "d1", "d9", "d2", "d5"]
relevant = {"d7", "d2", "d8"} # d8 was never retrieved
def recall_at_k(ranked, relevant, k):
return len(set(ranked[:k]) & relevant) / len(relevant)
def precision_at_k(ranked, relevant, k):
return len(set(ranked[:k]) & relevant) / k
def reciprocal_rank(ranked, relevant):
for i, d in enumerate(ranked, start=1):
if d in relevant:
return 1 / i
return 0.0
def average_precision(ranked, relevant):
hits, total = 0, 0.0
for i, d in enumerate(ranked, start=1):
if d in relevant:
hits += 1
total += hits / i
return total / len(relevant)
for k in (1, 3, 5):
print(f"recall@{k} {recall_at_k(ranked, relevant, k):.3f} precision@{k} {precision_at_k(ranked, relevant, k):.3f}")
print(f"MRR {reciprocal_rank(ranked, relevant):.3f} AP {average_precision(ranked, relevant):.3f}")
# nDCG uses graded relevance: scores are the ranker's, gains are the labels, in rank order
gains = np.array([[0, 2, 0, 0, 1, 0]]) # d7 highly relevant, d2 partly
scores = np.array([[6, 5, 4, 3, 2, 1]]) # descending, i.e. the ranked order
print(f"nDCG@3 {ndcg_score(gains, scores, k=3):.3f} nDCG@6 {ndcg_score(gains, scores):.3f}")
recall@1 0.000 precision@1 0.000
recall@3 0.333 precision@3 0.333
recall@5 0.667 precision@5 0.400
MRR 0.500 AP 0.300
nDCG@3 0.480 nDCG@6 0.627
d8が一度も取得されなかったため、recall at 5は0.667が上限です。RAGシステムで注視すべき数値はこれです。正解パッセージが上位k件に入っていなければ、下流でどんなプロンプトエンジニアリングをしても取り戻せません。/reference/rag-pipeline-checklist/ を参照してください。
生成
| 指標 | 式 | 使う場面 | 隠してしまうもの |
|---|---|---|---|
| パープレキシティ | exp(-mean log p(token)) | 同じトークナイザとデータで言語モデルを比較する | トークナイザが異なると比較できず、有用性については何も語らない |
| BLEU | 簡潔さペナルティ × n-gram適合率の幾何平均、n = 1〜4 | 複数の参照訳がある機械翻訳 | いずれかのn-gram次数で一致がなければゼロになり、意味と4を超える語順には盲目 |
| ROUGE-L | 最長共通部分列のF値 | 参照要約に対する要約評価 | 原文のコピーを報い、妥当な言い換えを罰する |
| 完全一致とトークンF1 | 文字列の等価性、またはトークン単位のF1 | 正解が既知の短答型QA | 書式の違いが誤りとして数えられる |
| BERTScore | 文脈埋め込みのコサイン類似度、トークン単位で対応付け | 言い換えに寛容な参照との類似度 | 埋め込みモデルに依存し、スコアが狭い帯域に集中する |
| LLM-as-a-judge | モデルがルーブリックに照らして出力を採点する | 参照が存在しない、自由形式のタスク | 位置と長さのバイアス、審判自身の誤り、そして人手ラベルに対する検証が必須 |
| Pass at k | k個のサンプルのいずれかがテストに通る問題の割合 | コード生成 | テストが決めるため、弱いテストは誤ったコードを通す |
| 人間の選好(勝率) | ペア比較で勝った割合 | 品質に関する最終判断 | 高価で遅く、評価者の意見が割れる |
import math
from collections import Counter
def ngrams(tokens, n):
return Counter(tuple(tokens[i:i + n]) for i in range(len(tokens) - n + 1))
def bleu(candidate, reference, max_n=4):
"""Sentence-level BLEU with uniform weights, no smoothing. Zero if any n-gram order has no match."""
cand, ref = candidate.split(), reference.split()
log_precision = 0.0
for n in range(1, max_n + 1):
c, r = ngrams(cand, n), ngrams(ref, n)
overlap = sum(min(count, r[g]) for g, count in c.items())
total = max(sum(c.values()), 1)
if overlap == 0:
return 0.0
log_precision += math.log(overlap / total) / max_n
brevity = 1.0 if len(cand) > len(ref) else math.exp(1 - len(ref) / len(cand))
return brevity * math.exp(log_precision)
def rouge_l(candidate, reference):
"""F-measure of the longest common subsequence of tokens."""
a, b = candidate.split(), reference.split()
table = [[0] * (len(b) + 1) for _ in range(len(a) + 1)]
for i, x in enumerate(a, 1):
for j, y in enumerate(b, 1):
table[i][j] = table[i - 1][j - 1] + 1 if x == y else max(table[i - 1][j], table[i][j - 1])
lcs = table[-1][-1]
if lcs == 0:
return 0.0
p, r = lcs / len(a), lcs / len(b)
return 2 * p * r / (p + r)
def perplexity(token_log_probs):
return math.exp(-sum(token_log_probs) / len(token_log_probs))
ref = "the cat sat on the mat"
for cand in ["the cat sat on the mat", "the cat is on the mat", "a dog slept on a rug"]:
print(f"{cand!r:32} BLEU {bleu(cand, ref):.3f} ROUGE-L {rouge_l(cand, ref):.3f}")
print(f"perplexity of a 6-token sequence at log p = -2.0 each: {perplexity([-2.0] * 6):.2f}")
'the cat sat on the mat' BLEU 1.000 ROUGE-L 1.000
'the cat is on the mat' BLEU 0.000 ROUGE-L 0.833
'a dog slept on a rug' BLEU 0.000 ROUGE-L 0.167
perplexity of a 6-token sequence at log p = -2.0 each: 7.39
2番目の候補は1語変えただけですが、4-gramが1つも残らないため、平滑化なしの文単位BLEUではゼロになります。コーパス単位のBLEUや平滑化版が存在するのはこのためです。公開するものにはライブラリ(sacrebleu)を使い、どの変種かを明記してください。
ブートストラップによる信頼区間
テストセット上の指標は標本統計量です。テスト項目を復元抽出で再サンプリングし、指標を2000回ほど再計算して、2.5パーセンタイルと97.5パーセンタイルを読み取ります。区間が大きく重なる2つのモデルは、リーダーボードが何と言おうと、そのテストセットでは区別できません。対応のある比較では、両モデルで同じインデックスを再サンプリングし、差をブートストラップします。
import numpy as np
from sklearn.metrics import f1_score
rng = np.random.default_rng(0)
n = 200
y_true = rng.integers(0, 2, n)
# a classifier that is right about 80 % of the time
y_pred = np.where(rng.random(n) < 0.8, y_true, 1 - y_true)
point = f1_score(y_true, y_pred)
boot = []
for _ in range(2000):
idx = rng.integers(0, n, n) # resample test items with replacement
boot.append(f1_score(y_true[idx], y_pred[idx]))
lo, hi = np.percentile(boot, [2.5, 97.5])
print(f"F1 {point:.3f} 95 % bootstrap CI [{lo:.3f}, {hi:.3f}] width {hi - lo:.3f}")
# halve the test set and the interval widens
half = n // 2
boot_half = [f1_score(y_true[idx], y_pred[idx]) for idx in (rng.integers(0, half, half) for _ in range(2000))]
lo2, hi2 = np.percentile(boot_half, [2.5, 97.5])
print(f"with n={half}: CI [{lo2:.3f}, {hi2:.3f}] width {hi2 - lo2:.3f}")
F1 0.748 95 % bootstrap CI [0.680, 0.809] width 0.129
with n=100: CI [0.632, 0.820] width 0.188
テスト項目が200件だと、区間の幅は13ポイントです。その規模のテストセットでの2ポイントの改善は、そうでないと示されるまではノイズです。見出しとなる数値には必ず、区間、テストセットのサイズ、シードの数を添えて報告してください。
出典
- scikit-learnの評価指標とスコアリング
- Davis and Goadrich, The Relationship Between Precision-Recall and ROC Curves, 2006
- Papineni et al., BLEU: a Method for Automatic Evaluation of Machine Translation, 2002
- Lin, ROUGE: A Package for Automatic Evaluation of Summaries, 2004
- Zhang et al., BERTScore: Evaluating Text Generation with BERT, 2019
- Zheng et al., Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena, 2023
- Chen et al., Evaluating Large Language Models Trained on Code (pass at k), 2021
- Efron and Tibshirani, An Introduction to the Bootstrap, 1993