"""評価指標のテスト(SPEC_02「評価指標」「交差検証の実行」の「評価の出し方」).
- macro F1・accuracy・クラス別 precision / recall / F1・混同行列
(クラス ID 順固定.SPEC_01「クラス定義」)
- 部分集合 `all`(全程度)・`common`(strong と normal)・`mild`
- mild の予測の内訳(淡紅・元のクラス・その他と予測された割合.全体とクラス別)
- fold 間の平均 ± 標準偏差・混同行列の全 fold 合計
期待値は手計算した既知の予測・正解から求める.
"""
from __future__ import annotations
import math
import numpy as np
import pytest
from tias_judge_model.data.classes import CLASS_SLUGS, label_to_id
from tias_judge_model.evaluation.metrics import (
aggregate_folds,
classification_metrics,
macro_f1,
mean_std,
mild_prediction_breakdown,
subset_metrics,
sum_confusion_matrices,
)
PALE, LIGHT_RED, RED, DARK_RED, PURPLE = range(5)
# 正解 [淡白, 淡白, 淡紅, 淡紅, 紅, 紅]・予測 [淡白, 淡紅, 淡紅, 淡紅, 紅, 淡白]
Y_TRUE = np.array([PALE, PALE, LIGHT_RED, LIGHT_RED, RED, RED])
Y_PRED = np.array([PALE, LIGHT_RED, LIGHT_RED, LIGHT_RED, RED, PALE])
# 手計算: 淡白 P=1/2 R=1/2 F1=1/2・淡紅 P=2/3 R=1 F1=4/5・紅 P=1 R=1/2 F1=2/3
EXPECTED = {
"pale": (0.5, 0.5, 0.5, 2),
"light_red": (2 / 3, 1.0, 0.8, 2),
"red": (1.0, 0.5, 2 / 3, 2),
}
EXPECTED_CM = [
[1, 1, 0, 0, 0],
[0, 2, 0, 0, 0],
[1, 0, 1, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
]
class TestClassOrder:
def test_class_ids_are_fixed_as_in_spec(self) -> None:
# SPEC_01「クラス定義」: 0 淡白・1 淡紅・2 紅・3 暗赤紅・4 紫
assert CLASS_SLUGS == ("pale", "light_red", "red", "dark_red", "purple")
assert [label_to_id(s) for s in CLASS_SLUGS] == [0, 1, 2, 3, 4]
class TestClassificationMetrics:
def test_accuracy(self) -> None:
m = classification_metrics(Y_TRUE, Y_PRED)
assert m["accuracy"] == pytest.approx(4 / 6)
assert m["n"] == 6
def test_per_class_precision_recall_f1(self) -> None:
m = classification_metrics(Y_TRUE, Y_PRED)
for slug, (p, r, f1, support) in EXPECTED.items():
got = m["per_class"][slug]
assert got["precision"] == pytest.approx(p)
assert got["recall"] == pytest.approx(r)
assert got["f1"] == pytest.approx(f1)
assert got["support"] == support
def test_macro_f1_averages_classes_present_in_truth(self) -> None:
expected = (0.5 + 0.8 + 2 / 3) / 3
m = classification_metrics(Y_TRUE, Y_PRED)
assert m["macro_f1"] == pytest.approx(expected)
assert macro_f1(Y_TRUE, Y_PRED) == pytest.approx(expected)
def test_macro_f1_perfect_prediction_is_one(self) -> None:
y = np.array([0, 1, 2, 3, 4, 4])
assert macro_f1(y, y) == pytest.approx(1.0)
def test_confusion_matrix_rows_true_cols_pred_in_class_id_order(self) -> None:
m = classification_metrics(Y_TRUE, Y_PRED)
assert m["confusion_matrix"] == EXPECTED_CM
def test_confusion_matrix_is_always_5x5(self) -> None:
# 正解・予測に現れないクラスがあっても軸がぶれない
m = classification_metrics(np.array([PURPLE]), np.array([PURPLE]))
cm = np.array(m["confusion_matrix"])
assert cm.shape == (5, 5)
assert cm[PURPLE, PURPLE] == 1
assert cm.sum() == 1
def test_input_order_does_not_change_metrics(self) -> None:
rng = np.random.default_rng(0)
perm = rng.permutation(len(Y_TRUE))
a = classification_metrics(Y_TRUE, Y_PRED)
b = classification_metrics(Y_TRUE[perm], Y_PRED[perm])
assert a == b
class TestMildPredictionBreakdown:
# mild の正解 [紅, 紅, 紅, 暗赤紅, 紫, 淡白]・予測 [淡紅, 紅, 暗赤紅, 暗赤紅, 淡紅, 淡白]
TRUE = np.array([RED, RED, RED, DARK_RED, PURPLE, PALE])
PRED = np.array([LIGHT_RED, RED, DARK_RED, DARK_RED, LIGHT_RED, PALE])
def test_overall_fractions(self) -> None:
b = mild_prediction_breakdown(self.TRUE, self.PRED)["overall"]
assert b["n"] == 6
assert b["light_red"] == pytest.approx(2 / 6)
assert b["original"] == pytest.approx(3 / 6)
assert b["other"] == pytest.approx(1 / 6)
def test_fractions_sum_to_one(self) -> None:
b = mild_prediction_breakdown(self.TRUE, self.PRED)
for entry in [b["overall"], *b["per_class"].values()]:
assert entry["light_red"] + entry["original"] + entry["other"] == pytest.approx(1.0)
def test_per_class_fractions(self) -> None:
per_class = mild_prediction_breakdown(self.TRUE, self.PRED)["per_class"]
red = per_class["red"]
assert red["n"] == 3
assert red["light_red"] == pytest.approx(1 / 3)
assert red["original"] == pytest.approx(1 / 3)
assert red["other"] == pytest.approx(1 / 3)
assert per_class["dark_red"]["original"] == pytest.approx(1.0)
assert per_class["purple"]["light_red"] == pytest.approx(1.0)
assert per_class["pale"]["original"] == pytest.approx(1.0)
class TestSubsetMetrics:
# 程度つきの held_out: strong 2・normal 2・mild 2
TRUE = np.array([RED, RED, LIGHT_RED, LIGHT_RED, RED, PURPLE])
PRED = np.array([RED, PALE, LIGHT_RED, LIGHT_RED, LIGHT_RED, PURPLE])
SEV = ["strong", "strong", "normal", "normal", "mild", "mild"]
def test_subset_sizes(self) -> None:
s = subset_metrics(self.TRUE, self.PRED, self.SEV)
assert s["all"]["n"] == 6
assert s["common"]["n"] == 4
assert s["mild"]["n"] == 2
def test_common_is_strong_and_normal_only(self) -> None:
s = subset_metrics(self.TRUE, self.PRED, self.SEV)
expected = classification_metrics(self.TRUE[:4], self.PRED[:4])
assert s["common"]["accuracy"] == pytest.approx(expected["accuracy"])
assert s["common"]["confusion_matrix"] == expected["confusion_matrix"]
def test_all_uses_every_image(self) -> None:
s = subset_metrics(self.TRUE, self.PRED, self.SEV)
assert s["all"]["accuracy"] == pytest.approx(4 / 6)
assert np.array(s["all"]["confusion_matrix"]).sum() == 6
def test_mild_has_prediction_breakdown(self) -> None:
s = subset_metrics(self.TRUE, self.PRED, self.SEV)
overall = s["mild"]["prediction_breakdown"]["overall"]
assert overall["n"] == 2
assert overall["light_red"] == pytest.approx(0.5)
assert overall["original"] == pytest.approx(0.5)
assert overall["other"] == pytest.approx(0.0)
assert s["mild"]["accuracy"] == pytest.approx(0.5)
def test_empty_mild_subset_does_not_crash(self) -> None:
s = subset_metrics(self.TRUE[:4], self.PRED[:4], self.SEV[:4])
assert s["common"]["n"] == 4
assert s["all"]["n"] == 4
assert not s["mild"]
class TestFoldAggregation:
def test_mean_std_uses_sample_std(self) -> None:
r = mean_std([0.5, 0.7, 0.9])
assert r["mean"] == pytest.approx(0.7)
assert r["std"] == pytest.approx(0.2) # ddof=1
def test_mean_std_single_value(self) -> None:
r = mean_std([0.4])
assert r["mean"] == pytest.approx(0.4)
assert r["std"] == pytest.approx(0.0)
def test_aggregate_folds_averages_macro_f1_and_accuracy(self) -> None:
f1 = classification_metrics(np.array([0, 1]), np.array([0, 1])) # acc 1.0
f2 = classification_metrics(np.array([0, 1]), np.array([1, 1])) # acc 0.5
agg = aggregate_folds([f1, f2])
assert agg["accuracy"]["mean"] == pytest.approx(0.75)
assert agg["accuracy"]["std"] == pytest.approx(math.sqrt(0.125))
assert agg["macro_f1"]["mean"] == pytest.approx((1.0 + f2["macro_f1"]) / 2)
assert agg["per_class"]["light_red"]["recall"]["mean"] == pytest.approx(1.0)
def test_sum_confusion_matrices(self) -> None:
a = classification_metrics(Y_TRUE, Y_PRED)["confusion_matrix"]
b = classification_metrics(np.array([PURPLE, DARK_RED]), np.array([PURPLE, RED]))[
"confusion_matrix"
]
total = np.array(sum_confusion_matrices([a, b]))
assert total.shape == (5, 5)
assert total.sum() == 8
assert total[PURPLE, PURPLE] == 1
assert total[DARK_RED, RED] == 1
assert total[LIGHT_RED, LIGHT_RED] == 2