Spaces:
Running
Running
File size: 8,542 Bytes
7103d1c | 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 | """Presence classifier, lean head (plan §0.2 #7, task 4.3): one logistic head per type on frozen section embeddings.
heads = LinearHeads(type_ids, C=1.0, class_weight="balanced")
heads.fit_type(t, X_t, y_t) # each type may have its own rows (e.g. human labels for some types,
# teacher labels for others)
heads.calibrate(X_fit, Y_fit) # temperature per type on held-out labels: P(present) is a probability
heads.tune_thresholds(X_fit, Y_fit) # per-type cut-off that maximises F1 on the same held-out labels
P = heads.predict_proba(X) # [n, T]
A type with fewer than `min_positives` training positives gets a constant prior instead of a head. Metrics for 4.3
and 4.6: prf_table (per-type P/R/F1 at thresholds) and ece (expected calibration error over all section-type pairs).
reference_split puts whole documents of the reference set into a fitting half (calibration, thresholds, the cascade's
combination layer) and an evaluation half (ablations, gate), so no number is measured on the data it was tuned on.
`setfit` and `finetune` (presence.head) are separate heads, built only if the linear head misses gate 4.8.
"""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
from typing import Any, Sequence
import numpy as np
def _sigmoid(z: np.ndarray) -> np.ndarray:
return 1.0 / (1.0 + np.exp(-np.clip(z, -40, 40)))
class LinearHeads:
def __init__(self, types: Sequence[str], *, C: float = 1.0, class_weight: str | None = "balanced",
min_positives: int = 3, max_iter: int = 2000):
self.types = list(types)
self.C, self.class_weight, self.min_positives, self.max_iter = C, class_weight, min_positives, max_iter
self.coef: np.ndarray | None = None
self.intercept = np.zeros(len(self.types))
self.temperature = np.ones(len(self.types))
self.threshold = np.full(len(self.types), 0.5)
self.info: dict[str, dict[str, Any]] = {t: {"mode": "prior", "positives": 0, "rows": 0} for t in self.types}
def fit_type(self, t: str, X: np.ndarray, y: np.ndarray) -> None:
from sklearn.linear_model import LogisticRegression
j = self.types.index(t)
X, y = np.asarray(X, dtype=np.float32), np.asarray(y).astype(int)
if self.coef is None:
self.coef = np.zeros((len(self.types), X.shape[1]))
pos = int(y.sum())
self.info[t] = {"positives": pos, "rows": int(len(y))}
if pos < self.min_positives or pos == len(y):
prior = (pos + 0.5) / (len(y) + 1.0)
self.coef[j] = 0.0
self.intercept[j] = np.log(prior / (1 - prior))
self.info[t]["mode"] = "prior"
return
m = LogisticRegression(C=self.C, class_weight=self.class_weight, max_iter=self.max_iter)
m.fit(X, y)
self.coef[j], self.intercept[j] = m.coef_[0], m.intercept_[0]
self.info[t]["mode"] = "head"
def logits(self, X: np.ndarray) -> np.ndarray:
if self.coef is None: # no type was ever fitted: priors of 0.5
return np.zeros((len(X), len(self.types)))
return np.asarray(X, dtype=np.float32) @ self.coef.T + self.intercept
def predict_proba(self, X: np.ndarray) -> np.ndarray:
return _sigmoid(self.logits(X) / self.temperature)
def calibrate(self, X: np.ndarray, Y: np.ndarray) -> None:
"""Temperature per type by minimising the log loss on held-out labels Y [n, T] (types without both classes
in Y, and prior-only types, keep T = 1)."""
from scipy.optimize import minimize_scalar
Z = self.logits(X)
for j, t in enumerate(self.types):
y = Y[:, j]
if self.info[t]["mode"] != "head" or y.min() == y.max():
continue
def nll(log_t: float, z: np.ndarray = Z[:, j], y: np.ndarray = y) -> float:
p = np.clip(_sigmoid(z / np.exp(log_t)), 1e-7, 1 - 1e-7)
return float(-(y * np.log(p) + (1 - y) * np.log(1 - p)).mean())
self.temperature[j] = float(np.exp(minimize_scalar(nll, bounds=(-3, 3), method="bounded").x))
def tune_thresholds(self, X: np.ndarray, Y: np.ndarray, P: np.ndarray | None = None) -> None:
"""Per-type cut-off maximising F1 on held-out labels (0.5 when a type has no held-out positive)."""
P = self.predict_proba(X) if P is None else P
self.threshold = tune_thresholds(Y, P)
def save(self, path: str | Path) -> None:
path = Path(path)
path.mkdir(parents=True, exist_ok=True)
np.savez(path / "heads.npz", coef=self.coef if self.coef is not None else np.zeros((len(self.types), 0)),
intercept=self.intercept, temperature=self.temperature, threshold=self.threshold)
(path / "heads.json").write_text(json.dumps({"types": self.types, "C": self.C, "class_weight": self.class_weight,
"min_positives": self.min_positives, "info": self.info}, indent=1),
encoding="utf-8")
@classmethod
def load(cls, path: str | Path) -> "LinearHeads":
path = Path(path)
meta = json.loads((path / "heads.json").read_text(encoding="utf-8"))
h = cls(meta["types"], C=meta["C"], class_weight=meta["class_weight"], min_positives=meta["min_positives"])
z = np.load(path / "heads.npz")
h.coef = z["coef"] if z["coef"].size else None
h.intercept, h.temperature, h.threshold, h.info = z["intercept"], z["temperature"], z["threshold"], meta["info"]
return h
def tune_thresholds(Y: np.ndarray, P: np.ndarray, grid: np.ndarray | None = None) -> np.ndarray:
grid = np.linspace(0.05, 0.95, 37) if grid is None else grid
out = np.full(Y.shape[1], 0.5)
for j in range(Y.shape[1]):
y = Y[:, j]
if y.sum() == 0:
continue
best, best_f = 0.5, -1.0
for th in grid:
pred = P[:, j] >= th
tp = int((pred & (y == 1)).sum())
f = 2 * tp / max(1, int(pred.sum()) + int(y.sum()))
if f > best_f:
best, best_f = th, f
out[j] = best
return out
def prf_table(Y: np.ndarray, P: np.ndarray, types: Sequence[str], *, thresholds: np.ndarray) -> list[dict[str, Any]]:
"""Per-type counts and P/R/F1 at the thresholds (None where undefined)."""
rows = []
for j, t in enumerate(types):
pred, y = P[:, j] >= thresholds[j], Y[:, j] == 1
tp, fp, fn = int((pred & y).sum()), int((pred & ~y).sum()), int((~pred & y).sum())
p = tp / (tp + fp) if tp + fp else None
r = tp / (tp + fn) if tp + fn else None
f = (2 * tp / (2 * tp + fp + fn)) if (tp + fn) else None
rows.append({"type_id": t, "positives": int(y.sum()), "predicted": int(pred.sum()), "tp": tp, "fp": fp, "fn": fn,
"precision": p, "recall": r, "f1": f, "threshold": round(float(thresholds[j]), 3)})
return rows
def summary(rows: list[dict[str, Any]], *, min_positives: int = 5) -> dict[str, Any]:
tp, fp, fn = (sum(r[k] for r in rows) for k in ("tp", "fp", "fn"))
f1s = [r["f1"] for r in rows if r["positives"] >= min_positives and r["f1"] is not None]
return {"micro_precision": round(tp / max(1, tp + fp), 3), "micro_recall": round(tp / max(1, tp + fn), 3),
"micro_f1": round(2 * tp / max(1, 2 * tp + fp + fn), 3),
"macro_f1": round(float(np.mean(f1s)), 3) if f1s else None, "types_scored": len(f1s)}
def ece(Y: np.ndarray, P: np.ndarray, bins: int = 10) -> float:
"""Expected calibration error over all section-type pairs (equal-width probability bins)."""
y, p = np.asarray(Y, dtype=float).ravel(), np.asarray(P, dtype=float).ravel()
edges = np.linspace(0, 1, bins + 1)
idx = np.clip(np.digitize(p, edges[1:-1]), 0, bins - 1)
err = 0.0
for b in range(bins):
m = idx == b
if m.any():
err += m.mean() * abs(p[m].mean() - y[m].mean())
return float(round(err, 6))
def reference_split(keys: Sequence[tuple[str, int]], *, seed: int) -> dict[tuple[str, int], str]:
"""Whole documents of the reference set into 'fit' and 'eval' halves (by document count), seeded and order-free."""
docs = sorted({d for d, _ in keys}, key=lambda d: hashlib.sha1(f"{seed}:{d}".encode()).hexdigest())
fit = set(docs[: len(docs) // 2])
return {k: ("fit" if k[0] in fit else "eval") for k in keys}
|