Evaluation audits (identifiability, memorisation, Holm), MPS memory fixes (shape bucketing, cache release), Medium-stage acquisition scripts
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
7 changed files +487 −5
added
data/manifests/discover_arxiv_q-fin-econ_2008-2017_metadata.json
+242 −0
@@ -0,0 +1,242 @@ | ||
| 1 | +{ | |
| 2 | + "created_at": "2026-11-09T17:57:10Z", | |
| 3 | + "git": { | |
| 4 | + "commit": "c27a8dad9b8875ec7e0b6eb6a79001b306b96ca4", | |
| 5 | + "branch": "main", | |
| 6 | + "dirty": true | |
| 7 | + }, | |
| 8 | + "libraries": { | |
| 9 | + "python": "3.14.4", | |
| 10 | + "torch": "2.12.0", | |
| 11 | + "numpy": "2.5.4", | |
| 12 | + "anthropic": "2.3.0", | |
| 13 | + "transformers": "5.8.1", | |
| 14 | + "sentencepiece": "0.2.2", | |
| 15 | + "lxml": "6.1.3", | |
| 16 | + "pyarrow": "24.0.0", | |
| 17 | + "sacrebleu": "2.6.0", | |
| 18 | + "datasketch": "2.0.0", | |
| 19 | + "rapidfuzz": "3.14.6" | |
| 20 | + }, | |
| 21 | + "hardware": { | |
| 22 | + "platform": "macOS-27.0-arm64-arm-64bit-Mach-O", | |
| 23 | + "machine": "arm64", | |
| 24 | + "cpu_count": 18, | |
| 25 | + "chip": "Apple M5 Max", | |
| 26 | + "ram_gb": 48.0, | |
| 27 | + "torch_mps": true, | |
| 28 | + "torch_cuda": false | |
| 29 | + }, | |
| 30 | + "script": "discover_arxiv", | |
| 31 | + "args": { | |
| 32 | + "sets": [ | |
| 33 | + "q-fin", | |
| 34 | + "econ" | |
| 35 | + ], | |
| 36 | + "date_from": "2008-01-01", | |
| 37 | + "date_until": "2017-12-31", | |
| 38 | + "datestamp_until": null, | |
| 39 | + "max_docs": 50, | |
| 40 | + "seed": 1, | |
| 41 | + "out": "data/raw/arxiv", | |
| 42 | + "codes": "CC0,CC-BY,CC-BY-SA,CC-BY-NC,CC-BY-NC-SA", | |
| 43 | + "metadata_only": true, | |
| 44 | + "metadata_out": null, | |
| 45 | + "report": null, | |
| 46 | + "max_minutes": 25.0 | |
| 47 | + }, | |
| 48 | + "stats": { | |
| 49 | + "records_seen": 25499, | |
| 50 | + "duplicates": 0, | |
| 51 | + "license_excluded": 18486, | |
| 52 | + "no_versions": 0, | |
| 53 | + "before_window": 2, | |
| 54 | + "not_pre2018": 6849, | |
| 55 | + "eligible": 162, | |
| 56 | + "pdf_only": 0, | |
| 57 | + "fetched": 0, | |
| 58 | + "errors": 0, | |
| 59 | + "by_license": { | |
| 60 | + "UNKNOWN": 1416, | |
| 61 | + "ARXIV-NONEXCLUSIVE": 15567, | |
| 62 | + "CC-BY-NC-SA": 804, | |
| 63 | + "CC-BY": 5676, | |
| 64 | + "CC0": 220, | |
| 65 | + "CC-BY-SA": 313, | |
| 66 | + "CC-BY-NC-ND": 1503 | |
| 67 | + }, | |
| 68 | + "by_set": { | |
| 69 | + "q-fin": 162 | |
| 70 | + }, | |
| 71 | + "by_v1_year_eligible": { | |
| 72 | + "2008": 2, | |
| 73 | + "2009": 7, | |
| 74 | + "2010": 17, | |
| 75 | + "2011": 5, | |
| 76 | + "2012": 8, | |
| 77 | + "2013": 21, | |
| 78 | + "2014": 27, | |
| 79 | + "2015": 17, | |
| 80 | + "2016": 30, | |
| 81 | + "2017": 28 | |
| 82 | + }, | |
| 83 | + "by_license_eligible": { | |
| 84 | + "CC-BY-NC-SA": 55, | |
| 85 | + "CC-BY": 96, | |
| 86 | + "CC0": 9, | |
| 87 | + "CC-BY-SA": 2 | |
| 88 | + }, | |
| 89 | + "windows_harvested": [ | |
| 90 | + { | |
| 91 | + "set": "q-fin", | |
| 92 | + "from": "2008-01-01", | |
| 93 | + "until": "2008-12-31", | |
| 94 | + "pages_total": 1, | |
| 95 | + "records_seen_total": 946 | |
| 96 | + }, | |
| 97 | + { | |
| 98 | + "set": "q-fin", | |
| 99 | + "from": "2009-01-01", | |
| 100 | + "until": "2009-12-31", | |
| 101 | + "pages_total": 2, | |
| 102 | + "records_seen_total": 1725 | |
| 103 | + }, | |
| 104 | + { | |
| 105 | + "set": "q-fin", | |
| 106 | + "from": "2010-01-01", | |
| 107 | + "until": "2010-12-31", | |
| 108 | + "pages_total": 3, | |
| 109 | + "records_seen_total": 2129 | |
| 110 | + }, | |
| 111 | + { | |
| 112 | + "set": "q-fin", | |
| 113 | + "from": "2011-01-01", | |
| 114 | + "until": "2011-12-31", | |
| 115 | + "pages_total": 4, | |
| 116 | + "records_seen_total": 2543 | |
| 117 | + }, | |
| 118 | + { | |
| 119 | + "set": "q-fin", | |
| 120 | + "from": "2012-01-01", | |
| 121 | + "until": "2012-12-31", | |
| 122 | + "pages_total": 5, | |
| 123 | + "records_seen_total": 3011 | |
| 124 | + }, | |
| 125 | + { | |
| 126 | + "set": "q-fin", | |
| 127 | + "from": "2013-01-01", | |
| 128 | + "until": "2013-12-31", | |
| 129 | + "pages_total": 6, | |
| 130 | + "records_seen_total": 3629 | |
| 131 | + }, | |
| 132 | + { | |
| 133 | + "set": "q-fin", | |
| 134 | + "from": "2014-01-01", | |
| 135 | + "until": "2014-12-31", | |
| 136 | + "pages_total": 7, | |
| 137 | + "records_seen_total": 4302 | |
| 138 | + }, | |
| 139 | + { | |
| 140 | + "set": "q-fin", | |
| 141 | + "from": "2015-01-01", | |
| 142 | + "until": "2015-12-31", | |
| 143 | + "pages_total": 8, | |
| 144 | + "records_seen_total": 5265 | |
| 145 | + }, | |
| 146 | + { | |
| 147 | + "set": "q-fin", | |
| 148 | + "from": "2016-01-01", | |
| 149 | + "until": "2016-12-31", | |
| 150 | + "pages_total": 9, | |
| 151 | + "records_seen_total": 6062 | |
| 152 | + }, | |
| 153 | + { | |
| 154 | + "set": "q-fin", | |
| 155 | + "from": "2017-01-01", | |
| 156 | + "until": "2017-12-31", | |
| 157 | + "pages_total": 10, | |
| 158 | + "records_seen_total": 6723 | |
| 159 | + }, | |
| 160 | + { | |
| 161 | + "set": "q-fin", | |
| 162 | + "from": "2018-01-01", | |
| 163 | + "until": "2018-12-31", | |
| 164 | + "pages_total": 11, | |
| 165 | + "records_seen_total": 7644 | |
| 166 | + }, | |
| 167 | + { | |
| 168 | + "set": "q-fin", | |
| 169 | + "from": "2019-01-01", | |
| 170 | + "until": "2019-12-31", | |
| 171 | + "pages_total": 12, | |
| 172 | + "records_seen_total": 8805 | |
| 173 | + }, | |
| 174 | + { | |
| 175 | + "set": "q-fin", | |
| 176 | + "from": "2020-01-01", | |
| 177 | + "until": "2020-12-31", | |
| 178 | + "pages_total": 14, | |
| 179 | + "records_seen_total": 10476 | |
| 180 | + }, | |
| 181 | + { | |
| 182 | + "set": "q-fin", | |
| 183 | + "from": "2021-01-01", | |
| 184 | + "until": "2021-12-31", | |
| 185 | + "pages_total": 16, | |
| 186 | + "records_seen_total": 12461 | |
| 187 | + }, | |
| 188 | + { | |
| 189 | + "set": "q-fin", | |
| 190 | + "from": "2022-01-01", | |
| 191 | + "until": "2022-12-31", | |
| 192 | + "pages_total": 18, | |
| 193 | + "records_seen_total": 14229 | |
| 194 | + }, | |
| 195 | + { | |
| 196 | + "set": "q-fin", | |
| 197 | + "from": "2023-01-01", | |
| 198 | + "until": "2023-12-31", | |
| 199 | + "pages_total": 20, | |
| 200 | + "records_seen_total": 16201 | |
| 201 | + }, | |
| 202 | + { | |
| 203 | + "set": "q-fin", | |
| 204 | + "from": "2024-01-01", | |
| 205 | + "until": "2024-12-31", | |
| 206 | + "pages_total": 22, | |
| 207 | + "records_seen_total": 18596 | |
| 208 | + }, | |
| 209 | + { | |
| 210 | + "set": "q-fin", | |
| 211 | + "from": "2025-01-01", | |
| 212 | + "until": "2025-12-31", | |
| 213 | + "pages_total": 25, | |
| 214 | + "records_seen_total": 21599 | |
| 215 | + } | |
| 216 | + ], | |
| 217 | + "truncated_at": { | |
| 218 | + "set": "q-fin", | |
| 219 | + "window": [ | |
| 220 | + "2026-01-01", | |
| 221 | + "2026-11-09" | |
| 222 | + ], | |
| 223 | + "last_datestamp": "2026-09-15", | |
| 224 | + "sets_not_started": [ | |
| 225 | + "econ" | |
| 226 | + ] | |
| 227 | + }, | |
| 228 | + "metadata_out": "/Users/simon-pierreboucher/Desktop/UQO/Recherche/Recherche-UQO/ia/wp-ai-0002/data/manifests/arxiv_eligible_q-fin-econ_2008-2017.jsonl", | |
| 229 | + "datestamp_window": [ | |
| 230 | + "2008-01-01", | |
| 231 | + "2026-11-09" | |
| 232 | + ], | |
| 233 | + "oai_endpoint": "https://oaipmh.arxiv.org/oai", | |
| 234 | + "oai_pages": 29, | |
| 235 | + "http": { | |
| 236 | + "requests": 30, | |
| 237 | + "retries": 0, | |
| 238 | + "bytes": 64118631 | |
| 239 | + }, | |
| 240 | + "elapsed_s": 1506.1 | |
| 241 | + } | |
| 242 | +} | |
added
src/htr/evaluation/identifiability.py
+66 −0
@@ -0,0 +1,66 @@ | ||
| 1 | +"""Non-identifiability and ambiguity tests. | |
| 2 | + | |
| 3 | +Reconstruction from a paraphrase is a one-to-many inverse problem. We quantify this directly instead of treating | |
| 4 | +low lexical overlap as model failure: | |
| 5 | + | |
| 6 | +* **Within-original agreement** — variant E produces two independent rewrites of the same original; a model's two | |
| 7 | + reconstructions should agree with each other at least as much as either agrees with the original. We report | |
| 8 | + chrF(recon_1, recon_2), chrF(recon_i, original) and the identity-baseline counterparts chrF(rewrite_1, rewrite_2). | |
| 9 | +* **Rewrite-level ambiguity** — for pairs of *distinct* originals whose rewrites are lexically close (nearest | |
| 10 | + neighbours in the rewrite space by MinHash/5-gram Jaccard), we measure how far apart their originals are. The | |
| 11 | + distribution of d(orig_a, orig_b) given small d(rw_a, rw_b) is an empirical lower bound on the irreducible error of | |
| 12 | + any deterministic inverse map, computed from data only (no model involved). | |
| 13 | +* **Oracle ceiling** — the chrF between two human originals that an LLM mapped to near-identical rewrites bounds the | |
| 14 | + expected chrF of a perfect-but-deterministic reconstructor on those items. | |
| 15 | +""" | |
| 16 | +from __future__ import annotations | |
| 17 | + | |
| 18 | +from collections import defaultdict | |
| 19 | + | |
| 20 | +import numpy as np | |
| 21 | + | |
| 22 | +from htr.dedup.minhash import minhash | |
| 23 | +from htr.evaluation.bootstrap import bootstrap_ci | |
| 24 | +from htr.evaluation.metrics import lexical | |
| 25 | + | |
| 26 | + | |
| 27 | +def within_original_agreement(pairs: list[dict], outputs: dict[str, str] | None = None) -> dict: | |
| 28 | + """Uses variant-E pairs (sample_index 0 and 1 of the same passage).""" | |
| 29 | + by_passage: dict[str, dict[int, dict]] = defaultdict(dict) | |
| 30 | + for p in pairs: | |
| 31 | + if p["variant"] == "E": | |
| 32 | + by_passage[p["passage_id"]][p["sample_index"]] = p | |
| 33 | + rows = [] | |
| 34 | + for pid, d in by_passage.items(): | |
| 35 | + if 0 not in d or 1 not in d: | |
| 36 | + continue | |
| 37 | + a, b = d[0], d[1] | |
| 38 | + rw_ab = lexical(a["source_text"], b["source_text"])["chrf"] | |
| 39 | + rw_a_o = lexical(a["source_text"], a["target_text"])["chrf"] | |
| 40 | + row = {"passage_id": pid, "doc_id": a["doc_id"], "chrf_rewrite1_rewrite2": rw_ab, "chrf_rewrite_original": rw_a_o} | |
| 41 | + if outputs and a["pair_id"] in outputs and b["pair_id"] in outputs: | |
| 42 | + r1, r2 = outputs[a["pair_id"]], outputs[b["pair_id"]] | |
| 43 | + row["chrf_recon1_recon2"] = lexical(r1, r2)["chrf"] | |
| 44 | + row["chrf_recon_original"] = 0.5 * (lexical(r1, a["target_text"])["chrf"] + lexical(r2, a["target_text"])["chrf"]) | |
| 45 | + rows.append(row) | |
| 46 | + keys = [k for k in rows[0] if k.startswith("chrf")] if rows else [] | |
| 47 | + return {"n_passages": len(rows), **{k: bootstrap_ci([r[k] for r in rows if k in r], [r["doc_id"] for r in rows if k in r], n=1000) for k in keys}} | |
| 48 | + | |
| 49 | + | |
| 50 | +def rewrite_ambiguity(pairs: list[dict], jaccard_threshold: float = 0.5, num_perm: int = 128, max_items: int = 5000) -> dict: | |
| 51 | + """Nearest-neighbour rewrites from distinct originals: how different are their originals?""" | |
| 52 | + items = [p for p in pairs if p["variant"] in ("A", "B", "E")][:max_items] | |
| 53 | + mhs = [minhash(p["source_text"], num_perm, 5) for p in items] | |
| 54 | + close = [] | |
| 55 | + for i in range(len(items)): | |
| 56 | + for j in range(i + 1, len(items)): | |
| 57 | + if items[i]["passage_id"] == items[j]["passage_id"]: | |
| 58 | + continue | |
| 59 | + jac = mhs[i].jaccard(mhs[j]) | |
| 60 | + if jac >= jaccard_threshold: | |
| 61 | + lo = lexical(items[i]["target_text"], items[j]["target_text"]) | |
| 62 | + close.append({"jaccard_rewrites": float(jac), "chrf_originals": lo["chrf"], "edit_sim_originals": lo["edit_sim"], "same_doc": items[i]["doc_id"] == items[j]["doc_id"]}) | |
| 63 | + if not close: | |
| 64 | + return {"n_close_pairs": 0, "jaccard_threshold": jaccard_threshold, "n_items": len(items)} | |
| 65 | + chrf = np.array([c["chrf_originals"] for c in close]) | |
| 66 | + return {"n_close_pairs": len(close), "jaccard_threshold": jaccard_threshold, "n_items": len(items), "chrf_originals_mean": float(chrf.mean()), "chrf_originals_p10": float(np.quantile(chrf, 0.1)), "chrf_originals_median": float(np.median(chrf)), "frac_same_doc": float(np.mean([c["same_doc"] for c in close])), "examples": close[:5]} | |
added
src/htr/evaluation/memorization.py
+103 −0
@@ -0,0 +1,103 @@ | ||
| 1 | +"""Memorisation and contamination audit. | |
| 2 | + | |
| 3 | +* **Nearest-neighbour overlap** — for each reconstruction on held-out items, the maximum 5-gram Jaccard (MinHash) and | |
| 4 | + the longest common word n-gram against the TRAINING targets. A reconstruction that reproduces a training original | |
| 5 | + (not its own test original) signals memorisation / regurgitation. | |
| 6 | +* **Verbatim-copy rate** — fraction of outputs with normalised edit similarity ≥ 0.95 to any training target. | |
| 7 | +* **Canaries (optional, lawful: synthetic text only)** — synthetic passages with unique markers inserted into the | |
| 8 | + training set a fixed number of times; at test time we check whether the model emits the marker given an unrelated | |
| 9 | + rewrite. Implemented as helpers; the canary set is generated locally and never contains real data. | |
| 10 | +""" | |
| 11 | +from __future__ import annotations | |
| 12 | + | |
| 13 | +import random | |
| 14 | +import re | |
| 15 | + | |
| 16 | +from datasketch import MinHash, MinHashLSH | |
| 17 | + | |
| 18 | +from htr.dedup.minhash import minhash | |
| 19 | +from htr.utils.hashing import normalize_for_hash | |
| 20 | + | |
| 21 | +_TOK = re.compile(r"\w+") | |
| 22 | + | |
| 23 | + | |
| 24 | +def _ngrams(text: str, n: int) -> set[tuple[str, ...]]: | |
| 25 | + t = _TOK.findall(normalize_for_hash(text)) | |
| 26 | + return {tuple(t[i : i + n]) for i in range(max(0, len(t) - n + 1))} | |
| 27 | + | |
| 28 | + | |
| 29 | +class TrainingIndex: | |
| 30 | + def __init__(self, train_targets: dict[str, str], threshold: float = 0.3, num_perm: int = 128): | |
| 31 | + self.lsh = MinHashLSH(threshold=threshold, num_perm=num_perm) | |
| 32 | + self.num_perm = num_perm | |
| 33 | + self.targets = train_targets | |
| 34 | + self.mh: dict[str, MinHash] = {} | |
| 35 | + for pid, t in train_targets.items(): | |
| 36 | + m = minhash(t, num_perm, 5) | |
| 37 | + self.mh[pid] = m | |
| 38 | + self.lsh.insert(pid, m) | |
| 39 | + | |
| 40 | + def nearest(self, text: str) -> tuple[str | None, float]: | |
| 41 | + m = minhash(text, self.num_perm, 5) | |
| 42 | + best, best_j = None, 0.0 | |
| 43 | + for cand in self.lsh.query(m): | |
| 44 | + j = float(m.jaccard(self.mh[cand])) | |
| 45 | + if j > best_j: | |
| 46 | + best, best_j = cand, j | |
| 47 | + return best, best_j | |
| 48 | + | |
| 49 | + | |
| 50 | +def longest_common_ngram(a: str, b: str, max_n: int = 40) -> int: | |
| 51 | + ta, tb = _TOK.findall(normalize_for_hash(a)), _TOK.findall(normalize_for_hash(b)) | |
| 52 | + sb = set() | |
| 53 | + best = 0 | |
| 54 | + for n in range(3, max_n + 1): | |
| 55 | + grams_a = {tuple(ta[i : i + n]) for i in range(max(0, len(ta) - n + 1))} | |
| 56 | + grams_b = {tuple(tb[i : i + n]) for i in range(max(0, len(tb) - n + 1))} | |
| 57 | + if grams_a & grams_b: | |
| 58 | + best = n | |
| 59 | + else: | |
| 60 | + break | |
| 61 | + return best | |
| 62 | + | |
| 63 | + | |
| 64 | +def audit_outputs(outputs: dict[str, str], eval_pairs: list[dict], train_targets: dict[str, str]) -> dict: | |
| 65 | + idx = TrainingIndex(train_targets) | |
| 66 | + rows = [] | |
| 67 | + for p in eval_pairs: | |
| 68 | + out = outputs.get(p["pair_id"]) | |
| 69 | + if out is None: | |
| 70 | + continue | |
| 71 | + nn, jac = idx.nearest(out) | |
| 72 | + lcn = longest_common_ngram(out, train_targets[nn]) if nn else 0 | |
| 73 | + rows.append({"pair_id": p["pair_id"], "nn_train_passage": nn, "jaccard_to_train": jac, "longest_common_ngram": lcn, "same_passage_leak": nn == p["passage_id"]}) | |
| 74 | + if not rows: | |
| 75 | + return {"n": 0} | |
| 76 | + jac = [r["jaccard_to_train"] for r in rows] | |
| 77 | + return { | |
| 78 | + "n": len(rows), | |
| 79 | + "mean_max_jaccard_to_train": sum(jac) / len(jac), | |
| 80 | + "frac_jaccard_ge_0_5": sum(j >= 0.5 for j in jac) / len(jac), | |
| 81 | + "frac_jaccard_ge_0_8": sum(j >= 0.8 for j in jac) / len(jac), | |
| 82 | + "max_longest_common_ngram": max(r["longest_common_ngram"] for r in rows), | |
| 83 | + "frac_lcn_ge_12": sum(r["longest_common_ngram"] >= 12 for r in rows) / len(rows), | |
| 84 | + "n_same_passage_leak": sum(r["same_passage_leak"] for r in rows), # must be 0 with correct splits | |
| 85 | + "worst": sorted(rows, key=lambda r: -r["jaccard_to_train"])[:5], | |
| 86 | + } | |
| 87 | + | |
| 88 | + | |
| 89 | +def make_canaries(n: int = 5, seed: int = 0) -> list[dict]: | |
| 90 | + """Synthetic canary pairs: an invented study with a unique marker token in the target only.""" | |
| 91 | + rng = random.Random(seed) | |
| 92 | + out = [] | |
| 93 | + for i in range(n): | |
| 94 | + marker = f"ZQX{rng.randrange(10**6):06d}" | |
| 95 | + tgt = f"The specimen series labelled {marker} was examined in triplicate, and the recorded mass of 12.5 g did not vary between trials. No statistically significant difference emerged between the two preparations (p = 0.41)." | |
| 96 | + src = "The specimen series was examined in triplicate; its recorded mass of 12.5 g remained constant across trials, and the two preparations did not differ significantly (p = 0.41)." | |
| 97 | + out.append({"pair_id": f"canary_{i}", "passage_id": f"canary_{i}", "doc_id": f"canary_{i}", "variant": "A", "sample_index": 0, "source_text": src, "target_text": tgt, "marker": marker, "metadata": {"discipline": "other", "era": "2010-2017", "section_role": "methods", "source": "local"}}) | |
| 98 | + return out | |
| 99 | + | |
| 100 | + | |
| 101 | +def canary_leak_rate(outputs: dict[str, str], canaries: list[dict]) -> float: | |
| 102 | + hits = sum(1 for c in canaries if c["marker"] in outputs.get(c["pair_id"], "")) | |
| 103 | + return hits / max(1, len(canaries)) | |
modified
src/htr/evaluation/runner.py
+25 −4
@@ -34,11 +34,27 @@ def _agg(rows: list[dict], key: str | None, metrics: list[str]) -> dict: | ||
| 34 | 34 | groups[str(r[key]) if key else "all"].append(r) |
| 35 | 35 | out = {} |
| 36 | 36 | for g, rs in sorted(groups.items()): |
| 37 | − out[g] = {"n": len(rs), **{m: bootstrap_ci([r[m] for r in rs], [r["doc_id"] for r in rs], n=1000) for m in metrics}} | |
| 37 | + out[g] = {"n": len(rs), **{m: bootstrap_ci([r[m] for r in rs], [r["doc_id"] for r in rs], n=_N_BOOT) for m in metrics}} | |
| 38 | 38 | return out |
| 39 | 39 | |
| 40 | 40 | |
| 41 | −def evaluate_systems(pairs: list[dict], systems: dict[str, dict[str, str]], out_dir: Path, metrics: list[str] = PRIMARY, baseline: str = "B0_identity") -> dict: | |
| 41 | +_N_BOOT = 2000 | |
| 42 | + | |
| 43 | + | |
| 44 | +def holm_correction(pvals: dict[str, float]) -> dict[str, float]: | |
| 45 | + """Holm–Bonferroni step-down adjusted p-values (monotone, capped at 1).""" | |
| 46 | + items = sorted(pvals.items(), key=lambda kv: kv[1]) | |
| 47 | + m = len(items) | |
| 48 | + adj: dict[str, float] = {} | |
| 49 | + running = 0.0 | |
| 50 | + for i, (k, p) in enumerate(items): | |
| 51 | + val = min(1.0, (m - i) * p) | |
| 52 | + running = max(running, val) | |
| 53 | + adj[k] = running | |
| 54 | + return adj | |
| 55 | + | |
| 56 | + | |
| 57 | +def evaluate_systems(pairs: list[dict], systems: dict[str, dict[str, str]], out_dir: Path, metrics: list[str] = PRIMARY, baseline: str = "B0_identity", n_boot: int = 2000) -> dict: | |
| 42 | 58 | out_dir.mkdir(parents=True, exist_ok=True) |
| 43 | 59 | report: dict = {**run_header({"n_pairs": len(pairs), "systems": list(systems)}), "systems": {}} |
| 44 | 60 | rows_by: dict[str, list[dict]] = {} |
@@ -47,7 +63,7 @@ def evaluate_systems(pairs: list[dict], systems: dict[str, dict[str, str]], out_ | ||
| 47 | 63 | rows_by[name] = rows |
| 48 | 64 | write_jsonl(out_dir / f"items_{name}.jsonl", rows) |
| 49 | 65 | report["systems"][name] = {"overall": _agg(rows, None, metrics)["all"], "by_variant": _agg(rows, "variant", metrics), "by_discipline": _agg(rows, "discipline", metrics), "by_era": _agg(rows, "era", metrics), "by_role": _agg(rows, "role", metrics)} |
| 50 | − # paired differences vs baseline | |
| 66 | + # paired differences vs baseline, with Holm–Bonferroni correction over the primary metrics of each system | |
| 51 | 67 | if baseline in rows_by: |
| 52 | 68 | base = {r["pair_id"]: r for r in rows_by[baseline]} |
| 53 | 69 | report["paired_vs_baseline"] = {} |
@@ -55,7 +71,12 @@ def evaluate_systems(pairs: list[dict], systems: dict[str, dict[str, str]], out_ | ||
| 55 | 71 | if name == baseline: |
| 56 | 72 | continue |
| 57 | 73 | common = [r for r in rows if r["pair_id"] in base] |
| 58 | − report["paired_vs_baseline"][name] = {m: paired_bootstrap_diff([r[m] for r in common], [base[r["pair_id"]][m] for r in common], [r["doc_id"] for r in common], n=1000) for m in metrics} | |
| 74 | + diffs = {m: paired_bootstrap_diff([r[m] for r in common], [base[r["pair_id"]][m] for r in common], [r["doc_id"] for r in common], n=n_boot) for m in metrics} | |
| 75 | + holm = holm_correction({m: d["p_value_two_sided"] for m, d in diffs.items()}) | |
| 76 | + for m in diffs: | |
| 77 | + diffs[m]["p_holm"] = holm[m] | |
| 78 | + diffs[m]["significant_holm_0_05"] = holm[m] < 0.05 | |
| 79 | + report["paired_vs_baseline"][name] = diffs | |
| 59 | 80 | # stylometry: originals vs rewrites vs each system |
| 60 | 81 | originals = [p["target_text"] for p in pairs] |
| 61 | 82 | rewrites = [p["source_text"] for p in pairs] |
modified
src/htr/training/data.py
+4 −0
@@ -80,8 +80,12 @@ class PairDataset(torch.utils.data.Dataset): | ||
| 80 | 80 | } |
| 81 | 81 | |
| 82 | 82 | |
| 83 | +PAD_MULTIPLE = 32 # pad lengths to a multiple → far fewer distinct tensor shapes (MPS/CUDA kernel caches stay small) | |
| 84 | + | |
| 85 | + | |
| 83 | 86 | def _pad(seqs: list[list], pad_val, dtype=torch.long) -> torch.Tensor: |
| 84 | 87 | L = max(len(s) for s in seqs) |
| 88 | + L = ((L + PAD_MULTIPLE - 1) // PAD_MULTIPLE) * PAD_MULTIPLE | |
| 85 | 89 | return torch.tensor([list(s) + [pad_val] * (L - len(s)) for s in seqs], dtype=dtype) |
| 86 | 90 | |
| 87 | 91 | |
modified
src/htr/training/trainer.py
+8 −1
@@ -178,8 +178,15 @@ class Trainer: | ||
| 178 | 178 | torch.cuda.synchronize() |
| 179 | 179 | self.accel_seconds += time.perf_counter() - t0 |
| 180 | 180 | self.step += 1 |
| 181 | + if self.dev.type == "mps" and self.step % 20 == 0: | |
| 182 | + torch.mps.empty_cache() # MPS keeps per-shape allocations; release them regularly | |
| 181 | 183 | if self.step % self.cfg.log_every == 0: |
| 182 | − rec = {"step": self.step, "epoch": epoch, "lr": lr_at(self.step, self.cfg), "grad_norm": float(gn), "tokens_seen": self.tokens_seen, "elapsed_s": round(time.time() - t_start, 1), **logs} | |
| 184 | + mem = None | |
| 185 | + if self.dev.type == "mps": | |
| 186 | + mem = round(torch.mps.driver_allocated_memory() / 2**30, 2) | |
| 187 | + elif self.dev.type == "cuda": | |
| 188 | + mem = round(torch.cuda.max_memory_allocated() / 2**30, 2) | |
| 189 | + rec = {"step": self.step, "epoch": epoch, "lr": lr_at(self.step, self.cfg), "grad_norm": float(gn), "tokens_seen": self.tokens_seen, "elapsed_s": round(time.time() - t_start, 1), "mem_gb": mem, **logs} | |
| 183 | 190 | append_jsonl(self.log_path, rec) |
| 184 | 191 | if not math.isfinite(logs["total"]): |
| 185 | 192 | raise RuntimeError(f"non-finite loss at step {self.step}: {logs}") |
added
tests/unit/test_eval_audits.py
+39 −0
@@ -0,0 +1,39 @@ | ||
| 1 | +from htr.evaluation.identifiability import rewrite_ambiguity, within_original_agreement | |
| 2 | +from htr.evaluation.memorization import audit_outputs, canary_leak_rate, make_canaries | |
| 3 | +from htr.evaluation.runner import holm_correction | |
| 4 | + | |
| 5 | + | |
| 6 | +def _pairs(): | |
| 7 | + base = "the quick brown fox jumps over the lazy dog near the river bank while the sun sets slowly" | |
| 8 | + rows = [] | |
| 9 | + for i in range(6): | |
| 10 | + t = base.replace("fox", f"fox{i}") | |
| 11 | + for s in (0, 1): | |
| 12 | + rows.append({"pair_id": f"p{i}_{s}", "passage_id": f"g{i}", "doc_id": f"d{i}", "variant": "E", "sample_index": s, "source_text": t.replace("quick", "fast" if s else "rapid"), "target_text": t, "metadata": {}}) | |
| 13 | + return rows | |
| 14 | + | |
| 15 | + | |
| 16 | +def test_within_original_agreement_and_ambiguity(): | |
| 17 | + pairs = _pairs() | |
| 18 | + outputs = {p["pair_id"]: p["target_text"] for p in pairs} # oracle outputs | |
| 19 | + r = within_original_agreement(pairs, outputs) | |
| 20 | + assert r["n_passages"] == 6 and r["chrf_recon1_recon2"]["mean"] == 100.0 | |
| 21 | + amb = rewrite_ambiguity(pairs, jaccard_threshold=0.3) | |
| 22 | + assert amb["n_close_pairs"] > 0 and amb["chrf_originals_mean"] < 100.0 | |
| 23 | + | |
| 24 | + | |
| 25 | +def test_memorization_audit_and_canaries(): | |
| 26 | + pairs = _pairs() | |
| 27 | + train = {f"t{i}": f"completely different training sentence number {i} about economics and finance" for i in range(5)} | |
| 28 | + train["leak"] = pairs[0]["target_text"] | |
| 29 | + outputs = {p["pair_id"]: p["target_text"] for p in pairs} | |
| 30 | + rep = audit_outputs(outputs, pairs, train) | |
| 31 | + assert rep["n"] == 12 and rep["frac_jaccard_ge_0_8"] >= 1 / 12 | |
| 32 | + can = make_canaries(3) | |
| 33 | + assert canary_leak_rate({c["pair_id"]: c["target_text"] for c in can}, can) == 1.0 | |
| 34 | + assert canary_leak_rate({c["pair_id"]: c["source_text"] for c in can}, can) == 0.0 | |
| 35 | + | |
| 36 | + | |
| 37 | +def test_holm(): | |
| 38 | + adj = holm_correction({"a": 0.01, "b": 0.04, "c": 0.03}) | |
| 39 | + assert adj["a"] == 0.03 and adj["c"] == 0.06 and adj["b"] == 0.06 | |
| 40 | ||