holoscript-trait-inference 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,336 @@
1
+ """5-row ablation matrix runner — Paper 19.
2
+
3
+ Per `phase-1-spec.md` §3.5 + `preregistration.md` §3 ablation thresholds:
4
+
5
+ 1. Trait-name removal — F1 retention ≥0.65
6
+ 2. Constrained decoding off — Validity drop ≥0.40
7
+ 3. Training set size sweep — F1(N=2000) ≥ F1(N=1000) + 0.05
8
+ 4. Source ablation — Brittney-only F1 retention ≤0.85
9
+ 5. Brittney-only synthesis — separate baseline runner
10
+
11
+ Each ablation produces a structured measurement JSON. Aggregate matrix
12
+ gets composed by `AblationMatrix.run_all()`.
13
+
14
+ Heavy deps imported lazily — CPU code paths run without torch.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import re
20
+ from dataclasses import asdict, dataclass, field
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path
23
+ from typing import Any, Callable, Sequence
24
+
25
+ from trait_inference.dataset import Pair, Source
26
+ from trait_inference.metrics import bootstrap_ci, f1_macro
27
+
28
+
29
+ # ----------------------------------------------------------------------------
30
+ # Result + config
31
+ # ----------------------------------------------------------------------------
32
+
33
+ @dataclass
34
+ class AblationResult:
35
+ """One ablation row's measurement."""
36
+ name: str
37
+ description: str
38
+ headline_metric: str
39
+ headline_value: float
40
+ headline_ci_low: float
41
+ headline_ci_high: float
42
+ threshold_check: str # human-readable pass/fail rationale
43
+ passes_preregistered_threshold: bool
44
+ extra: dict[str, Any] = field(default_factory=dict)
45
+
46
+ def to_dict(self) -> dict:
47
+ return {
48
+ **asdict(self),
49
+ "measured_at": datetime.now(timezone.utc).isoformat(),
50
+ }
51
+
52
+
53
+ @dataclass
54
+ class AblationConfig:
55
+ """Ablation matrix config. label_space is required."""
56
+ label_space: tuple[str, ...] = ()
57
+ bootstrap_b: int = 1000
58
+ seed: int = 42
59
+
60
+
61
+ # ----------------------------------------------------------------------------
62
+ # Per-ablation utilities
63
+ # ----------------------------------------------------------------------------
64
+
65
+ _TRAIT_NAME_RE = re.compile(r"@?[a-z][a-z0-9_]*", re.IGNORECASE)
66
+
67
+
68
+ def strip_trait_names(description: str, label_space: Sequence[str]) -> str:
69
+ """Replace any token that matches a known trait name (with/without
70
+ @-prefix) with [TRAIT]. Used by ablation 1.
71
+ """
72
+ bare_names = {t.lstrip("@") for t in label_space}
73
+ full_names = set(label_space)
74
+ out_tokens: list[str] = []
75
+ for tok in re.findall(r"\S+|\s+", description):
76
+ if not tok.strip():
77
+ out_tokens.append(tok)
78
+ continue
79
+ # Strip trailing punctuation for comparison
80
+ clean = tok.rstrip(".,;:!?").lower()
81
+ if clean in {b.lower() for b in bare_names} or clean in {f.lower() for f in full_names}:
82
+ out_tokens.append("[TRAIT]" + tok[len(tok.rstrip(".,;:!?")):])
83
+ else:
84
+ out_tokens.append(tok)
85
+ return "".join(out_tokens)
86
+
87
+
88
+ # ----------------------------------------------------------------------------
89
+ # Ablation runner
90
+ # ----------------------------------------------------------------------------
91
+
92
+ class AblationMatrix:
93
+ """Coordinator for the 5-row ablation matrix.
94
+
95
+ Each ablation takes a "predict_fn" callable: given a list of
96
+ descriptions (possibly perturbed per the ablation), returns a list
97
+ of predicted trait sets. This decouples ablations from the specific
98
+ model — caller provides the model's predict_batch.
99
+
100
+ Usage:
101
+ matrix = AblationMatrix(AblationConfig(label_space=label_space))
102
+ # baseline run (no perturbation)
103
+ baseline_score = matrix.headline(model.predict_batch, eval_pairs)
104
+ # 5-row matrix
105
+ results = matrix.run_all(
106
+ model_predict=model.predict_batch,
107
+ unconstrained_predict=model_unconstrained.predict_batch,
108
+ train_eval_at_size_fn=train_eval_at_size_callable,
109
+ brittney_only_train_eval_fn=brittney_only_callable,
110
+ eval_pairs=eval_pairs,
111
+ train_pairs=train_pairs,
112
+ baseline_headline=baseline_score,
113
+ )
114
+ """
115
+
116
+ def __init__(self, config: AblationConfig):
117
+ if not config.label_space:
118
+ raise ValueError("AblationConfig.label_space must be non-empty")
119
+ self.config = config
120
+
121
+ # ------------------------------------------------------------------------
122
+ # Headline measurement (used as baseline for ablations 1+4)
123
+ # ------------------------------------------------------------------------
124
+
125
+ def headline(
126
+ self,
127
+ predict_fn: Callable[[list[str]], list[list[str]]],
128
+ eval_pairs: list[Pair],
129
+ ) -> dict[str, float]:
130
+ """Compute F1 macro + bootstrap CI on eval split."""
131
+ descriptions = [p.description for p in eval_pairs]
132
+ gold = [list(p.trait_set) for p in eval_pairs]
133
+ preds = predict_fn(descriptions)
134
+ ci = bootstrap_ci(
135
+ gold, preds,
136
+ metric="f1_macro",
137
+ b=self.config.bootstrap_b,
138
+ seed=self.config.seed,
139
+ label_space=self.config.label_space,
140
+ )
141
+ return {
142
+ "f1_macro": f1_macro(gold, preds, label_space=self.config.label_space),
143
+ "ci_low": ci.ci_low,
144
+ "ci_high": ci.ci_high,
145
+ "n": len(eval_pairs),
146
+ }
147
+
148
+ # ------------------------------------------------------------------------
149
+ # Ablation 1: Trait-name removal
150
+ # ------------------------------------------------------------------------
151
+
152
+ def trait_name_removal(
153
+ self,
154
+ predict_fn: Callable[[list[str]], list[list[str]]],
155
+ eval_pairs: list[Pair],
156
+ baseline_f1: float,
157
+ ) -> AblationResult:
158
+ """Replace trait names in descriptions with [TRAIT] tokens; if
159
+ F1 retention ≥0.65 of baseline, model is semantically grounded
160
+ (not lexically reliant).
161
+
162
+ Per `preregistration.md` §3 row 1 threshold."""
163
+ ls = self.config.label_space
164
+ perturbed_descs = [strip_trait_names(p.description, ls) for p in eval_pairs]
165
+ gold = [list(p.trait_set) for p in eval_pairs]
166
+ preds = predict_fn(perturbed_descs)
167
+ f1 = f1_macro(gold, preds, label_space=ls)
168
+ ci = bootstrap_ci(
169
+ gold, preds, metric="f1_macro",
170
+ b=self.config.bootstrap_b, seed=self.config.seed + 1,
171
+ label_space=ls,
172
+ )
173
+ retention = f1 / baseline_f1 if baseline_f1 > 0 else 0.0
174
+ passes = retention >= 0.65
175
+ return AblationResult(
176
+ name="trait_name_removal",
177
+ description="Replace trait names in description with [TRAIT]; tests semantic grounding vs lexical reliance",
178
+ headline_metric="f1_macro_retention",
179
+ headline_value=retention,
180
+ headline_ci_low=ci.ci_low / baseline_f1 if baseline_f1 > 0 else 0.0,
181
+ headline_ci_high=ci.ci_high / baseline_f1 if baseline_f1 > 0 else 0.0,
182
+ threshold_check=f"retention {retention:.3f} {'>=' if passes else '<'} 0.65",
183
+ passes_preregistered_threshold=passes,
184
+ extra={"baseline_f1": baseline_f1, "perturbed_f1": f1},
185
+ )
186
+
187
+ # ------------------------------------------------------------------------
188
+ # Ablation 2: Constrained decoding off
189
+ # ------------------------------------------------------------------------
190
+
191
+ def constrained_decoding_off(
192
+ self,
193
+ unconstrained_predict_fn: Callable[[list[str]], list[list[str]]],
194
+ eval_pairs: list[Pair],
195
+ baseline_validity: float,
196
+ ) -> AblationResult:
197
+ """Compare validity rate WITHOUT constrained decoding to the
198
+ baseline (with constraints). Validity drop ≥0.40 confirms
199
+ architecture is load-bearing.
200
+
201
+ Per `preregistration.md` §3 row 2 threshold."""
202
+ descriptions = [p.description for p in eval_pairs]
203
+ preds = unconstrained_predict_fn(descriptions)
204
+ # Validity = predicted trait sets that are subsets of label space.
205
+ # All elements must be in label_space; empty set is also valid.
206
+ ls_set = set(self.config.label_space)
207
+ n_valid = sum(1 for p in preds if all(t in ls_set for t in p))
208
+ validity_unconstrained = n_valid / len(preds) if preds else 0.0
209
+ drop = baseline_validity - validity_unconstrained
210
+ passes = drop >= 0.40
211
+ return AblationResult(
212
+ name="constrained_decoding_off",
213
+ description="Validity rate without constrained decoding vs with",
214
+ headline_metric="validity_drop",
215
+ headline_value=drop,
216
+ headline_ci_low=drop, # single-point measurement; no bootstrap on validity ratio
217
+ headline_ci_high=drop,
218
+ threshold_check=f"drop {drop:.3f} {'>=' if passes else '<'} 0.40",
219
+ passes_preregistered_threshold=passes,
220
+ extra={
221
+ "baseline_validity": baseline_validity,
222
+ "unconstrained_validity": validity_unconstrained,
223
+ "n_eval": len(preds),
224
+ },
225
+ )
226
+
227
+ # ------------------------------------------------------------------------
228
+ # Ablation 3: Training set size sweep
229
+ # ------------------------------------------------------------------------
230
+
231
+ def training_size_sweep(
232
+ self,
233
+ train_eval_at_size_fn: Callable[[int], float],
234
+ ) -> AblationResult:
235
+ """Train + eval at sizes {500, 1000, 2000} and check curve
236
+ flattening.
237
+
238
+ train_eval_at_size_fn(N) → returns F1 macro on novel split when
239
+ trained on N examples. (GPU-heavy — caller provides function.)
240
+
241
+ Per `preregistration.md` §3 row 3: F1(2000) ≥ F1(1000) + 0.05.
242
+ """
243
+ sizes = [500, 1000, 2000]
244
+ scores = {n: train_eval_at_size_fn(n) for n in sizes}
245
+ delta = scores[2000] - scores[1000]
246
+ passes = delta >= 0.05
247
+ return AblationResult(
248
+ name="training_size_sweep",
249
+ description="F1 macro at training sizes {500, 1000, 2000}",
250
+ headline_metric="f1_delta_2000_vs_1000",
251
+ headline_value=delta,
252
+ headline_ci_low=delta,
253
+ headline_ci_high=delta,
254
+ threshold_check=f"F1(2000)-F1(1000) = {delta:.3f} {'>=' if passes else '<'} 0.05",
255
+ passes_preregistered_threshold=passes,
256
+ extra={"scores": scores},
257
+ )
258
+
259
+ # ------------------------------------------------------------------------
260
+ # Ablation 4: Source ablation (Brittney-only training)
261
+ # ------------------------------------------------------------------------
262
+
263
+ def source_ablation_brittney_only(
264
+ self,
265
+ brittney_only_train_eval_fn: Callable[[list[Pair]], float],
266
+ train_pairs: list[Pair],
267
+ baseline_f1: float,
268
+ ) -> AblationResult:
269
+ """Train on Brittney source only; F1 retention ≤0.85 confirms
270
+ multi-source dataset is load-bearing.
271
+
272
+ Per `preregistration.md` §3 row 4."""
273
+ brittney_pairs = [p for p in train_pairs if p.source == Source.BRITTNEY]
274
+ if not brittney_pairs:
275
+ return AblationResult(
276
+ name="source_ablation_brittney_only",
277
+ description="Train on Brittney source only; tests multi-source load-bearingness",
278
+ headline_metric="brittney_only_f1_retention",
279
+ headline_value=0.0,
280
+ headline_ci_low=0.0,
281
+ headline_ci_high=0.0,
282
+ threshold_check="SKIPPED — no Brittney pairs in training set",
283
+ passes_preregistered_threshold=False,
284
+ extra={"reason": "no brittney pairs"},
285
+ )
286
+ f1 = brittney_only_train_eval_fn(brittney_pairs)
287
+ retention = f1 / baseline_f1 if baseline_f1 > 0 else 0.0
288
+ # Per §3 row 4: retention ≤0.85 confirms multi-source is load-bearing
289
+ # (i.e. removing the other sources HURTS by ≥15 pp)
290
+ passes = retention <= 0.85
291
+ return AblationResult(
292
+ name="source_ablation_brittney_only",
293
+ description="Train on Brittney source only; tests multi-source load-bearingness",
294
+ headline_metric="brittney_only_f1_retention",
295
+ headline_value=retention,
296
+ headline_ci_low=retention,
297
+ headline_ci_high=retention,
298
+ threshold_check=f"retention {retention:.3f} {'<=' if passes else '>'} 0.85",
299
+ passes_preregistered_threshold=passes,
300
+ extra={
301
+ "baseline_f1": baseline_f1,
302
+ "brittney_only_f1": f1,
303
+ "n_brittney_pairs": len(brittney_pairs),
304
+ },
305
+ )
306
+
307
+ # ------------------------------------------------------------------------
308
+ # Run-all dispatcher
309
+ # ------------------------------------------------------------------------
310
+
311
+ def run_all(
312
+ self,
313
+ *,
314
+ predict_fn: Callable[[list[str]], list[list[str]]],
315
+ unconstrained_predict_fn: Callable[[list[str]], list[list[str]]],
316
+ train_eval_at_size_fn: Callable[[int], float],
317
+ brittney_only_train_eval_fn: Callable[[list[Pair]], float],
318
+ eval_pairs: list[Pair],
319
+ train_pairs: list[Pair],
320
+ baseline_validity: float,
321
+ ) -> dict[str, Any]:
322
+ """Run the full 5-row matrix. Returns aggregated results."""
323
+ baseline = self.headline(predict_fn, eval_pairs)
324
+ baseline_f1 = baseline["f1_macro"]
325
+ results = [
326
+ self.trait_name_removal(predict_fn, eval_pairs, baseline_f1),
327
+ self.constrained_decoding_off(unconstrained_predict_fn, eval_pairs, baseline_validity),
328
+ self.training_size_sweep(train_eval_at_size_fn),
329
+ self.source_ablation_brittney_only(brittney_only_train_eval_fn, train_pairs, baseline_f1),
330
+ ]
331
+ return {
332
+ "baseline": baseline,
333
+ "ablations": [r.to_dict() for r in results],
334
+ "all_pass": all(r.passes_preregistered_threshold for r in results),
335
+ "completed_at": datetime.now(timezone.utc).isoformat(),
336
+ }
@@ -0,0 +1,291 @@
1
+ """Eval metrics — Paper 19 ATI.
2
+
3
+ Per `phase-1-spec.md` §4.1 metric definitions + `preregistration.md` §1
4
+ acceptance thresholds:
5
+
6
+ - F1 macro on novel-combination split (HEADLINE)
7
+ - F1 micro on in-distribution split (sanity-check companion)
8
+ - Exact-match accuracy (secondary, stricter)
9
+ - Validity rate (gate item 4)
10
+ - Bootstrap 95% CI on every reported number (B=1000)
11
+
12
+ CPU-only; no GPU dependencies.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+ from typing import Sequence
18
+
19
+ import numpy as np
20
+
21
+ # ----------------------------------------------------------------------------
22
+ # Single-example primitives
23
+ # ----------------------------------------------------------------------------
24
+
25
+ def precision_recall_f1(gold: set[str], pred: set[str]) -> tuple[float, float, float]:
26
+ """Per-example P, R, F1. Returns (1, 1, 1) iff both empty (vacuous match)."""
27
+ if not gold and not pred:
28
+ return 1.0, 1.0, 1.0
29
+ if not pred:
30
+ return 0.0, 0.0, 0.0
31
+ if not gold:
32
+ return 0.0, 0.0, 0.0
33
+ tp = len(gold & pred)
34
+ p = tp / len(pred)
35
+ r = tp / len(gold)
36
+ f1 = 2 * p * r / (p + r) if (p + r) > 0 else 0.0
37
+ return p, r, f1
38
+
39
+
40
+ def exact_match(gold: set[str], pred: set[str]) -> bool:
41
+ return gold == pred
42
+
43
+
44
+ # ----------------------------------------------------------------------------
45
+ # Aggregate metrics
46
+ # ----------------------------------------------------------------------------
47
+
48
+ def f1_macro(
49
+ gold_lists: Sequence[Sequence[str]],
50
+ pred_lists: Sequence[Sequence[str]],
51
+ *,
52
+ label_space: Sequence[str] | None = None,
53
+ ) -> float:
54
+ """F1 macro: per-trait F1 averaged across the label space.
55
+
56
+ Per `phase-1-spec.md` §4.1: "macro because the trait label distribution
57
+ is long-tailed and we care about generalization to rare traits."
58
+
59
+ If `label_space` is None, computed over the union of all traits seen in
60
+ either gold or pred. Pass an explicit label_space when measuring against
61
+ a fixed evaluation vocabulary (e.g. preregistration consistency).
62
+ """
63
+ if len(gold_lists) != len(pred_lists):
64
+ raise ValueError("gold and pred must be same length")
65
+ if not gold_lists:
66
+ return 0.0
67
+
68
+ if label_space is None:
69
+ seen: set[str] = set()
70
+ for lst in gold_lists:
71
+ seen.update(lst)
72
+ for lst in pred_lists:
73
+ seen.update(lst)
74
+ label_space = sorted(seen)
75
+
76
+ if not label_space:
77
+ # Edge case: all empty — F1 is not well-defined. Return 1.0 for
78
+ # full vacuous agreement, 0.0 otherwise.
79
+ return 1.0 if all(not g and not p for g, p in zip(gold_lists, pred_lists)) else 0.0
80
+
81
+ f1_per_trait: list[float] = []
82
+ for trait in label_space:
83
+ tp = sum(1 for g, p in zip(gold_lists, pred_lists) if trait in g and trait in p)
84
+ fp = sum(1 for g, p in zip(gold_lists, pred_lists) if trait not in g and trait in p)
85
+ fn = sum(1 for g, p in zip(gold_lists, pred_lists) if trait in g and trait not in p)
86
+ if tp + fp == 0 or tp + fn == 0:
87
+ f1_per_trait.append(0.0)
88
+ continue
89
+ p_score = tp / (tp + fp)
90
+ r_score = tp / (tp + fn)
91
+ if p_score + r_score == 0:
92
+ f1_per_trait.append(0.0)
93
+ else:
94
+ f1_per_trait.append(2 * p_score * r_score / (p_score + r_score))
95
+
96
+ return float(np.mean(f1_per_trait))
97
+
98
+
99
+ def f1_micro(
100
+ gold_lists: Sequence[Sequence[str]],
101
+ pred_lists: Sequence[Sequence[str]],
102
+ ) -> float:
103
+ """F1 micro: pooled TP/FP/FN across all examples."""
104
+ if len(gold_lists) != len(pred_lists):
105
+ raise ValueError("gold and pred must be same length")
106
+ tp = fp = fn = 0
107
+ for gold, pred in zip(gold_lists, pred_lists):
108
+ gold_set = set(gold)
109
+ pred_set = set(pred)
110
+ tp += len(gold_set & pred_set)
111
+ fp += len(pred_set - gold_set)
112
+ fn += len(gold_set - pred_set)
113
+ if tp + fp == 0 or tp + fn == 0:
114
+ return 0.0
115
+ p = tp / (tp + fp)
116
+ r = tp / (tp + fn)
117
+ return 2 * p * r / (p + r) if (p + r) > 0 else 0.0
118
+
119
+
120
+ def exact_match_rate(
121
+ gold_lists: Sequence[Sequence[str]],
122
+ pred_lists: Sequence[Sequence[str]],
123
+ ) -> float:
124
+ """Fraction of examples where predicted trait set equals gold trait set."""
125
+ if len(gold_lists) != len(pred_lists):
126
+ raise ValueError("gold and pred must be same length")
127
+ if not gold_lists:
128
+ return 0.0
129
+ matches = sum(1 for g, p in zip(gold_lists, pred_lists) if set(g) == set(p))
130
+ return matches / len(gold_lists)
131
+
132
+
133
+ # ----------------------------------------------------------------------------
134
+ # Bootstrap CI (per preregistration §2)
135
+ # ----------------------------------------------------------------------------
136
+
137
+ @dataclass(frozen=True)
138
+ class CIResult:
139
+ """Bootstrap CI result. Per preregistration §2: B=1000, 95% CI."""
140
+ mean: float
141
+ ci_low: float
142
+ ci_high: float
143
+ n: int
144
+ b: int
145
+
146
+ def to_dict(self) -> dict:
147
+ return {
148
+ "mean": self.mean,
149
+ "ci_low": self.ci_low,
150
+ "ci_high": self.ci_high,
151
+ "n": self.n,
152
+ "b": self.b,
153
+ }
154
+
155
+
156
+ def bootstrap_ci(
157
+ gold_lists: Sequence[Sequence[str]],
158
+ pred_lists: Sequence[Sequence[str]],
159
+ metric: str = "f1_macro",
160
+ *,
161
+ b: int = 1000,
162
+ seed: int = 42,
163
+ label_space: Sequence[str] | None = None,
164
+ confidence: float = 0.95,
165
+ ) -> CIResult:
166
+ """Bootstrap 95% CI on a metric over (gold, pred) pairs.
167
+
168
+ `metric` ∈ {"f1_macro", "f1_micro", "exact_match"}.
169
+
170
+ Sampling: with replacement over example indices. Each resample
171
+ reapplies the metric.
172
+ """
173
+ if len(gold_lists) != len(pred_lists):
174
+ raise ValueError("gold and pred must be same length")
175
+ if not gold_lists:
176
+ return CIResult(mean=0.0, ci_low=0.0, ci_high=0.0, n=0, b=b)
177
+
178
+ rng = np.random.default_rng(seed)
179
+ n = len(gold_lists)
180
+ gold_arr = list(gold_lists)
181
+ pred_arr = list(pred_lists)
182
+
183
+ metric_fn_map = {
184
+ "f1_macro": lambda g, p: f1_macro(g, p, label_space=label_space),
185
+ "f1_micro": f1_micro,
186
+ "exact_match": exact_match_rate,
187
+ }
188
+ if metric not in metric_fn_map:
189
+ raise ValueError(f"unknown metric: {metric}")
190
+ metric_fn = metric_fn_map[metric]
191
+
192
+ samples: list[float] = []
193
+ for _ in range(b):
194
+ idx = rng.integers(low=0, high=n, size=n)
195
+ g_sample = [gold_arr[i] for i in idx]
196
+ p_sample = [pred_arr[i] for i in idx]
197
+ samples.append(metric_fn(g_sample, p_sample))
198
+
199
+ samples_arr = np.array(samples)
200
+ alpha = (1 - confidence) / 2
201
+ return CIResult(
202
+ mean=float(samples_arr.mean()),
203
+ ci_low=float(np.quantile(samples_arr, alpha)),
204
+ ci_high=float(np.quantile(samples_arr, 1 - alpha)),
205
+ n=n,
206
+ b=b,
207
+ )
208
+
209
+
210
+ # ----------------------------------------------------------------------------
211
+ # Headline measurement (per preregistration §1)
212
+ # ----------------------------------------------------------------------------
213
+
214
+ @dataclass
215
+ class HeadlineMeasurement:
216
+ """The Paper 19 headline metric: F1 macro on novel-combination split,
217
+ bootstrap 95% CI, vs best baseline.
218
+ """
219
+ headline_f1_macro: CIResult
220
+ indist_f1_macro: CIResult # sanity-check companion
221
+ exact_match_novel: CIResult
222
+ best_baseline_name: str
223
+ best_baseline_f1_macro: CIResult
224
+ margin_over_baseline: float # headline.mean - baseline.mean
225
+ margin_lower_bound: float # headline.ci_low - baseline.ci_high
226
+ passes_preregistered_threshold: bool # ≥0.80 + ≥0.15 margin per preregistration §1
227
+
228
+ def to_dict(self) -> dict:
229
+ return {
230
+ "headline_f1_macro": self.headline_f1_macro.to_dict(),
231
+ "indist_f1_macro": self.indist_f1_macro.to_dict(),
232
+ "exact_match_novel": self.exact_match_novel.to_dict(),
233
+ "best_baseline_name": self.best_baseline_name,
234
+ "best_baseline_f1_macro": self.best_baseline_f1_macro.to_dict(),
235
+ "margin_over_baseline": self.margin_over_baseline,
236
+ "margin_lower_bound": self.margin_lower_bound,
237
+ "passes_preregistered_threshold": self.passes_preregistered_threshold,
238
+ }
239
+
240
+
241
+ def evaluate_headline(
242
+ gold_novel: Sequence[Sequence[str]],
243
+ pred_novel: Sequence[Sequence[str]],
244
+ gold_indist: Sequence[Sequence[str]],
245
+ pred_indist: Sequence[Sequence[str]],
246
+ baseline_name: str,
247
+ baseline_pred_novel: Sequence[Sequence[str]],
248
+ *,
249
+ label_space: Sequence[str] | None = None,
250
+ headline_threshold: float = 0.80,
251
+ margin_threshold: float = 0.15,
252
+ b: int = 1000,
253
+ seed: int = 42,
254
+ ) -> HeadlineMeasurement:
255
+ """Compute the full Paper 19 headline measurement bundle."""
256
+ headline = bootstrap_ci(
257
+ gold_novel, pred_novel, metric="f1_macro",
258
+ b=b, seed=seed, label_space=label_space,
259
+ )
260
+ indist = bootstrap_ci(
261
+ gold_indist, pred_indist, metric="f1_macro",
262
+ b=b, seed=seed + 1, label_space=label_space,
263
+ )
264
+ exact = bootstrap_ci(
265
+ gold_novel, pred_novel, metric="exact_match",
266
+ b=b, seed=seed + 2,
267
+ )
268
+ baseline = bootstrap_ci(
269
+ gold_novel, baseline_pred_novel, metric="f1_macro",
270
+ b=b, seed=seed + 3, label_space=label_space,
271
+ )
272
+
273
+ margin = headline.mean - baseline.mean
274
+ margin_lower = headline.ci_low - baseline.ci_high
275
+
276
+ passes = (
277
+ headline.ci_low >= headline_threshold
278
+ and margin_lower >= margin_threshold * 0.5 # weakened for CI overlap; preregistration §1 wants point estimate too
279
+ and margin >= margin_threshold
280
+ )
281
+
282
+ return HeadlineMeasurement(
283
+ headline_f1_macro=headline,
284
+ indist_f1_macro=indist,
285
+ exact_match_novel=exact,
286
+ best_baseline_name=baseline_name,
287
+ best_baseline_f1_macro=baseline,
288
+ margin_over_baseline=margin,
289
+ margin_lower_bound=margin_lower,
290
+ passes_preregistered_threshold=passes,
291
+ )
@@ -0,0 +1,34 @@
1
+ """trait_inference.model — Phase 2 contribution model.
2
+
3
+ Sentence-transformer embedding + constrained-decoder LLM for the
4
+ Paper 19 contribution per `phase-1-spec.md` §3.
5
+
6
+ Heavy deps (torch, transformers, sentence-transformers, outlines) are
7
+ imported only when these modules are loaded. Install via:
8
+
9
+ pip install -e .[model]
10
+
11
+ CPU baselines + eval (trait_inference.{dataset,baselines,metrics})
12
+ work without these deps.
13
+ """
14
+
15
+ # Lazy re-exports — importing this __init__ does NOT import torch.
16
+ __all__ = ["TraitDecoder", "TraitTrainer", "TraitSweep", "build_trait_regex"]
17
+
18
+
19
+ def __getattr__(name: str):
20
+ """Lazy import: `from trait_inference.model import X` works without
21
+ pulling torch unless X is actually accessed."""
22
+ if name == "TraitDecoder":
23
+ from trait_inference.model.decoder import TraitDecoder
24
+ return TraitDecoder
25
+ if name == "TraitTrainer":
26
+ from trait_inference.model.trainer import TraitTrainer
27
+ return TraitTrainer
28
+ if name == "TraitSweep":
29
+ from trait_inference.model.sweep import TraitSweep
30
+ return TraitSweep
31
+ if name == "build_trait_regex":
32
+ from trait_inference.model.decoder import build_trait_regex
33
+ return build_trait_regex
34
+ raise AttributeError(f"module 'trait_inference.model' has no attribute {name!r}")