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.
- holoscript_trait_inference-0.1.0.dist-info/METADATA +193 -0
- holoscript_trait_inference-0.1.0.dist-info/RECORD +16 -0
- holoscript_trait_inference-0.1.0.dist-info/WHEEL +5 -0
- holoscript_trait_inference-0.1.0.dist-info/entry_points.txt +2 -0
- holoscript_trait_inference-0.1.0.dist-info/top_level.txt +1 -0
- trait_inference/__init__.py +27 -0
- trait_inference/baselines.py +238 -0
- trait_inference/cli.py +498 -0
- trait_inference/dataset.py +348 -0
- trait_inference/eval/__init__.py +21 -0
- trait_inference/eval/ablations.py +336 -0
- trait_inference/metrics.py +291 -0
- trait_inference/model/__init__.py +34 -0
- trait_inference/model/decoder.py +192 -0
- trait_inference/model/sweep.py +242 -0
- trait_inference/model/trainer.py +237 -0
|
@@ -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
|
+
}
|