"""回収した交差検証の結果(runs/*_cv_*/metrics.json)を集計して Markdown の表にする.
`tias-remote pull` で手元に回収した実験ディレクトリを読む(metrics.json と config.yaml は
集計値のみで個人情報を含まない).モデル × 学習率 × 条件(ややあり込み/除外)ごとに,
全 seed × 5 fold の fold ごとの値の平均 ± 標準偏差と,同じ seed・同じ fold どうしの
対応比較(除外 − 込み)を出す.結果は標準出力(Markdown)と runs/_cv_summary.json に書く.
使い方:
uv run python scripts/summarize_cv.py [runs]
"""
import json
import math
import sys
from collections import defaultdict
from pathlib import Path
import yaml
CLASSES = ["pale", "light_red", "red", "dark_red", "purple"]
JP = {"pale": "淡白", "light_red": "淡紅", "red": "紅", "dark_red": "暗赤紅", "purple": "紫"}
MODEL_LABEL = {
"convnext_tiny": "ConvNeXt-Tiny",
"efficientnet_b0": "EfficientNet-B0",
"resnet50": "ResNet-50",
"vit_small_patch16_224": "ViT-Small",
}
MODEL_ORDER = list(MODEL_LABEL)
CONDITIONS = ("with_mild", "without_mild")
CONDITION_LABEL = {"with_mild": "込み", "without_mild": "除外"}
MeanStd = tuple[float, float, int]
def mean_std(xs: list[float]) -> MeanStd:
"""平均・不偏標準偏差・件数を返す."""
xs = [x for x in xs if x is not None and not math.isnan(x)]
if not xs:
return (float("nan"), float("nan"), 0)
m = sum(xs) / len(xs)
s = math.sqrt(sum((x - m) ** 2 for x in xs) / (len(xs) - 1)) if len(xs) > 1 else 0.0
return (m, s, len(xs))
def fmt(ms: MeanStd, digits: int = 3) -> str:
"""平均 ± 標準偏差を文字列にする."""
m, s, n = ms
return "—" if n == 0 else f"{m:.{digits}f} ± {s:.{digits}f}"
def label(key: tuple[str, float]) -> str:
"""(モデル名, 学習率)の表示名."""
name, lr = key
return f"{MODEL_LABEL[name]}(lr {lr:g})"
def load_runs(root: Path) -> list[dict]:
"""実験ディレクトリを読む(対象外のモデルは飛ばす)."""
runs = []
for mpath in sorted(root.glob("*_cv_*/metrics.json")):
metrics = json.loads(mpath.read_text(encoding="utf-8"))
doc = yaml.safe_load((mpath.parent / "config.yaml").read_text(encoding="utf-8"))
conf = doc.get("config", doc)
model = metrics["environment"]["model_name"]
if model not in MODEL_LABEL:
continue
cond = "with_mild" if "mild" in metrics["severities_trained"] else "without_mild"
runs.append(
{
"run_id": metrics["run_id"],
"model": (model, float(conf["train"]["lr"])),
"cond": cond,
"seed": conf["split"]["seed"],
"m": metrics,
}
)
return runs
def fold_values(run: dict, subset: str, key: str) -> list[float]:
"""fold ごとの指標の値."""
return [f["subsets"][subset][key] for f in run["m"]["folds"]]
def fold_class_recall(run: dict, subset: str, cls: str) -> list[float]:
"""fold ごとのクラス別再現率(正解に現れた fold のみ)."""
out = []
for f in run["m"]["folds"]:
per_class = f["subsets"][subset].get("per_class", {})
if cls in per_class:
out.append(per_class[cls]["recall"])
return out
def mild_to_normal(run: dict) -> float:
"""mild の画像を淡紅と予測した割合(全 fold をまとめた値)."""
return run["m"]["pooled"]["mild"]["prediction_breakdown"]["overall"]["light_red"]
def pooled_folds(runs: list[dict], subset: str, key: str) -> MeanStd:
"""複数 run の fold ごとの値をまとめて平均 ± 標準偏差にする."""
return mean_std([v for r in runs for v in fold_values(r, subset, key)])
def main(root: Path) -> None:
"""集計して表を出力する."""
runs = load_runs(root)
groups: dict = defaultdict(list)
for r in runs:
groups[(r["model"], r["cond"])].append(r)
keys = sorted({r["model"] for r in runs}, key=lambda k: (MODEL_ORDER.index(k[0]), k[1]))
seeds = ", ".join(sorted({str(r["seed"]) for r in runs}))
lines = [f"対象: {len(runs)} 本(seed {seeds})\n"]
lines.append(
"| モデル | 条件 | seed 数 | common macro F1 | common 正解率 | all macro F1"
" | mild→淡紅 | 1 本の所要(秒) |"
)
lines.append("| --- | --- | --- | --- | --- | --- | --- | --- |")
summary = {}
for model in keys:
for cond in CONDITIONS:
rs = groups.get((model, cond), [])
if not rs:
continue
row = {
"common_f1": pooled_folds(rs, "common", "macro_f1"),
"common_acc": pooled_folds(rs, "common", "accuracy"),
"all_f1": pooled_folds(rs, "all", "macro_f1"),
"mild_to_normal": mean_std([mild_to_normal(r) for r in rs]),
"seconds": mean_std([r["m"]["timing"]["total_seconds"] for r in rs]),
}
cells = [
label(model),
CONDITION_LABEL[cond],
str(len(rs)),
fmt(row["common_f1"]),
fmt(row["common_acc"]),
fmt(row["all_f1"]),
fmt(row["mild_to_normal"], 2),
fmt(row["seconds"], 0),
]
lines.append("| " + " | ".join(cells) + " |")
summary[f"{model[0]}/lr{model[1]:g}/{cond}"] = row
lines.append(
"\n※ macro F1・正解率は全 seed × 5 fold の fold ごとの値の平均 ± 標準偏差."
"mild→淡紅 は run ごとの(全 fold をまとめた)割合の平均 ± 標準偏差.\n"
)
lines.append("| モデル | 条件 | " + " | ".join(JP[c] for c in CLASSES) + " |")
lines.append("| --- | --- | " + " | ".join("---" for _ in CLASSES) + " |")
for model in keys:
for cond in CONDITIONS:
rs = groups.get((model, cond), [])
if not rs:
continue
cells = [
fmt(mean_std([v for r in rs for v in fold_class_recall(r, "common", c)]), 2)
for c in CLASSES
]
lines.append(
f"| {label(model)} | {CONDITION_LABEL[cond]} | " + " | ".join(cells) + " |"
)
lines.append(
"\n※ common(「あり」と淡紅)での各クラスの再現率(fold ごとの値の平均 ± 標準偏差).\n"
)
lines.append(
"| モデル | 対応のある fold 数 | common F1 の差(除外 − 込み) | 除外が上回った fold |"
)
lines.append("| --- | --- | --- | --- |")
paired = {}
for model in keys:
with_mild = {r["seed"]: r for r in groups.get((model, "with_mild"), [])}
without = {r["seed"]: r for r in groups.get((model, "without_mild"), [])}
diffs = []
for seed in sorted(set(with_mild) & set(without)):
a = fold_values(with_mild[seed], "common", "macro_f1")
b = fold_values(without[seed], "common", "macro_f1")
diffs += [y - x for x, y in zip(a, b, strict=True)]
if diffs:
wins = sum(1 for d in diffs if d > 0)
n = len(diffs)
lines.append(f"| {label(model)} | {n} | {fmt(mean_std(diffs))} | {wins} / {n} |")
paired[f"{model[0]}/lr{model[1]:g}"] = {
"diff": mean_std(diffs),
"wins": wins,
"n": n,
}
lines.append(
"\n※ 同じ seed なら「込み」と「除外」の分割は完全に同じ(SPEC_02)なので,"
"fold ごとの差を取っている.\n"
)
print("\n".join(lines))
out = {"summary": summary, "paired": paired, "runs": [r["run_id"] for r in runs]}
(root / "_cv_summary.json").write_text(
json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8"
)
if __name__ == "__main__":
main(Path(sys.argv[1] if len(sys.argv) > 1 else "runs"))