File size: 8,191 Bytes
f211247
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
"""Per-tablet error analysis for the v13 ensemble on Hitit cuneiform OCR.

Loads the ensemble probs dump (which already carries tablet_ids, targets, and
label_to_idx), computes per-tablet top1 + top-5 confusions, and dumps a JSON
report plus a human-readable markdown table with the worst/best tablets.

Usage:
    python3 hitit_ocr/src/analysis/per_tablet_errors.py
"""

from __future__ import annotations

import json
from collections import Counter, defaultdict
from pathlib import Path

import torch

PROBS_PATH = Path("/arf/scratch/stakan/hitit-proje/hitit_ocr/runs/h100/ensemble_v13_probs.pt")
EVAL_PATH = Path("/arf/scratch/stakan/hitit-proje/hitit_ocr/runs/h100/ensemble_v13_eval.json")
LABEL_MAP_PATH = Path("/arf/scratch/stakan/hitit-proje/hitit_ocr/runs/h100/v13_label_to_idx.json")
OUT_JSON = Path("/arf/scratch/stakan/hitit-proje/hitit_ocr/runs/h100/per_tablet_error_analysis.json")
OUT_MD = Path("/arf/scratch/stakan/hitit-proje/hitit_ocr/runs/h100/per_tablet_error_analysis.md")

MIN_SAMPLES = 20  # tablets below this threshold are excluded from worst/best shortlists


def load_inputs():
    dump = torch.load(PROBS_PATH, map_location="cpu", weights_only=False)
    probs: torch.Tensor = dump["probs"]
    targets: torch.Tensor = dump["targets"]
    tablet_ids = list(dump["tablet_ids"])
    # Prefer the dump's label_to_idx (it was used to produce the predictions);
    # fall back to the on-disk file so the two agree.
    label_to_idx = dump.get("label_to_idx")
    if label_to_idx is None:
        with LABEL_MAP_PATH.open() as f:
            label_to_idx = json.load(f)
    if len(tablet_ids) != probs.shape[0]:
        raise RuntimeError(
            f"tablet_ids length {len(tablet_ids)} != probs rows {probs.shape[0]}"
        )
    if targets.shape[0] != probs.shape[0]:
        raise RuntimeError(
            f"targets length {targets.shape[0]} != probs rows {probs.shape[0]}"
        )
    return probs, targets, tablet_ids, label_to_idx


def main() -> None:
    probs, targets, tablet_ids, label_to_idx = load_inputs()

    idx_to_label = {int(v): k for k, v in label_to_idx.items()}
    preds = probs.argmax(dim=1)
    correct = preds.eq(targets)
    global_top1 = float(correct.float().mean().item())

    # Cross-check against the existing eval json.
    try:
        with EVAL_PATH.open() as f:
            eval_json = json.load(f)
        expected_top1 = float(eval_json.get("ensemble_top1", -1))
    except Exception:
        expected_top1 = -1.0

    # Group sample indices by tablet.
    by_tablet: dict[str, list[int]] = defaultdict(list)
    for i, tid in enumerate(tablet_ids):
        by_tablet[str(tid)].append(i)

    per_tablet_records: list[dict] = []
    for tid, idxs in by_tablet.items():
        n = len(idxs)
        tcorrect = int(correct[idxs].sum().item())
        acc = tcorrect / n
        # Top-5 confusion pairs (only wrong predictions).
        conf_counter: Counter = Counter()
        for i in idxs:
            if not bool(correct[i].item()):
                true_lbl = idx_to_label[int(targets[i].item())]
                pred_lbl = idx_to_label[int(preds[i].item())]
                conf_counter[(true_lbl, pred_lbl)] += 1
        top5_conf = [
            [t, p, int(c)] for (t, p), c in conf_counter.most_common(5)
        ]
        per_tablet_records.append(
            {
                "tablet_id": tid,
                "n": n,
                "top1": acc,
                "n_correct": tcorrect,
                "top5_confusions": top5_conf,
            }
        )

    # Sort: worst first (lowest accuracy, break ties by higher n so the shortlist
    # surfaces statistically meaningful tablets).
    eligible = [r for r in per_tablet_records if r["n"] >= MIN_SAMPLES]
    worst_sorted = sorted(eligible, key=lambda r: (r["top1"], -r["n"]))
    best_sorted = sorted(eligible, key=lambda r: (-r["top1"], -r["n"]))
    worst_10 = worst_sorted[:10]
    best_5 = best_sorted[:5]

    # Also keep a default stable sort for the full list (by n desc, then acc asc).
    per_tablet_records.sort(key=lambda r: (-r["n"], r["top1"]))

    report = {
        "global_top1": global_top1,
        "expected_top1": expected_top1,
        "n_samples": int(probs.shape[0]),
        "n_tablets": len(by_tablet),
        "min_samples_for_shortlist": MIN_SAMPLES,
        "n_tablets_eligible": len(eligible),
        "per_tablet": per_tablet_records,
        "worst_10": worst_10,
        "best_5": best_5,
    }

    OUT_JSON.parent.mkdir(parents=True, exist_ok=True)
    with OUT_JSON.open("w") as f:
        json.dump(report, f, indent=2, ensure_ascii=False)

    # Console summary.
    print(
        f"Global top1 = {global_top1:.4f} (expected {expected_top1:.4f}) over "
        f"{probs.shape[0]} samples across {len(by_tablet)} tablets "
        f"({len(eligible)} eligible at n>={MIN_SAMPLES})."
    )
    print("\nWorst 10 tablets (n>=20):")
    print(f"  {'tablet_id':<12}{'n':>6}{'acc':>8}   dominant confusion (true->pred, count)")
    for r in worst_10:
        dom = r["top5_confusions"][0] if r["top5_confusions"] else ["-", "-", 0]
        print(
            f"  {r['tablet_id']:<12}{r['n']:>6}{r['top1']:>8.3f}   "
            f"{dom[0]}->{dom[1]} ({dom[2]})"
        )
    print("\nBest 5 tablets (n>=20):")
    print(f"  {'tablet_id':<12}{'n':>6}{'acc':>8}")
    for r in best_5:
        print(f"  {r['tablet_id']:<12}{r['n']:>6}{r['top1']:>8.3f}")

    # Markdown.
    md_lines: list[str] = []
    md_lines.append("# Per-tablet error analysis (v13 ensemble)\n")
    md_lines.append(
        f"- Global top1: **{global_top1:.4f}** (sanity vs `ensemble_v13_eval.json`: {expected_top1:.4f})"
    )
    md_lines.append(
        f"- Samples: {probs.shape[0]} | Tablets: {len(by_tablet)} "
        f"| Eligible (n>={MIN_SAMPLES}): {len(eligible)}"
    )
    md_lines.append(
        f"- Architectures in ensemble: dinov3_vitl14 x2, convnextv2_large, dinov3_vitb14\n"
    )
    md_lines.append("## Worst 10 tablets\n")
    md_lines.append("| tablet_id | n | top1 | dominant confusion (true -> pred x count) |")
    md_lines.append("|---|---:|---:|---|")
    for r in worst_10:
        if r["top5_confusions"]:
            t, p, c = r["top5_confusions"][0]
            dom = f"{t} -> {p} x{c}"
        else:
            dom = "(none)"
        md_lines.append(
            f"| {r['tablet_id']} | {r['n']} | {r['top1']:.3f} | {dom} |"
        )
    md_lines.append("\n## Best 5 tablets\n")
    md_lines.append("| tablet_id | n | top1 |")
    md_lines.append("|---|---:|---:|")
    for r in best_5:
        md_lines.append(f"| {r['tablet_id']} | {r['n']} | {r['top1']:.3f} |")

    # 3-sentence commentary.
    if worst_10 and best_5:
        w = worst_10[0]
        b = best_5[0]
        w_conf = w["top5_confusions"][0] if w["top5_confusions"] else None
        conf_str = (
            f"with the dominant confusion being {w_conf[0]} -> {w_conf[1]} "
            f"({w_conf[2]}/{w['n'] - w['n_correct']} of its errors)"
            if w_conf
            else "without a single dominant confusion"
        )
        md_lines.append("\n## Commentary\n")
        md_lines.append(
            f"The worst tablet (`{w['tablet_id']}`, n={w['n']}) lands at top1={w['top1']:.3f}, "
            f"{conf_str}, hinting at either a distinct visual regime (erosion, lighting, "
            f"scribe hand) or noisy labels rather than bulk class imbalance. "
            f"In contrast the best tablet (`{b['tablet_id']}`, n={b['n']}) reaches top1={b['top1']:.3f}, "
            f"so ensemble capacity is clearly sufficient when the input distribution matches training. "
            f"Error mass concentrates on a handful of tablets: the bottom-10 eligible tablets contribute "
            f"{sum(r['n'] - r['n_correct'] for r in worst_10)} of "
            f"{int((~correct).sum().item())} total errors, which argues for tablet-aware augmentation or "
            f"pseudo-labeling (CoTTA / relight / stroke-aux) targeted at those IDs."
        )

    with OUT_MD.open("w") as f:
        f.write("\n".join(md_lines) + "\n")

    print(f"\nWrote JSON: {OUT_JSON}")
    print(f"Wrote MD:   {OUT_MD}")


if __name__ == "__main__":
    main()