File size: 8,165 Bytes
61b6fb9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Accuracy and calibration for typed decisions, from per-item outputs (eval_items.py).

Definitions (pinned by reproducing the typed-decisions card's reference rows, see `reference_rows`):
  accuracy  argmax == the gold label (ties broken by option order)
  nll       -log p(gold label)                                    (hard target)
  kl        KL(gold || p) = sum_k g_k log(g_k / p_k), natural log    (soft target, teacher mean)
  ce_soft   -sum_k g_k log p_k                                        (soft target)
  brier     sum_k (p_k - g_k)^2 against the gold DISTRIBUTION, mean over questions
  ece       10 equal-width bins of top-1 confidence; |accuracy - confidence| weighted by bin size
Variants are kept so a mismatch with a published definition is visible, not hidden.
"""
from __future__ import annotations

import collections
import json
import math
import random

EPS = 1e-12


def load(path):
    return [json.loads(x) for x in open(path, encoding="utf-8") if x.strip()]


def soft_vec(it):
    s = it.get("soft")
    if not s:
        return None
    v = [float(s.get(k, 0.0)) for k in it["keys"]]
    t = sum(v)
    return [x / t for x in v] if t > 0 else None


def item_scores(it):
    p, keys = it["probs"], it["keys"]
    gold = it["gold"][0]
    gi = keys.index(gold) if gold in keys else None
    top = max(range(len(p)), key=p.__getitem__)
    g = soft_vec(it)
    out = {"ok": int(keys[top] in it["gold"]), "conf": p[top], "nll": -math.log(max(p[gi], EPS)) if gi is not None else None}
    if g:
        out["kl"] = sum(gk * math.log(gk / max(pk, EPS)) for gk, pk in zip(g, p) if gk > 0)
        out["ce_soft"] = -sum(gk * math.log(max(pk, EPS)) for gk, pk in zip(g, p) if gk > 0)
        out["brier"] = sum((pk - gk) ** 2 for gk, pk in zip(g, p))
        out["brier_mean_k"] = out["brier"] / len(p)
    oh = [1.0 if i == gi else 0.0 for i in range(len(p))]
    out["brier_onehot"] = sum((pk - ok) ** 2 for pk, ok in zip(p, oh))
    return out


def ece(scores, bins=10):
    b = collections.defaultdict(list)
    for s in scores:
        b[min(int(s["conf"] * bins), bins - 1)].append(s)
    n = len(scores)
    return sum(len(v) / n * abs(sum(x["ok"] for x in v) / len(v) - sum(x["conf"] for x in v) / len(v)) for v in b.values())


def summarize(items):
    sc = [item_scores(it) for it in items]
    n = len(sc)
    def mean(k):
        v = [s[k] for s in sc if s.get(k) is not None]
        return sum(v) / len(v) if v else None
    out = {"n": n, "correct": sum(s["ok"] for s in sc), "accuracy": sum(s["ok"] for s in sc) / n,
           "nll": mean("nll"), "kl": mean("kl"), "ce_soft": mean("ce_soft"), "brier": mean("brier"),
           "brier_mean_k": mean("brier_mean_k"), "brier_onehot": mean("brier_onehot"), "ece10": ece(sc),
           "mean_conf": sum(s["conf"] for s in sc) / n}
    by = collections.defaultdict(list)
    for it, s in zip(items, sc):
        by[it["type"]].append(s["ok"])
    out["by_type"] = {k: {"correct": sum(v), "n": len(v), "accuracy": sum(v) / len(v)} for k, v in sorted(by.items())}
    return out


def bootstrap_ci(items, reps=4000, seed=0):
    """95 % interval of accuracy, resampling CASES (the questions of a case are not independent)."""
    cases = collections.defaultdict(list)
    for it in items:
        cases[it["id"]].append(item_scores(it)["ok"])
    ids = list(cases)
    rng = random.Random(seed)
    acc = []
    for _ in range(reps):
        s = [cases[rng.choice(ids)] for _ in ids]
        acc.append(sum(map(sum, s)) / sum(map(len, s)))
    acc.sort()
    return acc[int(0.025 * reps)], acc[int(0.975 * reps) - 1]


def paired(a_items, b_items):
    """McNemar on the same questions (exact two-sided binomial on the discordant pairs)."""
    A = {(i["id"], i["qid"]): item_scores(i)["ok"] for i in a_items}
    B = {(i["id"], i["qid"]): item_scores(i)["ok"] for i in b_items}
    common = A.keys() & B.keys()
    b01 = sum(1 for k in common if not A[k] and B[k])
    b10 = sum(1 for k in common if A[k] and not B[k])
    n = b01 + b10
    k = min(b01, b10)
    p = min(1.0, 2 * sum(math.comb(n, i) for i in range(k + 1)) / 2 ** n) if n else 1.0
    return {"n_common": len(common), "only_b_right": b01, "only_a_right": b10, "p_mcnemar_exact": p}


# ------------------------------------------------------------------ class-balance diagnostics
def confusion(gold, pred, labels=None):
    """labels in a fixed order (default: sorted union); returns (labels, matrix[gold][pred])."""
    labels = list(labels or sorted(set(gold) | set(pred)))
    ix = {l: i for i, l in enumerate(labels)}
    m = [[0] * len(labels) for _ in labels]
    for g, p in zip(gold, pred):
        m[ix[g]][ix[p]] += 1
    return labels, m


def per_class(labels, m):
    out = {}
    for i, l in enumerate(labels):
        tp = m[i][i]
        fn = sum(m[i]) - tp
        fp = sum(row[i] for row in m) - tp
        rec = tp / (tp + fn) if tp + fn else None
        prec = tp / (tp + fp) if tp + fp else 0.0
        f1 = 2 * prec * rec / (prec + rec) if rec and (prec + rec) else 0.0
        out[l] = {"support": tp + fn, "predicted": tp + fp, "recall": rec, "precision": prec, "f1": f1}
    return out


def balanced_metrics(gold, pred, labels=None):
    """accuracy, balanced accuracy (= macro recall over classes present in gold), macro-F1 over classes
    present in gold, MCC (Gorodkin's multiclass form; equals the binary MCC for two classes)."""
    labels, m = confusion(gold, pred, labels)
    pc = per_class(labels, m)
    present = [l for l in labels if pc[l]["support"]]
    n = len(gold)
    acc = sum(m[i][i] for i in range(len(labels))) / n
    bal = sum(pc[l]["recall"] for l in present) / len(present)
    mf1 = sum(pc[l]["f1"] for l in present) / len(present)
    t = [sum(r) for r in m]                      # true counts
    p = [sum(r[j] for r in m) for j in range(len(labels))]   # predicted counts
    c = sum(m[i][i] for i in range(len(labels)))
    num = c * n - sum(tk * pk for tk, pk in zip(t, p))
    den = math.sqrt((n * n - sum(pk * pk for pk in p)) * (n * n - sum(tk * tk for tk in t)))
    mcc = num / den if den else 0.0
    return {"n": n, "accuracy": acc, "balanced_accuracy": bal, "macro_f1": mf1, "mcc": mcc,
            "labels": labels, "confusion": m, "gold_dist": dict(zip(labels, t)), "pred_dist": dict(zip(labels, p)),
            "per_class": pc}


def auroc(scores, positives):
    """Probability that a random positive scores above a random negative (ties count 1/2)."""
    pos = [s for s, y in zip(scores, positives) if y]
    neg = [s for s, y in zip(scores, positives) if not y]
    if not pos or not neg:
        return None
    order = sorted(scores)
    ranks = {}
    i = 0
    while i < len(order):  # average ranks over ties
        j = i
        while j < len(order) and order[j] == order[i]:
            j += 1
        ranks[order[i]] = (i + j + 1) / 2
        i = j
    rsum = sum(ranks[s] for s in pos)
    return (rsum - len(pos) * (len(pos) + 1) / 2) / (len(pos) * len(neg))


def average_precision(scores, positives):
    """PR-AUC as average precision (step function, no interpolation) for the positive class."""
    pairs = sorted(zip(scores, positives), key=lambda x: -x[0])
    tp, ap, npos = 0, 0.0, sum(1 for _, y in pairs if y)
    if not npos:
        return None
    for k, (_, y) in enumerate(pairs, 1):
        if y:
            tp += 1
            ap += tp / k
    return ap / npos


def score_mae(items):
    """OUR definition (no public code to match): mean |E_p[level] - E_gold[level]| over `score` questions,
    levels 0..K-1, gold expectation from the teacher distribution; and against the gold label."""
    d_soft, d_lab = [], []
    for it in items:
        if it["type"] != "score":
            continue
        p = it["probs"]
        e = sum(i * x for i, x in enumerate(p))
        g = soft_vec(it)
        if g:
            d_soft.append(abs(e - sum(i * x for i, x in enumerate(g))))
        d_lab.append(abs(e - int(it["gold"][0])))
    return {"mae_vs_gold_expectation": sum(d_soft) / len(d_soft) if d_soft else None,
            "mae_vs_gold_label": sum(d_lab) / len(d_lab) if d_lab else None, "n": len(d_lab)}