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,192 @@
1
+ """Constrained-decoding trait set generator — Paper 19 contribution.
2
+
3
+ Per `phase-1-spec.md` §3.1: small LLM decoder with constrained
4
+ generation over the trait composition lattice. The decoder samples ONLY
5
+ JSON arrays of valid trait names, in canonical sorted order, with no
6
+ duplicates. This bakes gate item 4 (≥90% validity) into the
7
+ ARCHITECTURE per F.031.
8
+
9
+ Approach:
10
+ - HF transformers LLM (default Qwen2.5-0.5B; ≤1B sweep target).
11
+ - Prompt template: {description} → {trait JSON}.
12
+ - Constrained decoding via outlines.RegexFSM with a regex enumerating
13
+ the label space.
14
+ - Post-decode: parse JSON, deduplicate, return as sorted set
15
+ (defense-in-depth).
16
+
17
+ Heavy deps (torch, transformers, outlines) imported only when
18
+ TraitDecoder is constructed.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import re
24
+ from dataclasses import dataclass, field
25
+ from typing import Any, Sequence
26
+
27
+
28
+ # ----------------------------------------------------------------------------
29
+ # Regex builder for the trait JSON list (no heavy deps)
30
+ # ----------------------------------------------------------------------------
31
+
32
+ def build_trait_regex(label_space: Sequence[str]) -> str:
33
+ """Build a regex that matches a JSON list of traits drawn from the
34
+ label space, in any order, with arbitrary length 0..N.
35
+
36
+ Returns a Python regex string suitable for outlines.RegexFSM.
37
+
38
+ NOTE: This regex enforces MEMBERSHIP in the label space and JSON
39
+ syntax, but does NOT enforce sorted-unique-set semantics — those
40
+ are applied post-decode via parse_trait_set(). Stateful
41
+ sort-unique constraint is possible via outlines CFG but adds
42
+ significant complexity; defense-in-depth post-processing is
43
+ sufficient for the spec.
44
+
45
+ Examples (with label_space = ["@grabbable", "@physics", "@rigid"]):
46
+ [] — valid (empty)
47
+ ["@grabbable"] — valid
48
+ ["@grabbable", "@physics"] — valid
49
+ ["@physics", "@grabbable"] — valid (post-dedup will sort)
50
+ ["@unknown"] — REJECTED by regex
51
+ """
52
+ if not label_space:
53
+ return r"\[\]"
54
+ # Each trait literal — escaped for regex, JSON-quoted.
55
+ trait_alts = "|".join(re.escape(t) for t in sorted(set(label_space)))
56
+ one_trait = rf'"({trait_alts})"'
57
+ # `[]` OR `[T(, T)*]`
58
+ return rf'\[(?:{one_trait}(?:, {one_trait})*)?\]'
59
+
60
+
61
+ def parse_trait_set(text: str, label_space: Sequence[str] | None = None) -> list[str]:
62
+ """Parse a JSON-list string from the decoder, return canonical
63
+ sorted-unique trait list.
64
+
65
+ Tolerant: returns [] on parse failure rather than raising — generator
66
+ output should be assumed well-formed (constrained decoding) but
67
+ defense-in-depth means we never crash the eval harness on a single
68
+ bad output.
69
+
70
+ If label_space is provided, traits not in label_space are dropped.
71
+ """
72
+ text = text.strip()
73
+ try:
74
+ parsed = json.loads(text)
75
+ except json.JSONDecodeError:
76
+ return []
77
+ if not isinstance(parsed, list):
78
+ return []
79
+ out: set[str] = set()
80
+ for item in parsed:
81
+ if not isinstance(item, str):
82
+ continue
83
+ if label_space is not None and item not in label_space:
84
+ continue
85
+ out.add(item)
86
+ return sorted(out)
87
+
88
+
89
+ # ----------------------------------------------------------------------------
90
+ # TraitDecoder — model wrapper
91
+ # ----------------------------------------------------------------------------
92
+
93
+ @dataclass
94
+ class TraitDecoderConfig:
95
+ """Config for the constrained-decoding model."""
96
+ model_name: str = "Qwen/Qwen2.5-0.5B" # ≤1B per spec; sweep over Qwen-0.5B/1B + Llama-3.2-1B
97
+ label_space: tuple[str, ...] = ()
98
+ prompt_template: str = (
99
+ "Given a description of a virtual object, return the minimal "
100
+ "set of HoloScript traits that apply, as a JSON array.\n"
101
+ "Description: {description}\n"
102
+ "Traits: "
103
+ )
104
+ max_new_tokens: int = 256
105
+ temperature: float = 0.0 # deterministic; required for Paper 19 reproducibility
106
+ device: str = "cuda" # override "cpu" for local smoke-test
107
+ encoder_name: str | None = None # if set, use sentence-transformer for input embedding (Phase 2.1 ablation axis)
108
+
109
+
110
+ class TraitDecoder:
111
+ """Wrapper over HF transformers + outlines constrained generation.
112
+
113
+ Construction triggers heavy imports + model load. For CPU smoke
114
+ tests, pass `device="cpu"` AND a tiny model_name like
115
+ "sshleifer/tiny-gpt2".
116
+
117
+ Usage:
118
+ decoder = TraitDecoder(TraitDecoderConfig(label_space=(...,)))
119
+ traits = decoder.predict("a ball that bounces")
120
+ traits # ["@collidable", "@physics", "@rigid"]
121
+ """
122
+
123
+ def __init__(self, config: TraitDecoderConfig):
124
+ if not config.label_space:
125
+ raise ValueError("TraitDecoderConfig.label_space must be non-empty")
126
+ # Heavy imports happen here, not at module load.
127
+ try:
128
+ import torch # noqa: F401
129
+ from transformers import AutoModelForCausalLM, AutoTokenizer
130
+ except ImportError as exc:
131
+ raise ImportError(
132
+ "TraitDecoder requires the [model] extra. "
133
+ "Install via: pip install -e '.[model]'"
134
+ ) from exc
135
+
136
+ self.config = config
137
+ self.tokenizer = AutoTokenizer.from_pretrained(config.model_name)
138
+ if self.tokenizer.pad_token is None:
139
+ self.tokenizer.pad_token = self.tokenizer.eos_token
140
+
141
+ self.model = AutoModelForCausalLM.from_pretrained(config.model_name)
142
+ self.model.to(config.device)
143
+ self.model.eval()
144
+
145
+ self._regex = build_trait_regex(config.label_space)
146
+ self._fsm: Any = None # lazy-built on first predict (outlines optional at construction)
147
+
148
+ def _build_fsm(self) -> Any:
149
+ """Build the outlines FSM for constrained decoding."""
150
+ if self._fsm is not None:
151
+ return self._fsm
152
+ try:
153
+ import outlines # noqa: F401
154
+ except ImportError as exc:
155
+ raise ImportError(
156
+ "TraitDecoder.predict() requires outlines. "
157
+ "Install via: pip install -e '.[model]'"
158
+ ) from exc
159
+ # outlines API surface has changed across versions; we use the
160
+ # generate.regex helper which is stable as of 0.0.40+.
161
+ from outlines.models.transformers import Transformers
162
+ from outlines.generate import regex as outlines_regex_gen
163
+ wrapped = Transformers(self.model, self.tokenizer)
164
+ self._fsm = outlines_regex_gen(wrapped, self._regex)
165
+ return self._fsm
166
+
167
+ def predict(self, description: str) -> list[str]:
168
+ """Generate constrained trait set for a single description."""
169
+ return self.predict_batch([description])[0]
170
+
171
+ def predict_batch(self, descriptions: list[str]) -> list[list[str]]:
172
+ """Batch predict. Returns list of canonical sorted trait lists."""
173
+ fsm = self._build_fsm()
174
+ prompts = [self.config.prompt_template.format(description=d) for d in descriptions]
175
+ # outlines `generate.regex` returns a callable; arg is prompt string(s).
176
+ results = fsm(prompts, max_tokens=self.config.max_new_tokens)
177
+ if isinstance(results, str):
178
+ results = [results]
179
+ out: list[list[str]] = []
180
+ for text in results:
181
+ out.append(parse_trait_set(text, label_space=self.config.label_space))
182
+ return out
183
+
184
+ @classmethod
185
+ def for_smoke_test(cls, label_space: Sequence[str]) -> "TraitDecoder":
186
+ """Construct a CPU-only tiny model for unit/smoke tests."""
187
+ return cls(TraitDecoderConfig(
188
+ model_name="sshleifer/tiny-gpt2",
189
+ label_space=tuple(label_space),
190
+ device="cpu",
191
+ max_new_tokens=64,
192
+ ))
@@ -0,0 +1,242 @@
1
+ """Hyperparameter sweep runner — Paper 19.
2
+
3
+ Per `phase-1-spec.md` §3.3 + §4.4: 162-cell hyperparameter sweep
4
+ fractional-factorial-pruned to ~30 cells. Per `preregistration.md`
5
+ §1: N=5 reseed per headline cell.
6
+
7
+ Sweep axes:
8
+ - decoder model: Qwen-0.5B / Qwen-1B / Llama-3.2-1B
9
+ - encoder name: all-MiniLM-L6 / mpnet-base / None (LLM-only)
10
+ - beam width: 1 / 4 / 8 (inference-time)
11
+ - constraint timing: per-token / per-emission
12
+ - training set size: 500 / 1000 / 2000 (resolves seed doc Q1)
13
+
14
+ Each cell = ~6 GPU-hours on RTX 4090; full sweep at 30 cells × N=5
15
+ reseed = 900 GPU-hours = ~$240 at $0.27/hr × 30 parallel GPUs ≈ 30hr
16
+ wall-clock if fully parallel.
17
+
18
+ This module designs the sweep — the runner is the entry point invoked
19
+ by trait-inference CLI `model sweep`.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import itertools
24
+ import json
25
+ from dataclasses import asdict, dataclass, field
26
+ from datetime import datetime, timezone
27
+ from pathlib import Path
28
+ from typing import Iterator
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class SweepCell:
33
+ """One cell in the hyperparameter sweep."""
34
+ cell_id: str
35
+ decoder_model: str
36
+ encoder_name: str | None
37
+ beam_width: int
38
+ constraint_timing: str # "per_token" | "per_emission"
39
+ train_size: int
40
+ reseed_index: int # 0..N-1 per preregistration N=5
41
+
42
+
43
+ @dataclass
44
+ class SweepConfig:
45
+ """Sweep configuration — defaults match `phase-1-spec.md` §3.3."""
46
+ decoder_models: tuple[str, ...] = (
47
+ "Qwen/Qwen2.5-0.5B",
48
+ "Qwen/Qwen2.5-1.5B", # 1B-class
49
+ "meta-llama/Llama-3.2-1B",
50
+ )
51
+ encoder_names: tuple[str | None, ...] = (
52
+ None, # LLM-only baseline
53
+ "sentence-transformers/all-MiniLM-L6-v2",
54
+ "sentence-transformers/mpnet-base-v2",
55
+ )
56
+ beam_widths: tuple[int, ...] = (1, 4, 8)
57
+ constraint_timings: tuple[str, ...] = ("per_token", "per_emission")
58
+ train_sizes: tuple[int, ...] = (500, 1000, 2000)
59
+ reseed_n: int = 5 # per preregistration §1
60
+ fractional_factorial_pruned: bool = True
61
+ pruned_target: int = 30 # spec target for ~30 cells
62
+
63
+ def full_grid(self) -> list[SweepCell]:
64
+ """Generate the full Cartesian-product grid (162 cells before
65
+ pruning, 162 × N=5 = 810 with reseed)."""
66
+ cells = []
67
+ for i, (dm, en, bw, ct, ts) in enumerate(itertools.product(
68
+ self.decoder_models,
69
+ self.encoder_names,
70
+ self.beam_widths,
71
+ self.constraint_timings,
72
+ self.train_sizes,
73
+ )):
74
+ for r in range(self.reseed_n):
75
+ cells.append(SweepCell(
76
+ cell_id=f"cell-{i:03d}-r{r}",
77
+ decoder_model=dm,
78
+ encoder_name=en,
79
+ beam_width=bw,
80
+ constraint_timing=ct,
81
+ train_size=ts,
82
+ reseed_index=r,
83
+ ))
84
+ return cells
85
+
86
+ def pruned_grid(self) -> list[SweepCell]:
87
+ """Generate the fractional-factorial-pruned grid.
88
+
89
+ Strategy: for each axis, vary one-at-a-time around a center
90
+ point (Plackett-Burman-style). Center = (Qwen-0.5B, mpnet-base,
91
+ beam=4, per_emission, train=1000). For headline measurement,
92
+ all reseeds (N=5) of the BEST cell from the pruned grid.
93
+
94
+ This produces ~30 cells matching `phase-1-spec.md` §3.3 target.
95
+ """
96
+ center = SweepCell(
97
+ cell_id="center",
98
+ decoder_model="Qwen/Qwen2.5-0.5B",
99
+ encoder_name="sentence-transformers/mpnet-base-v2",
100
+ beam_width=4,
101
+ constraint_timing="per_emission",
102
+ train_size=1000,
103
+ reseed_index=0,
104
+ )
105
+ cells: list[SweepCell] = [center]
106
+
107
+ # One-at-a-time perturbations
108
+ for dm in self.decoder_models:
109
+ if dm == center.decoder_model:
110
+ continue
111
+ cells.append(SweepCell(
112
+ cell_id=f"axis-decoder-{dm.split('/')[-1]}",
113
+ decoder_model=dm,
114
+ encoder_name=center.encoder_name,
115
+ beam_width=center.beam_width,
116
+ constraint_timing=center.constraint_timing,
117
+ train_size=center.train_size,
118
+ reseed_index=0,
119
+ ))
120
+ for en in self.encoder_names:
121
+ if en == center.encoder_name:
122
+ continue
123
+ cells.append(SweepCell(
124
+ cell_id=f"axis-encoder-{en if en else 'none'}".replace("/", "_"),
125
+ decoder_model=center.decoder_model,
126
+ encoder_name=en,
127
+ beam_width=center.beam_width,
128
+ constraint_timing=center.constraint_timing,
129
+ train_size=center.train_size,
130
+ reseed_index=0,
131
+ ))
132
+ for bw in self.beam_widths:
133
+ if bw == center.beam_width:
134
+ continue
135
+ cells.append(SweepCell(
136
+ cell_id=f"axis-beam-{bw}",
137
+ decoder_model=center.decoder_model,
138
+ encoder_name=center.encoder_name,
139
+ beam_width=bw,
140
+ constraint_timing=center.constraint_timing,
141
+ train_size=center.train_size,
142
+ reseed_index=0,
143
+ ))
144
+ for ct in self.constraint_timings:
145
+ if ct == center.constraint_timing:
146
+ continue
147
+ cells.append(SweepCell(
148
+ cell_id=f"axis-constraint-{ct}",
149
+ decoder_model=center.decoder_model,
150
+ encoder_name=center.encoder_name,
151
+ beam_width=center.beam_width,
152
+ constraint_timing=ct,
153
+ train_size=center.train_size,
154
+ reseed_index=0,
155
+ ))
156
+ for ts in self.train_sizes:
157
+ if ts == center.train_size:
158
+ continue
159
+ cells.append(SweepCell(
160
+ cell_id=f"axis-trainsize-{ts}",
161
+ decoder_model=center.decoder_model,
162
+ encoder_name=center.encoder_name,
163
+ beam_width=center.beam_width,
164
+ constraint_timing=center.constraint_timing,
165
+ train_size=ts,
166
+ reseed_index=0,
167
+ ))
168
+
169
+ # Reseed all cells N=5 times for headline measurement
170
+ full = []
171
+ for cell in cells:
172
+ for r in range(self.reseed_n):
173
+ full.append(SweepCell(
174
+ cell_id=f"{cell.cell_id}-r{r}",
175
+ decoder_model=cell.decoder_model,
176
+ encoder_name=cell.encoder_name,
177
+ beam_width=cell.beam_width,
178
+ constraint_timing=cell.constraint_timing,
179
+ train_size=cell.train_size,
180
+ reseed_index=r,
181
+ ))
182
+ return full
183
+
184
+ def to_dict(self) -> dict:
185
+ return {
186
+ "decoder_models": list(self.decoder_models),
187
+ "encoder_names": [str(e) for e in self.encoder_names],
188
+ "beam_widths": list(self.beam_widths),
189
+ "constraint_timings": list(self.constraint_timings),
190
+ "train_sizes": list(self.train_sizes),
191
+ "reseed_n": self.reseed_n,
192
+ "fractional_factorial_pruned": self.fractional_factorial_pruned,
193
+ "pruned_target": self.pruned_target,
194
+ }
195
+
196
+
197
+ class TraitSweep:
198
+ """Sweep runner — emits one cell config per JSON file under
199
+ `output_dir/cells/`, ready to be claimed by parallel GPU agents.
200
+
201
+ Does NOT run training itself — that's the per-cell trainer's job
202
+ (a GPU agent picks up a cell config + runs trainer + emits
203
+ measurement JSON).
204
+
205
+ The split (sweep generates work units, trainer consumes them) lets
206
+ 30 GPUs run in parallel without coordination — each GPU claims one
207
+ or more cells from `output_dir/cells/`.
208
+ """
209
+
210
+ def __init__(self, config: SweepConfig | None = None):
211
+ self.config = config or SweepConfig()
212
+
213
+ def emit_cells(self, output_dir: str | Path) -> dict:
214
+ """Write one JSON file per cell to `output_dir/cells/`. Returns
215
+ summary dict."""
216
+ output_dir = Path(output_dir)
217
+ cells_dir = output_dir / "cells"
218
+ cells_dir.mkdir(parents=True, exist_ok=True)
219
+
220
+ cells = (
221
+ self.config.pruned_grid()
222
+ if self.config.fractional_factorial_pruned
223
+ else self.config.full_grid()
224
+ )
225
+
226
+ for cell in cells:
227
+ cell_path = cells_dir / f"{cell.cell_id}.json"
228
+ cell_path.write_text(
229
+ json.dumps(asdict(cell), indent=2, sort_keys=False),
230
+ encoding="utf-8",
231
+ )
232
+
233
+ summary = {
234
+ "sweep_config": self.config.to_dict(),
235
+ "cell_count": len(cells),
236
+ "cells_dir": str(cells_dir),
237
+ "emitted_at": datetime.now(timezone.utc).isoformat(),
238
+ }
239
+ (output_dir / "sweep_summary.json").write_text(
240
+ json.dumps(summary, indent=2), encoding="utf-8"
241
+ )
242
+ return summary
@@ -0,0 +1,237 @@
1
+ """Training loop for the constrained-decoding trait model — Paper 19.
2
+
3
+ Per `phase-1-spec.md` §3.3: HF Trainer fine-tune over (description,
4
+ trait JSON) pairs with cross-entropy loss + early stopping on
5
+ validation F1 macro.
6
+
7
+ Heavy deps imported lazily.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Any, Callable
16
+
17
+ from trait_inference.dataset import Pair, Split
18
+
19
+
20
+ @dataclass
21
+ class TrainConfig:
22
+ """Frozen-spec hyperparameters per `phase-1-spec.md` §3.3.
23
+
24
+ Sweep axes (per §3.3):
25
+ - model_name (Qwen-0.5B / Qwen-1B / Llama-3.2-1B)
26
+ - learning_rate (encoder vs decoder)
27
+ - batch_size, num_epochs (compute-budget bounded)
28
+
29
+ Frozen per `preregistration.md`:
30
+ - reseed N=5
31
+ - early_stopping_patience=3 on val F1 macro
32
+ - bootstrap_b=1000 on headline measurement (eval only)
33
+ """
34
+ model_name: str = "Qwen/Qwen2.5-0.5B"
35
+ label_space: tuple[str, ...] = ()
36
+ output_dir: str = "checkpoints/trait_decoder_v0"
37
+ num_epochs: int = 20
38
+ train_batch_size: int = 32
39
+ eval_batch_size: int = 32
40
+ learning_rate: float = 5e-5
41
+ warmup_steps: int = 500
42
+ weight_decay: float = 0.01
43
+ max_seq_len: int = 512
44
+ logging_steps: int = 50
45
+ eval_steps: int = 500
46
+ save_steps: int = 500
47
+ early_stopping_patience: int = 3
48
+ seed: int = 42
49
+ fp16: bool = True # mixed-precision on supported GPUs
50
+ grad_accumulation_steps: int = 1
51
+ prompt_template: str = (
52
+ "Given a description of a virtual object, return the minimal "
53
+ "set of HoloScript traits that apply, as a JSON array.\n"
54
+ "Description: {description}\n"
55
+ "Traits: {trait_json}"
56
+ )
57
+
58
+ def to_dict(self) -> dict:
59
+ return {
60
+ "model_name": self.model_name,
61
+ "label_space_size": len(self.label_space),
62
+ "output_dir": self.output_dir,
63
+ "num_epochs": self.num_epochs,
64
+ "train_batch_size": self.train_batch_size,
65
+ "eval_batch_size": self.eval_batch_size,
66
+ "learning_rate": self.learning_rate,
67
+ "warmup_steps": self.warmup_steps,
68
+ "weight_decay": self.weight_decay,
69
+ "max_seq_len": self.max_seq_len,
70
+ "logging_steps": self.logging_steps,
71
+ "eval_steps": self.eval_steps,
72
+ "save_steps": self.save_steps,
73
+ "early_stopping_patience": self.early_stopping_patience,
74
+ "seed": self.seed,
75
+ "fp16": self.fp16,
76
+ "grad_accumulation_steps": self.grad_accumulation_steps,
77
+ }
78
+
79
+
80
+ def _format_example(pair: Pair, prompt_template: str) -> dict[str, str]:
81
+ """Format a Pair into a {prompt, completion} dict for HF Trainer."""
82
+ trait_json = json.dumps(list(pair.trait_set), separators=(", ", ": "))
83
+ text = prompt_template.format(description=pair.description, trait_json=trait_json)
84
+ return {"text": text, "trait_set": list(pair.trait_set)}
85
+
86
+
87
+ class TraitTrainer:
88
+ """HF Trainer wrapper for the trait-decoder fine-tune.
89
+
90
+ Construction triggers heavy imports + tokenizer load. Train +
91
+ eval are compute-heavy and require GPU for non-trivial models.
92
+ """
93
+
94
+ def __init__(self, config: TrainConfig):
95
+ if not config.label_space:
96
+ raise ValueError("TrainConfig.label_space must be non-empty")
97
+ try:
98
+ import torch # noqa: F401
99
+ from transformers import AutoModelForCausalLM, AutoTokenizer
100
+ except ImportError as exc:
101
+ raise ImportError(
102
+ "TraitTrainer requires the [model] extra. "
103
+ "Install via: pip install -e '.[model]'"
104
+ ) from exc
105
+
106
+ self.config = config
107
+ self.tokenizer = AutoTokenizer.from_pretrained(config.model_name)
108
+ if self.tokenizer.pad_token is None:
109
+ self.tokenizer.pad_token = self.tokenizer.eos_token
110
+ self.model = AutoModelForCausalLM.from_pretrained(config.model_name)
111
+
112
+ def _build_dataset(self, pairs: list[Pair]) -> Any:
113
+ """Build a HF Dataset from Pairs."""
114
+ from datasets import Dataset
115
+
116
+ formatted = [_format_example(p, self.config.prompt_template) for p in pairs]
117
+ ds = Dataset.from_list(formatted)
118
+
119
+ def tokenize(batch: dict[str, list]) -> dict[str, list]:
120
+ tokens = self.tokenizer(
121
+ batch["text"],
122
+ truncation=True,
123
+ max_length=self.config.max_seq_len,
124
+ padding=False,
125
+ )
126
+ tokens["labels"] = tokens["input_ids"].copy()
127
+ return tokens
128
+
129
+ return ds.map(tokenize, batched=True, remove_columns=["text", "trait_set"])
130
+
131
+ def _build_eval_callback(
132
+ self, val_pairs: list[Pair], label_space: tuple[str, ...]
133
+ ) -> Callable[..., dict[str, float]]:
134
+ """Return a compute_metrics function for HF Trainer that runs the
135
+ decoder against val pairs and computes F1 macro."""
136
+ from trait_inference.metrics import f1_macro
137
+ from trait_inference.model.decoder import TraitDecoder, TraitDecoderConfig
138
+
139
+ def compute_metrics(eval_pred: Any) -> dict[str, float]:
140
+ # NOTE: eval_pred contains predictions from the trainer's
141
+ # default forward pass — but we want to evaluate via
142
+ # constrained decoding, which is a separate inference path.
143
+ # So we run the decoder ourselves here over val_pairs.
144
+ decoder_cfg = TraitDecoderConfig(
145
+ model_name=self.config.model_name,
146
+ label_space=label_space,
147
+ device="cuda" if self.config.fp16 else "cpu",
148
+ )
149
+ decoder = TraitDecoder(decoder_cfg)
150
+ descriptions = [p.description for p in val_pairs]
151
+ preds = decoder.predict_batch(descriptions)
152
+ gold = [list(p.trait_set) for p in val_pairs]
153
+ return {
154
+ "val_f1_macro": f1_macro(gold, preds, label_space=label_space),
155
+ }
156
+
157
+ return compute_metrics
158
+
159
+ def train(
160
+ self,
161
+ train_pairs: list[Pair],
162
+ val_pairs: list[Pair],
163
+ ) -> dict[str, Any]:
164
+ """Run the full training loop. Returns summary dict.
165
+
166
+ Saves checkpoint to `config.output_dir` + structured training
167
+ log to `output_dir/train_log.json`.
168
+ """
169
+ from transformers import (
170
+ DataCollatorForLanguageModeling,
171
+ EarlyStoppingCallback,
172
+ Trainer,
173
+ TrainingArguments,
174
+ )
175
+
176
+ train_ds = self._build_dataset(train_pairs)
177
+ val_ds = self._build_dataset(val_pairs)
178
+
179
+ training_args = TrainingArguments(
180
+ output_dir=self.config.output_dir,
181
+ num_train_epochs=self.config.num_epochs,
182
+ per_device_train_batch_size=self.config.train_batch_size,
183
+ per_device_eval_batch_size=self.config.eval_batch_size,
184
+ learning_rate=self.config.learning_rate,
185
+ warmup_steps=self.config.warmup_steps,
186
+ weight_decay=self.config.weight_decay,
187
+ logging_steps=self.config.logging_steps,
188
+ eval_strategy="steps",
189
+ eval_steps=self.config.eval_steps,
190
+ save_strategy="steps",
191
+ save_steps=self.config.save_steps,
192
+ save_total_limit=2,
193
+ load_best_model_at_end=True,
194
+ metric_for_best_model="val_f1_macro",
195
+ greater_is_better=True,
196
+ fp16=self.config.fp16,
197
+ seed=self.config.seed,
198
+ gradient_accumulation_steps=self.config.grad_accumulation_steps,
199
+ report_to="none",
200
+ )
201
+
202
+ data_collator = DataCollatorForLanguageModeling(
203
+ tokenizer=self.tokenizer, mlm=False
204
+ )
205
+
206
+ trainer = Trainer(
207
+ model=self.model,
208
+ args=training_args,
209
+ train_dataset=train_ds,
210
+ eval_dataset=val_ds,
211
+ data_collator=data_collator,
212
+ compute_metrics=self._build_eval_callback(val_pairs, self.config.label_space),
213
+ callbacks=[EarlyStoppingCallback(
214
+ early_stopping_patience=self.config.early_stopping_patience
215
+ )],
216
+ )
217
+
218
+ train_output = trainer.train()
219
+ eval_metrics = trainer.evaluate()
220
+
221
+ # Persist structured training log + config
222
+ log_path = Path(self.config.output_dir) / "train_log.json"
223
+ log_path.parent.mkdir(parents=True, exist_ok=True)
224
+ log_path.write_text(json.dumps({
225
+ "config": self.config.to_dict(),
226
+ "train_runtime_seconds": train_output.metrics.get("train_runtime"),
227
+ "train_loss": train_output.metrics.get("train_loss"),
228
+ "best_val_f1_macro": eval_metrics.get("eval_val_f1_macro"),
229
+ "completed_at": datetime.now(timezone.utc).isoformat(),
230
+ }, indent=2), encoding="utf-8")
231
+
232
+ return {
233
+ "train_runtime_seconds": train_output.metrics.get("train_runtime"),
234
+ "train_loss": train_output.metrics.get("train_loss"),
235
+ "best_val_f1_macro": eval_metrics.get("eval_val_f1_macro"),
236
+ "checkpoint_dir": self.config.output_dir,
237
+ }