PEFT
Safetensors
File size: 9,793 Bytes
210f9af
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
"""Evaluation metrics (pure Python, standard library only). Spec: docs/classifier-plan.md §5.1.

Labels are option strings; a distribution is a dict option -> probability in taxonomy option order. Conventions:
- **Masked / null gold** is skipped by every per-field metric (``None`` in the gold list).
- **F1 of a class** = 2TP / (2TP + FP + FN); precision is None without predictions, recall None without support.
- **Macro-F1** = unweighted mean F1 over the field's options, leaving out options with no support and no
  prediction (F1 undefined) and the options in ``exclude`` (the taxonomy's ``macro_f1_exclude``; nsb-v2:
  emotion_fine ``mixed``, still in ``per_class``); a missed or only falsely predicted option counts as 0.
- **Cohen's kappa** = (p_o - p_e) / (1 - p_e) over paired non-null labels; 1.0 when p_e == 1 and p_o == 1.
- **Top-label ECE**: 15 equal-width bins on the top-label probability, bin = min(floor(p x 15), 14);
  sum over non-empty bins of (bin size / n) x |accuracy - mean confidence|. Class-wise ECE: the same on p(class).
- **Log-loss**: mean -ln(max(p(gold), 1e-12)).
- **Weighted shares** (§2.3): predicted share = sum(p(class) / inclusion_prob) / sum(1 / inclusion_prob) against
  the teacher share with the same weights; a missing inclusion_prob counts as 1.
- **Bootstrap**: author-cluster percentile bootstrap: sorted clusters, ``random.Random(seed)``, each replicate draws
  as many clusters as there are with replacement; ``fn(ids)`` returning None is skipped; interval
  [v[floor(a/2 (m-1))], v[floor((1-a/2)(m-1))]] over the m sorted kept values.
"""
from __future__ import annotations

import math
import random
from collections import Counter, defaultdict
from typing import Callable, Hashable, Iterable, Mapping, Sequence

VERSION = "nsb-metrics-v1"
FLOOR = 1e-12
BINS = 15
REPS = 1000
LEVEL = 0.95
SEED = "nsb-eval-20261017"


def top(dist: Mapping[str, float]) -> str:
    """Highest-probability option; ties go to the earlier option."""
    best = None
    for k, v in dist.items():
        if best is None or v > dist[best]:
            best = k
    return best


def _pairs(gold: Sequence, other: Sequence) -> list[tuple]:
    gold, other = list(gold), list(other)
    if len(gold) != len(other):
        raise ValueError(f"lengths differ: {len(gold)}, {len(other)}")
    return [(g, o) for g, o in zip(gold, other) if g is not None and o is not None]


def _ratio(a: float, b: float) -> float | None:
    return a / b if b else None


def _bin(p: float, bins: int) -> int:
    return min(max(int(math.floor(p * bins)), 0), bins - 1)


def _ece(points: list[tuple[float, float]], bins: int) -> float | None:
    if not points:
        return None
    total: dict[int, list[float]] = defaultdict(lambda: [0, 0.0, 0.0])
    for conf, hit in points:
        b = total[_bin(conf, bins)]
        b[0] += 1
        b[1] += conf
        b[2] += hit
    n = len(points)
    return sum(c / n * abs(acc / c - s / c) for c, s, acc in total.values())


# ---------------------------------------------------------------------- per field
def accuracy(gold, pred) -> float | None:
    pairs = _pairs(gold, pred)
    return _ratio(sum(1 for g, p in pairs if g == p), len(pairs))


def per_class(gold, pred, classes) -> dict:
    """{class: {support, predicted, tp, precision, recall, f1}} for every given class."""
    classes = list(classes)
    known = set(classes)
    hits, support, predicted = Counter(), Counter(), Counter()
    for g, p in _pairs(gold, pred):
        if g not in known or p not in known:
            raise ValueError(f"label outside the classes: {g!r} / {p!r}")
        support[g] += 1
        predicted[p] += 1
        if g == p:
            hits[g] += 1
    out = {}
    for c in classes:
        tp, s, n = hits[c], support[c], predicted[c]
        out[c] = {"support": s, "predicted": n, "tp": tp, "precision": _ratio(tp, n), "recall": _ratio(tp, s),
                  "f1": _ratio(2 * tp, n + s)}
    return out


def _macro(pc: Mapping[str, dict], exclude: Iterable[str] = ()) -> float | None:
    skip = set(exclude)
    scores = [v["f1"] for c, v in pc.items() if v["f1"] is not None and c not in skip]
    return sum(scores) / len(scores) if scores else None


def macro_f1(gold, pred, classes, exclude: Iterable[str] = ()) -> float | None:
    return _macro(per_class(gold, pred, classes), exclude)


def kappa(a, b) -> float | None:
    """Cohen's kappa of two label lists (pairs with a None on either side are skipped)."""
    pairs = _pairs(a, b)
    n = len(pairs)
    if not n:
        return None
    po = sum(1 for x, y in pairs if x == y) / n
    ca, cb = Counter(x for x, _ in pairs), Counter(y for _, y in pairs)
    pe = sum(ca[k] * cb[k] for k in ca) / (n * n)
    if pe >= 1.0:
        return 1.0 if po >= 1.0 else 0.0
    return (po - pe) / (1.0 - pe)


def log_loss(gold, dists) -> float | None:
    pairs = _pairs(gold, dists)
    if not pairs:
        return None
    return sum(-math.log(max(d[g], FLOOR)) for g, d in pairs) / len(pairs)


def ece(gold, dists, bins: int = BINS) -> float | None:
    """Top-label expected calibration error."""
    points = []
    for g, d in _pairs(gold, dists):
        best = top(d)
        points.append((d[best], 1.0 if best == g else 0.0))
    return _ece(points, bins)


def class_ece(gold, dists, cls: str, bins: int = BINS) -> float | None:
    return _ece([(d[cls], 1.0 if g == cls else 0.0) for g, d in _pairs(gold, dists)], bins)


def weighted_shares(gold, dists, cls: str, weights=None) -> dict:
    """Predicted share (mean p(cls)) and teacher share, both weighted (weights default 1)."""
    gold, dists = list(gold), list(dists)
    weights = [1.0] * len(gold) if weights is None else [1.0 if w is None else float(w) for w in weights]
    num_p = num_g = den = 0.0
    n = 0
    for g, d, w in zip(gold, dists, weights):
        if g is None:
            continue
        num_p += w * d[cls]
        num_g += w * (1.0 if g == cls else 0.0)
        den += w
        n += 1
    pred, obs = _ratio(num_p, den), _ratio(num_g, den)
    return {"n": n, "predicted_share": pred, "teacher_share": obs,
            "difference": None if pred is None else pred - obs}


def inverse_weights(inclusion_probs: Iterable[float | None]) -> list[float]:
    """1 / inclusion_prob (1.0 when missing or not positive)."""
    return [1.0 / p if p else 1.0 for p in inclusion_probs]


def key_class(gold, dists, cls: str, weights=None) -> dict:
    """Precision, recall, F1 (top label), class-wise ECE and weighted predicted vs teacher share of one class."""
    gold, dists = list(gold), list(dists)
    pred = [top(d) for d in dists]
    known = {x for x in gold + pred if x is not None} | {cls}
    out = dict(per_class(gold, pred, sorted(known))[cls], class_ece=class_ece(gold, dists, cls))
    out.update(weighted_shares(gold, dists, cls, weights))
    return out


def field_summary(gold, dists, classes, macro_exclude: Iterable[str] = ()) -> dict:
    """Accuracy, macro-F1 (without ``macro_exclude``), kappa vs teacher, log-loss, top-label ECE and per-class
    figures (every class) of one field."""
    gold, dists = list(gold), list(dists)
    pred = [top(d) for d in dists]
    pc = per_class(gold, pred, classes)
    out = {"n": sum(1 for g in gold if g is not None), "masked": sum(1 for g in gold if g is None),
           "accuracy": accuracy(gold, pred), "macro_f1": _macro(pc, macro_exclude),
           "kappa": kappa(gold, pred), "log_loss": log_loss(gold, dists), "ece": ece(gold, dists),
           "per_class": pc}
    if macro_exclude:
        out["macro_f1_excludes"] = sorted(macro_exclude)
        out["macro_f1_all"] = _macro(pc)
    return out


# ---------------------------------------------------------------------- series
def mae(xs: Sequence[float], ys: Sequence[float]) -> float | None:
    if len(xs) != len(ys):
        raise ValueError("lengths differ")
    return sum(abs(x - y) for x, y in zip(xs, ys)) / len(xs) if xs else None


def pearson(xs: Sequence[float], ys: Sequence[float]) -> float | None:
    """Pearson r; None with fewer than 2 points or a constant series."""
    n = len(xs)
    if n != len(ys):
        raise ValueError("lengths differ")
    if n < 2:
        return None
    mx, my = sum(xs) / n, sum(ys) / n
    sxy = sum((x - mx) * (y - my) for x, y in zip(xs, ys))
    sxx = sum((x - mx) ** 2 for x in xs)
    syy = sum((y - my) ** 2 for y in ys)
    if sxx <= 0 or syy <= 0:
        return None
    return sxy / math.sqrt(sxx * syy)


# ---------------------------------------------------------------------- bootstrap
def percentile_interval(values: Sequence[float], level: float = LEVEL) -> list[float] | None:
    if not values:
        return None
    values = sorted(values)
    low = round((1.0 - level) / 2, 12)
    high = round(1.0 - low, 12)
    m = len(values) - 1
    return [values[int(low * m)], values[int(high * m)]]


def bootstrap(ids: Iterable[Hashable], group_of: Mapping | Callable, fn: Callable[[list], float | None],
              reps: int = REPS, seed=SEED, level: float = LEVEL) -> dict:
    """Author-cluster percentile bootstrap of fn(ids). ``group_of``: dict or callable id -> cluster."""
    get = group_of.__getitem__ if isinstance(group_of, Mapping) else group_of
    groups: dict = defaultdict(list)
    for i in ids:
        groups[get(i)].append(i)
    keys, rng, values = sorted(groups), random.Random(str(seed)), []
    for _ in range(reps):
        draw = [i for k in (rng.choice(keys) for _ in keys) for i in groups[k]] if keys else []
        v = fn(draw)
        if v is not None:
            values.append(v)
    return {"interval": percentile_interval(values, level), "level": level, "reps": reps, "reps_used": len(values),
            "groups": len(keys), "seed": str(seed)}