danielfein commited on
Commit
a4019dd
·
verified ·
1 Parent(s): 13e33b3

Add training support package

Browse files
detection_tokens/__init__.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Core package for training and using detection-token checkpoints."""
2
+
3
+ from .config import PipelineConfig
4
+ from .pipeline import (
5
+ prepare_data,
6
+ run_dataset_binary_evaluation,
7
+ run_holdout_evaluation,
8
+ run_training,
9
+ run_training_pipeline,
10
+ score_single_text,
11
+ )
12
+
13
+ __all__ = [
14
+ "PipelineConfig",
15
+ "prepare_data",
16
+ "run_dataset_binary_evaluation",
17
+ "run_holdout_evaluation",
18
+ "run_training",
19
+ "run_training_pipeline",
20
+ "score_single_text",
21
+ ]
detection_tokens/checkpoints.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ import torch
7
+
8
+
9
+ @dataclass(slots=True)
10
+ class TokenCheckpoint:
11
+ new_token: str
12
+ token_id: int | None
13
+ embedding: torch.Tensor
14
+ loss_history: list[float]
15
+ secondary_embeddings: list[torch.Tensor] | None = None
16
+
17
+
18
+ def save_token_checkpoint(
19
+ *,
20
+ token: str,
21
+ token_id: int,
22
+ embedding: torch.Tensor,
23
+ loss_history: list[float],
24
+ path: Path,
25
+ secondary_embeddings: list[torch.Tensor] | None = None,
26
+ ) -> None:
27
+ path.parent.mkdir(parents=True, exist_ok=True)
28
+ torch.save(
29
+ {
30
+ "new_token": token,
31
+ "token_id": token_id,
32
+ "embedding": embedding.detach().cpu(),
33
+ "loss_history": list(loss_history),
34
+ "secondary_embeddings": (
35
+ [row.detach().cpu() for row in secondary_embeddings]
36
+ if secondary_embeddings
37
+ else None
38
+ ),
39
+ },
40
+ path,
41
+ )
42
+
43
+
44
+ def load_token_checkpoint(path: Path) -> TokenCheckpoint:
45
+ payload = torch.load(path, map_location="cpu", weights_only=True)
46
+ token = payload.get("new_token", payload.get("token"))
47
+ if token is None:
48
+ raise KeyError(f"Checkpoint {path} has no token metadata")
49
+ return TokenCheckpoint(
50
+ new_token=str(token),
51
+ token_id=payload.get("token_id"),
52
+ embedding=payload["embedding"].detach().cpu(),
53
+ loss_history=list(payload.get("loss_history", [])),
54
+ secondary_embeddings=[
55
+ row.detach().cpu()
56
+ for row in (payload.get("secondary_embeddings") or [])
57
+ ] or None,
58
+ )
detection_tokens/config.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+
6
+
7
+ def model_slug(model_name: str) -> str:
8
+ return model_name.replace("/", "__")
9
+
10
+
11
+ @dataclass(slots=True)
12
+ class PangramBinaryConfig:
13
+ enabled: bool = True
14
+ dataset_name: str = "pangram/editlens_iclr"
15
+ dataset_split: str = "train"
16
+ local_dataset_path: Path | None = Path("/home/ubuntu/data/pangram_editlens_iclr")
17
+ ai_text_types: tuple[str, ...] = ("ai_generated",)
18
+ human_text_types: tuple[str, ...] = ("human_written",)
19
+ train_pairs: int = 5_000
20
+
21
+
22
+ @dataclass(slots=True)
23
+ class RaidBinaryConfig:
24
+ enabled: bool = True
25
+ dataset_name: str = "liamdugan/raid"
26
+ dataset_split: str = "train"
27
+ human_model_name: str = "human"
28
+ require_attack_none: bool = True
29
+ train_pairs: int = 5_000
30
+ eval_holdout_pairs: int = 1_000
31
+
32
+
33
+ @dataclass(slots=True)
34
+ class DataConfig:
35
+ task_name: str = "binary_human_vs_ai"
36
+ training_holdout_pairs: int = 1_000
37
+ min_text_chars: int = 200
38
+ pangram: PangramBinaryConfig = field(default_factory=PangramBinaryConfig)
39
+ raid: RaidBinaryConfig = field(default_factory=RaidBinaryConfig)
40
+
41
+
42
+ @dataclass(slots=True)
43
+ class ModelConfig:
44
+ model_name: str = "meta-llama/Llama-3.1-8B-Instruct"
45
+ ai_token: str = "<ai>"
46
+ human_token: str = "<human>"
47
+ max_length: int = 384
48
+ prompt_template: str = "Write {token} text."
49
+
50
+
51
+ @dataclass(slots=True)
52
+ class TrainingConfig:
53
+ ai_learning_rate: float = 1.0e-4
54
+ human_learning_rate: float = 1.0e-4
55
+ batch_size: int = 16
56
+ train_steps: int | None = 625
57
+ beta: float = 0.1
58
+ apo_alpha: float = 1.0
59
+ warmup_steps: int = 20
60
+ min_learning_rate: float = 1.0e-6
61
+ ref_cache_batch_size: int = 16
62
+ eval_subset_size: int = 128
63
+ eval_every_steps: int = 50
64
+ neutral_word: str | None = None # if set, warm-start new token rows from this vocab token
65
+ source_balance_by_source: bool = False
66
+
67
+
68
+ @dataclass(slots=True)
69
+ class CheckpointInitConfig:
70
+ ai_token_path: Path | None = None
71
+ human_token_path: Path | None = None
72
+
73
+
74
+ @dataclass(slots=True)
75
+ class EvaluationConfig:
76
+ mode: str = "holdout"
77
+ saved_pairs_filename: str = "holdout_pairs.json"
78
+ binary_split: str = "test"
79
+ positive_text_types: tuple[str, ...] = ("ai_generated",)
80
+ negative_text_types: tuple[str, ...] = ("human_written",)
81
+ binary_output_path: Path | None = None
82
+
83
+
84
+ @dataclass(slots=True)
85
+ class ScoringConfig:
86
+ text: str = "Example text to score."
87
+ score_mode: str = "avg_margin"
88
+ token_sigmoid_tau: float = 1.0
89
+
90
+
91
+ @dataclass(slots=True)
92
+ class VerbalizationTokenSetConfig:
93
+ name: str
94
+ token_dir: Path
95
+
96
+
97
+ def default_verbalization_token_sets() -> tuple[VerbalizationTokenSetConfig, ...]:
98
+ root = Path("/lambda/nfs/daniel-dc/detectiontokens_outputs")
99
+ return (
100
+ VerbalizationTokenSetConfig(
101
+ name="raid_only",
102
+ token_dir=root / "raid_only_train_raid_holdout_eval" / "tokens",
103
+ ),
104
+ VerbalizationTokenSetConfig(
105
+ name="pangram_only",
106
+ token_dir=root / "pangram_1k_train_pangram_test_eval" / "tokens",
107
+ ),
108
+ )
109
+
110
+
111
+ @dataclass(slots=True)
112
+ class VerbalizationConfig:
113
+ output_dir: Path = Path("/lambda/nfs/daniel-dc/detectiontokens_outputs/verbalizations")
114
+ token_sets: tuple[VerbalizationTokenSetConfig, ...] = field(default_factory=default_verbalization_token_sets)
115
+ n_samples: int = 5
116
+ max_new_tokens: int = 128
117
+
118
+
119
+ @dataclass(slots=True)
120
+ class OutputConfig:
121
+ output_root: Path = Path("/lambda/nfs/daniel-dc/detectiontokens_outputs/pangram_raid_binary_llama31_8b")
122
+ repo_root: Path = Path("/lambda/nfs/daniel-dc/DetectionTokens")
123
+
124
+ @property
125
+ def splits_dir(self) -> Path:
126
+ return self.output_root / "splits"
127
+
128
+ @property
129
+ def tokens_dir(self) -> Path:
130
+ return self.output_root / "tokens"
131
+
132
+ @property
133
+ def model_tokens_root(self) -> Path:
134
+ return self.repo_root / "tokens"
135
+
136
+ def model_tokens_dir(self, model_name: str) -> Path:
137
+ return self.model_tokens_root / model_slug(model_name)
138
+
139
+ @property
140
+ def cache_dir(self) -> Path:
141
+ return self.output_root / "ref_cache"
142
+
143
+ @property
144
+ def evaluation_dir(self) -> Path:
145
+ return self.output_root / "evaluation"
146
+
147
+
148
+ @dataclass(slots=True)
149
+ class PipelineConfig:
150
+ seed: int = 42
151
+ data: DataConfig = field(default_factory=DataConfig)
152
+ model: ModelConfig = field(default_factory=ModelConfig)
153
+ training: TrainingConfig = field(default_factory=TrainingConfig)
154
+ init_checkpoints: CheckpointInitConfig = field(default_factory=CheckpointInitConfig)
155
+ evaluation: EvaluationConfig = field(default_factory=EvaluationConfig)
156
+ scoring: ScoringConfig = field(default_factory=ScoringConfig)
157
+ verbalization: VerbalizationConfig = field(default_factory=VerbalizationConfig)
158
+ output: OutputConfig = field(default_factory=OutputConfig)
detection_tokens/data.py ADDED
@@ -0,0 +1,300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import random
5
+ from collections import defaultdict
6
+ from dataclasses import asdict, dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from datasets import Dataset, load_dataset
11
+
12
+ from .config import DataConfig
13
+
14
+
15
+ @dataclass(slots=True)
16
+ class SourcePair:
17
+ pair_id: str
18
+ text_id: str
19
+ source_id: str
20
+ dataset_name: str
21
+ source: str
22
+ model: str
23
+ text_type: str
24
+ cosine_score: float | None
25
+ ai_text: str
26
+ human_text: str
27
+
28
+
29
+ @dataclass(slots=True)
30
+ class BinaryEvalRow:
31
+ row_id: str
32
+ text: str
33
+ label: int
34
+ text_type: str
35
+ model: str
36
+ source_id: str
37
+
38
+
39
+ def _valid_text(text: str, *, min_text_chars: int) -> bool:
40
+ return isinstance(text, str) and len(text.strip()) >= min_text_chars
41
+
42
+
43
+ def _normalize_text(text: Any) -> str:
44
+ return str(text or "").strip()
45
+
46
+
47
+ def _find_local_arrow_file(root: Path, split: str) -> Path:
48
+ direct_path = root / f"editlens_iclr-{split}.arrow"
49
+ if direct_path.exists():
50
+ return direct_path
51
+ matches = sorted(root.rglob(f"editlens_iclr-{split}.arrow"))
52
+ if not matches:
53
+ raise FileNotFoundError(f"Missing local dataset file for split={split!r} under {root}")
54
+ return matches[0]
55
+
56
+
57
+ def _pick_best_row(rows: list[dict[str, Any]], *, text_key: str) -> dict[str, Any]:
58
+ if not rows:
59
+ raise ValueError("Cannot pick from an empty row list.")
60
+ rows = sorted(
61
+ rows,
62
+ key=lambda row: (
63
+ _normalize_text(row.get("prompt")) == "",
64
+ _normalize_text(row.get("title")) == "",
65
+ _normalize_text(row.get(text_key)) == "",
66
+ ),
67
+ )
68
+ return rows[0]
69
+
70
+
71
+ def load_pangram_rows(config: DataConfig, *, split: str) -> Dataset:
72
+ if config.pangram.local_dataset_path is not None:
73
+ arrow_path = _find_local_arrow_file(config.pangram.local_dataset_path, split)
74
+ return Dataset.from_file(str(arrow_path))
75
+ return load_dataset(config.pangram.dataset_name, split=split)
76
+
77
+
78
+ def load_raid_rows(config: DataConfig, *, split: str) -> Dataset:
79
+ return load_dataset(config.raid.dataset_name, split=split)
80
+
81
+
82
+ def build_pangram_binary_pairs(config: DataConfig) -> list[SourcePair]:
83
+ rows = load_pangram_rows(config, split=config.pangram.dataset_split)
84
+ ai_rows = []
85
+ human_by_text_id: dict[str, list[dict[str, Any]]] = defaultdict(list)
86
+ human_by_source_id: dict[str, list[dict[str, Any]]] = defaultdict(list)
87
+
88
+ for row in rows:
89
+ text = _normalize_text(row.get("text"))
90
+ if not _valid_text(text, min_text_chars=config.min_text_chars):
91
+ continue
92
+ text_type = _normalize_text(row.get("text_type"))
93
+ if text_type in config.pangram.human_text_types:
94
+ text_id = _normalize_text(row.get("text_id"))
95
+ source_id = _normalize_text(row.get("source_id"))
96
+ if text_id:
97
+ human_by_text_id[text_id].append(dict(row))
98
+ if source_id:
99
+ human_by_source_id[source_id].append(dict(row))
100
+ elif text_type in config.pangram.ai_text_types:
101
+ ai_rows.append(dict(row))
102
+
103
+ pairs: list[SourcePair] = []
104
+ for row in ai_rows:
105
+ pair_source_id = _normalize_text(row.get("source_id"))
106
+ if not pair_source_id:
107
+ continue
108
+ candidates = human_by_text_id.get(pair_source_id)
109
+ if not candidates:
110
+ candidates = human_by_source_id.get(pair_source_id)
111
+ if not candidates:
112
+ continue
113
+ human_row = _pick_best_row(candidates, text_key="text")
114
+ pairs.append(
115
+ SourcePair(
116
+ pair_id=f"pangram::{pair_source_id}::{_normalize_text(row.get('text_id'))}",
117
+ text_id=_normalize_text(row.get("text_id")),
118
+ source_id=pair_source_id,
119
+ dataset_name="pangram",
120
+ source=_normalize_text(row.get("source")),
121
+ model=_normalize_text(row.get("model")),
122
+ text_type=_normalize_text(row.get("text_type")),
123
+ cosine_score=float(row["cosine_score"]) if row.get("cosine_score") is not None else None,
124
+ ai_text=_normalize_text(row.get("text")),
125
+ human_text=_normalize_text(human_row.get("text")),
126
+ )
127
+ )
128
+ return pairs
129
+
130
+
131
+ def build_raid_binary_pairs(config: DataConfig) -> list[SourcePair]:
132
+ rows = load_raid_rows(config, split=config.raid.dataset_split)
133
+ human_rows: list[dict[str, Any]] = []
134
+ ai_rows: list[dict[str, Any]] = []
135
+ for row in rows:
136
+ if config.raid.require_attack_none and _normalize_text(row.get("attack")) not in {"", "none"}:
137
+ continue
138
+ text = _normalize_text(row.get("generation"))
139
+ if not _valid_text(text, min_text_chars=config.min_text_chars):
140
+ continue
141
+ model = _normalize_text(row.get("model"))
142
+ if model == config.raid.human_model_name:
143
+ human_rows.append(dict(row))
144
+ else:
145
+ ai_rows.append(dict(row))
146
+
147
+ human_by_id: dict[str, list[dict[str, Any]]] = defaultdict(list)
148
+ human_by_source_id: dict[str, list[dict[str, Any]]] = defaultdict(list)
149
+ for row in human_rows:
150
+ row_id = _normalize_text(row.get("id"))
151
+ source_id = _normalize_text(row.get("source_id"))
152
+ if row_id:
153
+ human_by_id[row_id].append(row)
154
+ if source_id:
155
+ human_by_source_id[source_id].append(row)
156
+
157
+ pairs: list[SourcePair] = []
158
+ for row in ai_rows:
159
+ pair_source_id = _normalize_text(row.get("source_id"))
160
+ if not pair_source_id:
161
+ continue
162
+ candidates = human_by_id.get(pair_source_id)
163
+ if not candidates:
164
+ candidates = human_by_source_id.get(pair_source_id)
165
+ if not candidates:
166
+ continue
167
+ human_row = _pick_best_row(candidates, text_key="generation")
168
+ pairs.append(
169
+ SourcePair(
170
+ pair_id=f"raid::{pair_source_id}::{_normalize_text(row.get('model'))}::{_normalize_text(row.get('id'))}",
171
+ text_id=_normalize_text(row.get("id")),
172
+ source_id=pair_source_id,
173
+ dataset_name="raid",
174
+ source=_normalize_text(row.get("domain")),
175
+ model=_normalize_text(row.get("model")),
176
+ text_type="ai_generated",
177
+ cosine_score=None,
178
+ ai_text=_normalize_text(row.get("generation")),
179
+ human_text=_normalize_text(human_row.get("generation")),
180
+ )
181
+ )
182
+ return pairs
183
+
184
+
185
+ def _take_pairs(
186
+ pairs: list[SourcePair],
187
+ *,
188
+ take: int,
189
+ seed: int,
190
+ ) -> tuple[list[SourcePair], list[SourcePair]]:
191
+ rng = random.Random(seed)
192
+ shuffled = list(pairs)
193
+ rng.shuffle(shuffled)
194
+ if len(shuffled) < take:
195
+ raise ValueError(f"Need at least {take} pairs, found {len(shuffled)}.")
196
+ return shuffled[:take], shuffled[take:]
197
+
198
+
199
+ def build_training_and_eval_splits(
200
+ config: DataConfig,
201
+ *,
202
+ seed: int,
203
+ ) -> tuple[list[SourcePair], list[SourcePair], list[SourcePair], dict[str, int]]:
204
+ source_pools: dict[str, list[SourcePair]] = {}
205
+ if config.pangram.enabled:
206
+ source_pools["pangram"] = build_pangram_binary_pairs(config)
207
+ if config.raid.enabled:
208
+ source_pools["raid"] = build_raid_binary_pairs(config)
209
+
210
+ train_pairs: list[SourcePair] = []
211
+ holdout_candidates: list[SourcePair] = []
212
+ raid_eval_pairs: list[SourcePair] = []
213
+ metadata = {f"{name}_pairs_available": len(pairs) for name, pairs in source_pools.items()}
214
+
215
+ if config.raid.enabled:
216
+ raid_eval_pairs, remaining_raid = _take_pairs(
217
+ source_pools["raid"],
218
+ take=config.raid.eval_holdout_pairs,
219
+ seed=seed + 100,
220
+ )
221
+ source_pools["raid"] = remaining_raid
222
+
223
+ if config.pangram.enabled:
224
+ selected, remaining = _take_pairs(
225
+ source_pools["pangram"],
226
+ take=config.pangram.train_pairs,
227
+ seed=seed + 1,
228
+ )
229
+ train_pairs.extend(selected)
230
+ holdout_candidates.extend(remaining)
231
+
232
+ if config.raid.enabled:
233
+ selected, remaining = _take_pairs(
234
+ source_pools["raid"],
235
+ take=config.raid.train_pairs,
236
+ seed=seed + 2,
237
+ )
238
+ train_pairs.extend(selected)
239
+ holdout_candidates.extend(remaining)
240
+
241
+ holdout_pairs, _ = _take_pairs(
242
+ holdout_candidates,
243
+ take=config.training_holdout_pairs,
244
+ seed=seed + 3,
245
+ )
246
+ random.Random(seed + 4).shuffle(train_pairs)
247
+ random.Random(seed + 5).shuffle(holdout_pairs)
248
+ metadata.update(
249
+ {
250
+ "train_pairs_from_pangram": sum(pair.dataset_name == "pangram" for pair in train_pairs),
251
+ "train_pairs_from_raid": sum(pair.dataset_name == "raid" for pair in train_pairs),
252
+ "holdout_pairs_from_pangram": sum(pair.dataset_name == "pangram" for pair in holdout_pairs),
253
+ "holdout_pairs_from_raid": sum(pair.dataset_name == "raid" for pair in holdout_pairs),
254
+ "raid_eval_pairs_from_raid": len(raid_eval_pairs),
255
+ }
256
+ )
257
+ return train_pairs, holdout_pairs, raid_eval_pairs, metadata
258
+
259
+
260
+ def save_pairs(path: Path, pairs: list[SourcePair]) -> None:
261
+ path.parent.mkdir(parents=True, exist_ok=True)
262
+ path.write_text(json.dumps([asdict(pair) for pair in pairs], indent=2), encoding="utf-8")
263
+
264
+
265
+ def load_pairs(path: Path) -> list[SourcePair]:
266
+ rows = json.loads(path.read_text(encoding="utf-8"))
267
+ return [SourcePair(**row) for row in rows]
268
+
269
+
270
+ def load_binary_eval_rows(
271
+ config: DataConfig,
272
+ *,
273
+ split: str,
274
+ positive_text_types: set[str],
275
+ negative_text_types: set[str],
276
+ ) -> list[BinaryEvalRow]:
277
+ rows = load_pangram_rows(config, split=split)
278
+ payload: list[BinaryEvalRow] = []
279
+ for index, row in enumerate(rows):
280
+ text_type = str(row.get("text_type", "")).strip()
281
+ if text_type in positive_text_types:
282
+ label = 1
283
+ elif text_type in negative_text_types:
284
+ label = 0
285
+ else:
286
+ continue
287
+ text = str(row.get("text", "")).strip()
288
+ if not _valid_text(text, min_text_chars=config.min_text_chars):
289
+ continue
290
+ payload.append(
291
+ BinaryEvalRow(
292
+ row_id=str(row.get("text_id", index)),
293
+ text=text,
294
+ label=label,
295
+ text_type=text_type,
296
+ model=str(row.get("model", "")),
297
+ source_id=str(row.get("source_id", "")),
298
+ )
299
+ )
300
+ return payload
detection_tokens/modeling.py ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import os
5
+ import importlib.util
6
+ from dataclasses import dataclass
7
+
8
+ import torch
9
+ import torch.nn.functional as F
10
+ from huggingface_hub import login
11
+ from transformers import AutoConfig, AutoModelForCausalLM, AutoTokenizer
12
+
13
+ from .checkpoints import load_token_checkpoint
14
+ from .config import PipelineConfig
15
+
16
+
17
+ def _load_causal_lm(model_name: str, **kwargs):
18
+ """Load a causal LM, falling back to architecture-specific classes when
19
+ AutoModelForCausalLM doesn't recognize the model type."""
20
+ try:
21
+ return AutoModelForCausalLM.from_pretrained(model_name, **kwargs)
22
+ except (ValueError, ModuleNotFoundError):
23
+ config = AutoConfig.from_pretrained(model_name)
24
+ model_type = getattr(config, "model_type", "")
25
+ if model_type == "gemma3":
26
+ from transformers import Gemma3ForConditionalGeneration
27
+ return Gemma3ForConditionalGeneration.from_pretrained(model_name, **kwargs)
28
+ if model_type == "gemma4":
29
+ from transformers import Gemma4ForConditionalGeneration
30
+ return Gemma4ForConditionalGeneration.from_pretrained(model_name, **kwargs)
31
+ raise
32
+
33
+
34
+ def resolve_hf_token() -> str | None:
35
+ return os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_TOKEN")
36
+
37
+
38
+ def maybe_login_hf() -> None:
39
+ token = resolve_hf_token()
40
+ if token:
41
+ login(token=token, add_to_git_credential=False)
42
+
43
+
44
+ def resolve_dtype() -> torch.dtype:
45
+ return torch.bfloat16 if torch.cuda.is_available() else torch.float32
46
+
47
+
48
+ def has_accelerate() -> bool:
49
+ return importlib.util.find_spec("accelerate") is not None
50
+
51
+
52
+ @dataclass(slots=True)
53
+ class ModelBundle:
54
+ config: PipelineConfig
55
+ tokenizer: AutoTokenizer
56
+ model: AutoModelForCausalLM
57
+ initial_tokenizer_len: int
58
+
59
+
60
+ def initialize_model_bundle(config: PipelineConfig) -> ModelBundle:
61
+ maybe_login_hf()
62
+ hf_token = resolve_hf_token()
63
+ dtype = resolve_dtype()
64
+
65
+ tokenizer = AutoTokenizer.from_pretrained(config.model.model_name, use_fast=True, token=hf_token)
66
+ if tokenizer.pad_token is None:
67
+ tokenizer.pad_token = tokenizer.eos_token
68
+
69
+ initial_len = len(tokenizer)
70
+ tokenizer.add_special_tokens(
71
+ {"additional_special_tokens": [config.model.ai_token, config.model.human_token]}
72
+ )
73
+
74
+ load_kwargs = {
75
+ "token": hf_token,
76
+ "dtype": dtype,
77
+ }
78
+ if torch.cuda.is_available() and has_accelerate():
79
+ load_kwargs["device_map"] = "auto"
80
+ model = _load_causal_lm(config.model.model_name, **load_kwargs)
81
+ if torch.cuda.is_available() and not has_accelerate():
82
+ model = model.to("cuda")
83
+ elif not torch.cuda.is_available():
84
+ model = model.to("cpu")
85
+
86
+ try:
87
+ model.resize_token_embeddings(len(tokenizer), mean_resizing=False)
88
+ except TypeError:
89
+ model.resize_token_embeddings(len(tokenizer))
90
+
91
+ # Mean-fill Gemma 4's auxiliary per-layer token table. Some Transformers
92
+ # versions resize it internally, while others leave it at the old size.
93
+ for name, module in model.named_modules():
94
+ if isinstance(module, torch.nn.Embedding) and module is not model.get_input_embeddings():
95
+ if module.weight.shape[0] == initial_len:
96
+ mean_row = module.weight.data[:initial_len].mean(
97
+ dim=0, dtype=torch.float32
98
+ ).to(dtype=module.weight.dtype)
99
+ new_emb = torch.nn.Embedding(
100
+ len(tokenizer), module.weight.shape[1],
101
+ device=module.weight.device, dtype=module.weight.dtype,
102
+ )
103
+ new_emb.weight.data[:initial_len] = module.weight.data
104
+ new_emb.weight.data[initial_len:] = mean_row
105
+ if not torch.equal(
106
+ new_emb.weight.data[initial_len], mean_row
107
+ ):
108
+ raise RuntimeError(
109
+ f"Failed to mean-fill resized embedding {name}"
110
+ )
111
+ parent_name, attr_name = name.rsplit(".", 1)
112
+ parent = dict(model.named_modules())[parent_name]
113
+ setattr(parent, attr_name, new_emb)
114
+ print(
115
+ f"Mean-filled secondary embedding {name}: "
116
+ f"{initial_len} -> {len(tokenizer)}"
117
+ )
118
+ elif module.weight.shape[0] == len(tokenizer):
119
+ mean_row = module.weight.data[:initial_len].mean(
120
+ dim=0, dtype=torch.float32
121
+ ).to(dtype=module.weight.dtype)
122
+ module.weight.data[initial_len:] = mean_row
123
+ if not torch.equal(module.weight.data[initial_len], mean_row):
124
+ raise RuntimeError(
125
+ f"Failed to mean-fill expanded embedding {name}"
126
+ )
127
+ print(
128
+ f"Mean-filled expanded secondary embedding {name}: "
129
+ f"rows {initial_len}:{len(tokenizer)}"
130
+ )
131
+
132
+ model.config.use_cache = False
133
+ if hasattr(model, "gradient_checkpointing_enable"):
134
+ model.gradient_checkpointing_enable()
135
+ model.eval()
136
+
137
+ input_emb = model.get_input_embeddings()
138
+ with torch.no_grad():
139
+ mean_in = input_emb.weight[:initial_len].mean(dim=0)
140
+ for token in (config.model.ai_token, config.model.human_token):
141
+ token_id = tokenizer.convert_tokens_to_ids(token)
142
+ input_emb.weight[token_id].copy_(mean_in + torch.randn_like(mean_in) * 1e-5)
143
+
144
+ bundle = ModelBundle(
145
+ config=config,
146
+ tokenizer=tokenizer,
147
+ model=model,
148
+ initial_tokenizer_len=initial_len,
149
+ )
150
+ apply_initial_checkpoints(bundle)
151
+ return bundle
152
+
153
+
154
+ def apply_initial_checkpoints(bundle: ModelBundle) -> None:
155
+ input_emb = bundle.model.get_input_embeddings()
156
+ token_dir = bundle.config.output.model_tokens_dir(bundle.config.model.model_name)
157
+ ai_path = bundle.config.init_checkpoints.ai_token_path or token_dir / "ai_token.pt"
158
+ human_path = bundle.config.init_checkpoints.human_token_path or token_dir / "human_token.pt"
159
+
160
+ secondary_embs = [
161
+ module
162
+ for module in bundle.model.modules()
163
+ if (
164
+ isinstance(module, torch.nn.Embedding)
165
+ and module is not input_emb
166
+ and module.weight.shape[0] == len(bundle.tokenizer)
167
+ )
168
+ ]
169
+
170
+ def install(path, token):
171
+ if not path.exists():
172
+ return
173
+ checkpoint = load_token_checkpoint(path)
174
+ token_id = bundle.tokenizer.convert_tokens_to_ids(token)
175
+ saved_secondary = checkpoint.secondary_embeddings or []
176
+ if len(saved_secondary) != len(secondary_embs):
177
+ raise ValueError(
178
+ f"{path} has {len(saved_secondary)} secondary rows, but "
179
+ f"{bundle.config.model.model_name} exposes "
180
+ f"{len(secondary_embs)} secondary token embeddings."
181
+ )
182
+ input_emb.weight[token_id].copy_(
183
+ checkpoint.embedding.to(
184
+ input_emb.weight.device, dtype=input_emb.weight.dtype
185
+ )
186
+ )
187
+ for embedding, row in zip(secondary_embs, saved_secondary):
188
+ embedding.weight[token_id].copy_(
189
+ row.to(embedding.weight.device, dtype=embedding.weight.dtype)
190
+ )
191
+
192
+ with torch.no_grad():
193
+ install(ai_path, bundle.config.model.ai_token)
194
+ install(human_path, bundle.config.model.human_token)
195
+
196
+
197
+ def build_prompt(bundle: ModelBundle, token: str) -> str:
198
+ content = bundle.config.model.prompt_template.format(token=token)
199
+ messages = [{"role": "user", "content": content}]
200
+ return bundle.tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
201
+
202
+
203
+ def encode_response(bundle: ModelBundle, prompt_text: str, response_text: str) -> tuple[torch.Tensor, int]:
204
+ full_text = prompt_text + response_text
205
+ prompt_ids = bundle.tokenizer(prompt_text, return_tensors="pt", add_special_tokens=False)["input_ids"][0]
206
+ full_ids = bundle.tokenizer(
207
+ full_text,
208
+ return_tensors="pt",
209
+ add_special_tokens=False,
210
+ truncation=True,
211
+ max_length=bundle.config.model.max_length,
212
+ )["input_ids"][0]
213
+ return full_ids, int(len(prompt_ids))
214
+
215
+
216
+ def compute_average_logprob(bundle: ModelBundle, input_ids: torch.Tensor, prompt_len: int) -> torch.Tensor:
217
+ token_logps = compute_token_logprobs(bundle, input_ids, prompt_len)
218
+ return token_logps.mean()
219
+
220
+
221
+ def _model_forward(bundle: ModelBundle, input_ids: torch.Tensor, attention_mask: torch.Tensor | None = None):
222
+ """Run a forward pass, injecting token_type_ids for models that require it."""
223
+ fwd_kwargs: dict = {"input_ids": input_ids}
224
+ if attention_mask is not None:
225
+ fwd_kwargs["attention_mask"] = attention_mask
226
+ model_type = getattr(bundle.model.config, "model_type", "")
227
+ if model_type in ("gemma3", "gemma4"):
228
+ fwd_kwargs["token_type_ids"] = torch.zeros_like(input_ids)
229
+ return bundle.model(**fwd_kwargs)
230
+
231
+
232
+ def compute_token_logprobs(bundle: ModelBundle, input_ids: torch.Tensor, prompt_len: int) -> torch.Tensor:
233
+ input_ids = input_ids.unsqueeze(0).to(bundle.model.device)
234
+ logits = _model_forward(bundle, input_ids).logits[0]
235
+ shift_logits = logits[prompt_len - 1 : -1]
236
+ shift_labels = input_ids[0, prompt_len:]
237
+ log_probs = F.log_softmax(shift_logits, dim=-1)
238
+ return log_probs[torch.arange(len(shift_labels), device=bundle.model.device), shift_labels]
239
+
240
+
241
+ def compute_sequence_logprob(bundle: ModelBundle, input_ids: torch.Tensor, prompt_len: int) -> torch.Tensor:
242
+ input_ids = input_ids.unsqueeze(0).to(bundle.model.device)
243
+ logits = _model_forward(bundle, input_ids).logits[0]
244
+ shift_logits = logits[prompt_len - 1 : -1]
245
+ shift_labels = input_ids[0, prompt_len:]
246
+ log_probs = F.log_softmax(shift_logits, dim=-1)
247
+ token_logps = log_probs[torch.arange(len(shift_labels), device=bundle.model.device), shift_labels]
248
+ return token_logps.sum()
249
+
250
+
251
+ def cosine_with_floor(
252
+ step: int,
253
+ total_steps: int,
254
+ base_lr: float,
255
+ *,
256
+ min_lr: float,
257
+ warmup_steps: int,
258
+ ) -> float:
259
+ if step < warmup_steps:
260
+ return base_lr * float(step + 1) / float(max(1, warmup_steps))
261
+ progress = float(step - warmup_steps) / float(max(1, total_steps - warmup_steps))
262
+ cosine = 0.5 * (1.0 + math.cos(math.pi * progress))
263
+ return min_lr + (base_lr - min_lr) * cosine
detection_tokens/pipeline.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import random
5
+ from dataclasses import asdict
6
+
7
+ import numpy as np
8
+ import torch
9
+
10
+ from .checkpoints import save_token_checkpoint
11
+ from .config import PipelineConfig
12
+ from .data import build_training_and_eval_splits, load_binary_eval_rows, load_pairs, save_pairs
13
+ from .modeling import initialize_model_bundle
14
+ from .scoring import dual_score, evaluate_binary_rows, evaluate_holdout
15
+ from .training import train_single_token
16
+ from .verbalization import save_verbalizations, verbalize_token_set
17
+
18
+
19
+ def set_seed(seed: int) -> None:
20
+ random.seed(seed)
21
+ np.random.seed(seed)
22
+ torch.manual_seed(seed)
23
+
24
+
25
+ def prepare_data(config: PipelineConfig) -> dict:
26
+ set_seed(config.seed)
27
+ config.output.output_root.mkdir(parents=True, exist_ok=True)
28
+ train_pairs, holdout_pairs, raid_eval_pairs, metadata = build_training_and_eval_splits(
29
+ config.data,
30
+ seed=config.seed,
31
+ )
32
+ save_pairs(config.output.splits_dir / "train_pairs.json", train_pairs)
33
+ save_pairs(config.output.splits_dir / "holdout_pairs.json", holdout_pairs)
34
+ save_pairs(config.output.splits_dir / "raid_holdout_pairs.json", raid_eval_pairs)
35
+ summary = {
36
+ "task_name": config.data.task_name,
37
+ "train_pairs": len(train_pairs),
38
+ "holdout_pairs": len(holdout_pairs),
39
+ "raid_holdout_pairs": len(raid_eval_pairs),
40
+ **metadata,
41
+ }
42
+ (config.output.output_root / "split_summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
43
+ return summary
44
+
45
+
46
+ def _load_saved_splits(config: PipelineConfig) -> tuple[list, list]:
47
+ train_pairs = load_pairs(config.output.splits_dir / "train_pairs.json")
48
+ holdout_pairs = load_pairs(config.output.splits_dir / "holdout_pairs.json")
49
+ return train_pairs, holdout_pairs
50
+
51
+
52
+ def run_training(config: PipelineConfig) -> dict:
53
+ set_seed(config.seed)
54
+ train_pairs, holdout_pairs = _load_saved_splits(config)
55
+ bundle = initialize_model_bundle(config)
56
+
57
+ ai_artifacts, ai_ref_chosen, ai_ref_rejected = train_single_token(
58
+ bundle,
59
+ train_pairs,
60
+ holdout_pairs,
61
+ token=config.model.ai_token,
62
+ learning_rate=config.training.ai_learning_rate,
63
+ chosen_key="ai_text",
64
+ rejected_key="human_text",
65
+ config=config.training,
66
+ cache_dir=config.output.cache_dir,
67
+ seed=config.seed,
68
+ )
69
+ save_token_checkpoint(
70
+ token=config.model.ai_token,
71
+ token_id=bundle.tokenizer.convert_tokens_to_ids(config.model.ai_token),
72
+ embedding=ai_artifacts.embedding,
73
+ loss_history=ai_artifacts.loss_history,
74
+ path=config.output.tokens_dir / "ai_token.pt",
75
+ )
76
+
77
+ # Reuse the <ai> ref cache for <human> (chosen/rejected are swapped).
78
+ human_artifacts, _, _ = train_single_token(
79
+ bundle,
80
+ train_pairs,
81
+ holdout_pairs,
82
+ token=config.model.human_token,
83
+ learning_rate=config.training.human_learning_rate,
84
+ chosen_key="human_text",
85
+ rejected_key="ai_text",
86
+ config=config.training,
87
+ cache_dir=config.output.cache_dir,
88
+ seed=config.seed,
89
+ precomputed_ref_chosen=ai_ref_rejected,
90
+ precomputed_ref_rejected=ai_ref_chosen,
91
+ )
92
+ save_token_checkpoint(
93
+ token=config.model.human_token,
94
+ token_id=bundle.tokenizer.convert_tokens_to_ids(config.model.human_token),
95
+ embedding=human_artifacts.embedding,
96
+ loss_history=human_artifacts.loss_history,
97
+ path=config.output.tokens_dir / "human_token.pt",
98
+ )
99
+
100
+ summary = {
101
+ "train_pairs": len(train_pairs),
102
+ "holdout_pairs": len(holdout_pairs),
103
+ "ai_best_val_accuracy": ai_artifacts.best_val_accuracy,
104
+ "ai_final_val_accuracy": ai_artifacts.final_val_accuracy,
105
+ "human_best_val_accuracy": human_artifacts.best_val_accuracy,
106
+ "human_final_val_accuracy": human_artifacts.final_val_accuracy,
107
+ }
108
+ (config.output.output_root / "train_summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
109
+ return summary
110
+
111
+
112
+ def run_holdout_evaluation(config: PipelineConfig) -> dict:
113
+ set_seed(config.seed)
114
+ eval_path = config.output.splits_dir / config.evaluation.saved_pairs_filename
115
+ holdout_pairs = load_pairs(eval_path)
116
+ bundle = initialize_model_bundle(config)
117
+ return evaluate_holdout(bundle, holdout_pairs, config.output.evaluation_dir)
118
+
119
+
120
+ def run_training_pipeline(config: PipelineConfig) -> dict:
121
+ split_summary = prepare_data(config)
122
+ train_summary = run_training(config)
123
+ evaluation = run_holdout_evaluation(config)
124
+ summary = {
125
+ "seed": config.seed,
126
+ "model_name": config.model.model_name,
127
+ "split_summary": split_summary,
128
+ "train_summary": train_summary,
129
+ "evaluation": evaluation,
130
+ "config": asdict(config),
131
+ }
132
+ (config.output.output_root / "pipeline_summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
133
+ return summary
134
+
135
+
136
+ def score_single_text(config: PipelineConfig, text: str) -> dict:
137
+ set_seed(config.seed)
138
+ bundle = initialize_model_bundle(config)
139
+ score, ai_logp, human_logp = dual_score(bundle, text)
140
+ return {
141
+ "text": text,
142
+ "score": score,
143
+ "ai_avg_logp": ai_logp,
144
+ "human_avg_logp": human_logp,
145
+ }
146
+
147
+
148
+ def run_dataset_binary_evaluation(
149
+ config: PipelineConfig,
150
+ *,
151
+ split: str,
152
+ positive_text_types: set[str],
153
+ negative_text_types: set[str],
154
+ output_path=None,
155
+ ) -> dict:
156
+ set_seed(config.seed)
157
+ bundle = initialize_model_bundle(config)
158
+ rows = load_binary_eval_rows(
159
+ config.data,
160
+ split=split,
161
+ positive_text_types=positive_text_types,
162
+ negative_text_types=negative_text_types,
163
+ )
164
+ return evaluate_binary_rows(bundle, rows, output_path=output_path)
165
+
166
+
167
+ def run_verbalizations(config: PipelineConfig) -> list[dict]:
168
+ set_seed(config.seed)
169
+ bundle = initialize_model_bundle(config)
170
+ results = [verbalize_token_set(bundle, token_set, config) for token_set in config.verbalization.token_sets]
171
+ save_verbalizations(config.verbalization.output_dir, results)
172
+ return results
detection_tokens/scoring.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import asdict, dataclass
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+ import torch
9
+
10
+ from .data import BinaryEvalRow, SourcePair
11
+ from .modeling import ModelBundle, build_prompt, compute_average_logprob, compute_token_logprobs, encode_response
12
+
13
+
14
+ @dataclass(slots=True)
15
+ class ScoredText:
16
+ text: str
17
+ label: int
18
+ pair_id: str
19
+ score: float
20
+ ai_avg_logp: float
21
+ human_avg_logp: float
22
+
23
+
24
+ def compute_auc(labels: list[int], scores: list[float]) -> float:
25
+ order = np.argsort(scores)
26
+ ranks = np.empty(len(scores), dtype=np.float64)
27
+ ranks[order] = np.arange(1, len(scores) + 1)
28
+ pos_count = sum(labels)
29
+ neg_count = len(labels) - pos_count
30
+ if pos_count == 0 or neg_count == 0:
31
+ return 0.5
32
+ pos_rank_sum = float(sum(rank for rank, label in zip(ranks, labels) if label == 1))
33
+ return (pos_rank_sum - pos_count * (pos_count + 1) / 2.0) / (pos_count * neg_count)
34
+
35
+
36
+ def dual_score(bundle: ModelBundle, text: str) -> tuple[float, float, float]:
37
+ ai_prompt = build_prompt(bundle, bundle.config.model.ai_token)
38
+ human_prompt = build_prompt(bundle, bundle.config.model.human_token)
39
+ ai_ids, ai_prompt_len = encode_response(bundle, ai_prompt, text)
40
+ human_ids, human_prompt_len = encode_response(bundle, human_prompt, text)
41
+ ai_logp = float(compute_average_logprob(bundle, ai_ids, ai_prompt_len).item())
42
+ human_logp = float(compute_average_logprob(bundle, human_ids, human_prompt_len).item())
43
+ score_mode = bundle.config.scoring.score_mode
44
+ if score_mode == "avg_margin":
45
+ score = ai_logp - human_logp
46
+ elif score_mode == "soft_token_sigmoid":
47
+ ai_token_logps = compute_token_logprobs(bundle, ai_ids, ai_prompt_len)
48
+ human_token_logps = compute_token_logprobs(bundle, human_ids, human_prompt_len)
49
+ if ai_token_logps.shape[0] != human_token_logps.shape[0]:
50
+ token_count = min(ai_token_logps.shape[0], human_token_logps.shape[0])
51
+ ai_token_logps = ai_token_logps[:token_count]
52
+ human_token_logps = human_token_logps[:token_count]
53
+ margins = ai_token_logps - human_token_logps
54
+ tau = max(bundle.config.scoring.token_sigmoid_tau, 1.0e-6)
55
+ score = float(torch.sigmoid(margins / tau).mean().item())
56
+ else:
57
+ raise ValueError(f"Unsupported scoring mode: {score_mode}")
58
+ return score, ai_logp, human_logp
59
+
60
+
61
+ def decision_threshold(bundle: ModelBundle) -> float:
62
+ if bundle.config.scoring.score_mode == "soft_token_sigmoid":
63
+ return 0.5
64
+ return 0.0
65
+
66
+
67
+ def evaluate_holdout(bundle: ModelBundle, holdout_pairs: list[SourcePair], output_dir: Path) -> dict:
68
+ threshold = decision_threshold(bundle)
69
+ rows: list[ScoredText] = []
70
+ for pair in holdout_pairs:
71
+ for label, text in ((1, pair.ai_text), (0, pair.human_text)):
72
+ score, ai_logp, human_logp = dual_score(bundle, text)
73
+ rows.append(
74
+ ScoredText(
75
+ text=text,
76
+ label=label,
77
+ pair_id=pair.pair_id,
78
+ score=score,
79
+ ai_avg_logp=ai_logp,
80
+ human_avg_logp=human_logp,
81
+ )
82
+ )
83
+
84
+ labels = [row.label for row in rows]
85
+ scores = [row.score for row in rows]
86
+ tp = sum(int(row.label == 1 and row.score > threshold) for row in rows)
87
+ fp = sum(int(row.label == 0 and row.score > threshold) for row in rows)
88
+ tn = sum(int(row.label == 0 and row.score <= threshold) for row in rows)
89
+ fn = sum(int(row.label == 1 and row.score <= threshold) for row in rows)
90
+ precision = tp / max(1, tp + fp)
91
+ recall = tp / max(1, tp + fn)
92
+ f1 = 0.0 if precision + recall == 0.0 else 2.0 * precision * recall / (precision + recall)
93
+
94
+ pairwise_wins = 0
95
+ by_pair: dict[str, dict[int, ScoredText]] = {}
96
+ for row in rows:
97
+ by_pair.setdefault(row.pair_id, {})[row.label] = row
98
+ for item in by_pair.values():
99
+ pairwise_wins += int(item[1].score > item[0].score)
100
+
101
+ summary = {
102
+ "num_holdout_texts": len(rows),
103
+ "score_mode": bundle.config.scoring.score_mode,
104
+ "decision_threshold": threshold,
105
+ "auroc": compute_auc(labels, scores),
106
+ "accuracy": (tp + tn) / len(rows),
107
+ "pairwise_rate": pairwise_wins / len(holdout_pairs),
108
+ "mean_score_ai": float(np.mean([row.score for row in rows if row.label == 1])),
109
+ "mean_score_human": float(np.mean([row.score for row in rows if row.label == 0])),
110
+ "tp": tp,
111
+ "fp": fp,
112
+ "tn": tn,
113
+ "fn": fn,
114
+ "precision": precision,
115
+ "recall": recall,
116
+ "f1": f1,
117
+ }
118
+ output_dir.mkdir(parents=True, exist_ok=True)
119
+ (output_dir / "dual_eval_summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
120
+ (output_dir / "dual_eval_details.json").write_text(
121
+ json.dumps([asdict(row) for row in rows], indent=2),
122
+ encoding="utf-8",
123
+ )
124
+ return summary
125
+
126
+
127
+ def evaluate_binary_rows(
128
+ bundle: ModelBundle,
129
+ rows: list[BinaryEvalRow],
130
+ output_path: Path | None = None,
131
+ ) -> dict:
132
+ threshold = decision_threshold(bundle)
133
+ scores: list[float] = []
134
+ labels: list[int] = []
135
+ details = []
136
+ for row in rows:
137
+ score, ai_logp, human_logp = dual_score(bundle, row.text)
138
+ scores.append(score)
139
+ labels.append(row.label)
140
+ details.append(
141
+ {
142
+ "row_id": row.row_id,
143
+ "label": row.label,
144
+ "text_type": row.text_type,
145
+ "model": row.model,
146
+ "source_id": row.source_id,
147
+ "score": score,
148
+ "ai_avg_logp": ai_logp,
149
+ "human_avg_logp": human_logp,
150
+ }
151
+ )
152
+
153
+ labels_array = np.asarray(labels, dtype=np.int64)
154
+ scores_array = np.asarray(scores, dtype=np.float64)
155
+ predictions = (scores_array > threshold).astype(np.int64)
156
+
157
+ tp = int(((labels_array == 1) & (predictions == 1)).sum())
158
+ fp = int(((labels_array == 0) & (predictions == 1)).sum())
159
+ tn = int(((labels_array == 0) & (predictions == 0)).sum())
160
+ fn = int(((labels_array == 1) & (predictions == 0)).sum())
161
+ precision = tp / max(1, tp + fp)
162
+ recall = tp / max(1, tp + fn)
163
+ f1 = 0.0 if precision + recall == 0.0 else 2.0 * precision * recall / (precision + recall)
164
+
165
+ summary = {
166
+ "num_rows": len(rows),
167
+ "score_mode": bundle.config.scoring.score_mode,
168
+ "decision_threshold": threshold,
169
+ "positive_rows": int(labels_array.sum()),
170
+ "negative_rows": int((labels_array == 0).sum()),
171
+ "auroc": compute_auc(labels, scores),
172
+ "accuracy_at_zero": float((predictions == labels_array).mean()),
173
+ "tp": tp,
174
+ "fp": fp,
175
+ "tn": tn,
176
+ "fn": fn,
177
+ "precision": precision,
178
+ "recall": recall,
179
+ "f1": f1,
180
+ "mean_score_positive": float(scores_array[labels_array == 1].mean()),
181
+ "mean_score_negative": float(scores_array[labels_array == 0].mean()),
182
+ }
183
+ if output_path is not None:
184
+ output_path.parent.mkdir(parents=True, exist_ok=True)
185
+ output_path.write_text(
186
+ json.dumps({"summary": summary, "details": details}, indent=2),
187
+ encoding="utf-8",
188
+ )
189
+ return summary
detection_tokens/training.py ADDED
@@ -0,0 +1,430 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import gc
4
+ import hashlib
5
+ import random
6
+ from collections import Counter
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ import torch
11
+ import torch.nn.functional as F
12
+ from tqdm.auto import tqdm
13
+
14
+ from .config import TrainingConfig
15
+ from .data import SourcePair
16
+ from .modeling import (
17
+ ModelBundle,
18
+ _model_forward,
19
+ build_prompt,
20
+ cosine_with_floor,
21
+ encode_response,
22
+ )
23
+
24
+
25
+ @dataclass(slots=True)
26
+ class TrainedTokenArtifacts:
27
+ embedding: torch.Tensor
28
+ loss_history: list[float]
29
+ best_val_accuracy: float
30
+ final_val_accuracy: float
31
+ secondary_embeddings: list[torch.Tensor] = None # per-layer embeddings (e.g. Gemma 4)
32
+
33
+
34
+ def _ref_cache_key(model_name: str, prompt: str, pair_ids: list[str], key: str) -> str:
35
+ h = hashlib.sha256()
36
+ h.update(model_name.encode())
37
+ h.update(prompt.encode())
38
+ h.update(key.encode())
39
+ for pair_id in pair_ids:
40
+ h.update(pair_id.encode())
41
+ return h.hexdigest()[:16]
42
+
43
+
44
+ def _source_balance_key(pair: SourcePair) -> str:
45
+ source_id = str(pair.source_id or "").strip()
46
+ if source_id:
47
+ return source_id
48
+ human_text = str(pair.human_text or "").strip()
49
+ if human_text:
50
+ digest = hashlib.sha256(human_text.encode("utf-8")).hexdigest()[:16]
51
+ return f"human_text::{digest}"
52
+ return f"pair::{pair.pair_id}"
53
+
54
+
55
+ def _source_balance_weights(train_pairs: list[SourcePair]) -> torch.Tensor:
56
+ keys = [_source_balance_key(pair) for pair in train_pairs]
57
+ counts = Counter(keys)
58
+ if not counts:
59
+ return torch.ones(len(train_pairs), dtype=torch.float32)
60
+ scale = len(train_pairs) / len(counts)
61
+ weights = torch.tensor([scale / counts[key] for key in keys], dtype=torch.float32)
62
+ print(
63
+ "[source-balance] "
64
+ f"pairs={len(train_pairs)} sources={len(counts)} "
65
+ f"mean_weight={weights.mean().item():.4f} "
66
+ f"min_weight={weights.min().item():.4f} "
67
+ f"max_weight={weights.max().item():.4f}",
68
+ flush=True,
69
+ )
70
+ return weights
71
+
72
+
73
+ def _pack_encoded_batch(
74
+ bundle: ModelBundle,
75
+ encoded_list: list[tuple[torch.Tensor, int]],
76
+ ) -> tuple[torch.Tensor, torch.Tensor, list[int]]:
77
+ pad_id = bundle.tokenizer.pad_token_id
78
+ device = bundle.model.device
79
+ max_len = max(ids.shape[-1] for ids, _ in encoded_list)
80
+ input_ids = torch.full((len(encoded_list), max_len), pad_id, device=device)
81
+ attention_mask = torch.zeros((len(encoded_list), max_len), dtype=torch.long, device=device)
82
+ prompt_lens: list[int] = []
83
+ for batch_index, (ids, prompt_len) in enumerate(encoded_list):
84
+ seq_len = ids.shape[-1]
85
+ offset = max_len - seq_len
86
+ input_ids[batch_index, offset:] = ids.squeeze().to(device)
87
+ attention_mask[batch_index, offset:] = 1
88
+ prompt_lens.append(prompt_len + offset)
89
+ return input_ids, attention_mask, prompt_lens
90
+
91
+
92
+ def batch_sequence_logprobs(
93
+ bundle: ModelBundle,
94
+ encoded_list: list[tuple[torch.Tensor, int]],
95
+ ) -> torch.Tensor:
96
+ input_ids, attention_mask, prompt_lens = _pack_encoded_batch(bundle, encoded_list)
97
+ logits = _model_forward(bundle, input_ids, attention_mask).logits
98
+ results: list[torch.Tensor] = []
99
+ for batch_index, prompt_len in enumerate(prompt_lens):
100
+ row_logits = logits[batch_index, prompt_len - 1 : -1]
101
+ targets = input_ids[batch_index, prompt_len:]
102
+ # Accumulate response likelihoods in fp32. Summing in bf16 visibly
103
+ # quantizes long-sequence scores and destroys fine-grained rankings.
104
+ row_log_probs = F.log_softmax(row_logits.float(), dim=-1)
105
+ token_logps = row_log_probs.gather(-1, targets.unsqueeze(-1)).squeeze(-1)
106
+ results.append(token_logps.sum())
107
+ return torch.stack(results)
108
+
109
+
110
+ def batched_sequence_logprobs(
111
+ bundle: ModelBundle,
112
+ encoded_list: list[tuple[torch.Tensor, int]],
113
+ *,
114
+ batch_size: int,
115
+ desc: str,
116
+ checkpoint_path: Path | None = None,
117
+ checkpoint_every: int = 1000,
118
+ ) -> torch.Tensor:
119
+ n = len(encoded_list)
120
+ total_batches = (n + batch_size - 1) // batch_size
121
+ results = torch.zeros(n)
122
+ start_batch = 0
123
+
124
+ if checkpoint_path is not None and checkpoint_path.exists():
125
+ ckpt = torch.load(checkpoint_path, map_location="cpu", weights_only=True)
126
+ if ckpt["n"] == n:
127
+ results = ckpt["results"]
128
+ start_batch = ckpt["next_batch"]
129
+ if start_batch >= total_batches:
130
+ return results
131
+
132
+ for batch_idx in tqdm(range(start_batch, total_batches), desc=desc, initial=start_batch, total=total_batches):
133
+ start = batch_idx * batch_size
134
+ batch = encoded_list[start : start + batch_size]
135
+ results[start : start + len(batch)] = batch_sequence_logprobs(bundle, batch).detach().cpu()
136
+ if torch.cuda.is_available():
137
+ torch.cuda.empty_cache()
138
+ if checkpoint_path is not None and (batch_idx + 1) % checkpoint_every == 0:
139
+ torch.save({"n": n, "results": results, "next_batch": batch_idx + 1}, checkpoint_path)
140
+
141
+ return results
142
+
143
+
144
+ def _load_or_compute_ref(
145
+ bundle: ModelBundle,
146
+ encoded_list: list[tuple[torch.Tensor, int]],
147
+ *,
148
+ batch_size: int,
149
+ desc: str,
150
+ cache_dir: Path,
151
+ cache_key: str,
152
+ ) -> torch.Tensor:
153
+ cache_dir.mkdir(parents=True, exist_ok=True)
154
+ cache_path = cache_dir / f"{cache_key}.pt"
155
+ ckpt_path = cache_dir / f"{cache_key}.ckpt.pt"
156
+ if cache_path.exists():
157
+ cached = torch.load(cache_path, map_location="cpu", weights_only=True)
158
+ if cached.shape[0] == len(encoded_list):
159
+ return cached
160
+ with torch.no_grad():
161
+ result = batched_sequence_logprobs(
162
+ bundle, encoded_list, batch_size=batch_size, desc=desc,
163
+ checkpoint_path=ckpt_path, checkpoint_every=1000,
164
+ )
165
+ torch.save(result, cache_path)
166
+ if ckpt_path.exists():
167
+ ckpt_path.unlink()
168
+ return result
169
+
170
+
171
+ def encode_all(
172
+ bundle: ModelBundle,
173
+ pairs: list[SourcePair],
174
+ *,
175
+ prompt: str,
176
+ chosen_key: str,
177
+ rejected_key: str,
178
+ ) -> tuple[list[tuple[torch.Tensor, int]], list[tuple[torch.Tensor, int]]]:
179
+ chosen = []
180
+ rejected = []
181
+ for pair in tqdm(pairs, desc="encode chosen"):
182
+ ids, prompt_len = encode_response(bundle, prompt, getattr(pair, chosen_key))
183
+ chosen.append((ids.cpu(), prompt_len))
184
+ for pair in tqdm(pairs, desc="encode rejected"):
185
+ ids, prompt_len = encode_response(bundle, prompt, getattr(pair, rejected_key))
186
+ rejected.append((ids.cpu(), prompt_len))
187
+ return chosen, rejected
188
+
189
+
190
+ @torch.no_grad()
191
+ def eval_pair_accuracy(
192
+ bundle: ModelBundle,
193
+ pairs: list[SourcePair],
194
+ *,
195
+ prompt: str,
196
+ chosen_key: str,
197
+ rejected_key: str,
198
+ n: int,
199
+ batch_size: int,
200
+ ) -> float:
201
+ subset = pairs[: min(n, len(pairs))]
202
+ if not subset:
203
+ return 0.0
204
+ chosen_enc = []
205
+ rejected_enc = []
206
+ for pair in subset:
207
+ chosen_ids, chosen_prompt_len = encode_response(bundle, prompt, getattr(pair, chosen_key))
208
+ rejected_ids, rejected_prompt_len = encode_response(bundle, prompt, getattr(pair, rejected_key))
209
+ chosen_enc.append((chosen_ids.cpu(), chosen_prompt_len))
210
+ rejected_enc.append((rejected_ids.cpu(), rejected_prompt_len))
211
+
212
+ correct = 0
213
+ for start in range(0, len(subset), batch_size):
214
+ chosen_logps = batch_sequence_logprobs(bundle, chosen_enc[start : start + batch_size])
215
+ rejected_logps = batch_sequence_logprobs(bundle, rejected_enc[start : start + batch_size])
216
+ correct += int((chosen_logps > rejected_logps).sum().item())
217
+ return correct / len(subset)
218
+
219
+
220
+ def train_single_token(
221
+ bundle: ModelBundle,
222
+ train_pairs: list[SourcePair],
223
+ holdout_pairs: list[SourcePair],
224
+ *,
225
+ token: str,
226
+ learning_rate: float,
227
+ chosen_key: str,
228
+ rejected_key: str,
229
+ config: TrainingConfig,
230
+ cache_dir: Path,
231
+ seed: int,
232
+ precomputed_ref_chosen: torch.Tensor | None = None,
233
+ precomputed_ref_rejected: torch.Tensor | None = None,
234
+ ) -> tuple[TrainedTokenArtifacts, torch.Tensor, torch.Tensor]:
235
+ gc.collect()
236
+ if torch.cuda.is_available():
237
+ torch.cuda.empty_cache()
238
+
239
+ token_id = bundle.tokenizer.convert_tokens_to_ids(token)
240
+
241
+ # Warm-start: overwrite new token rows from a neutral existing vocab token
242
+ # (must happen BEFORE ref cache so ref logprobs reflect the warm start)
243
+ if config.neutral_word is not None:
244
+ nid = bundle.tokenizer.convert_tokens_to_ids(config.neutral_word)
245
+ unk = bundle.tokenizer.unk_token_id
246
+ if nid is None or nid == unk:
247
+ nid = bundle.tokenizer.convert_tokens_to_ids("text") or 0
248
+ print(f"[{token}] warm-starting from '{config.neutral_word}' (id={nid})")
249
+ input_emb_ws = bundle.model.get_input_embeddings()
250
+ with torch.no_grad():
251
+ input_emb_ws.weight[token_id] = input_emb_ws.weight[nid].clone()
252
+ for _, m in bundle.model.named_modules():
253
+ if (isinstance(m, torch.nn.Embedding)
254
+ and m is not input_emb_ws
255
+ and m.weight.shape[0] == len(bundle.tokenizer)):
256
+ m.weight[token_id] = m.weight[nid].clone()
257
+
258
+ for parameter in bundle.model.parameters():
259
+ parameter.requires_grad = False
260
+ input_emb = bundle.model.get_input_embeddings()
261
+ input_emb.weight.requires_grad = True
262
+
263
+ # Gemma 4's auxiliary per-layer token table must be resized and mean-filled,
264
+ # but its new rows remain frozen. Training it diverts signal away from the
265
+ # tied input/output row and degrades the verbalization channel.
266
+ secondary_embs: list[torch.nn.Embedding] = []
267
+ for name, module in bundle.model.named_modules():
268
+ if (isinstance(module, torch.nn.Embedding)
269
+ and module is not input_emb
270
+ and module.weight.shape[0] == len(bundle.tokenizer)):
271
+ expected_mean = module.weight.data[:bundle.initial_tokenizer_len].mean(
272
+ dim=0, dtype=torch.float32
273
+ ).to(dtype=module.weight.dtype)
274
+ if not torch.equal(module.weight.data[token_id], expected_mean):
275
+ raise RuntimeError(
276
+ f"Secondary embedding {name} token row is not mean-initialized"
277
+ )
278
+ module.weight.requires_grad = False
279
+ secondary_embs.append(module)
280
+ if secondary_embs:
281
+ print(
282
+ f"[{token}] freezing {len(secondary_embs)} secondary "
283
+ "embedding(s) at mean initialization"
284
+ )
285
+
286
+ optimizer = torch.optim.AdamW(
287
+ [{"params": [input_emb.weight], "lr": learning_rate,
288
+ "_lr_scale": 1.0}],
289
+ weight_decay=0.0,
290
+ )
291
+ prompt = build_prompt(bundle, token)
292
+
293
+ chosen_enc, rejected_enc = encode_all(
294
+ bundle,
295
+ train_pairs,
296
+ prompt=prompt,
297
+ chosen_key=chosen_key,
298
+ rejected_key=rejected_key,
299
+ )
300
+ train_ids = [pair.pair_id for pair in train_pairs]
301
+ pair_weights = _source_balance_weights(train_pairs) if config.source_balance_by_source else None
302
+ # Cache key suffix includes neutral_word so warm-start and mean-init caches
303
+ # never collide (neutral_word=None → empty suffix = backward-compatible)
304
+ ws_suffix = f"_{config.neutral_word}" if config.neutral_word else ""
305
+ if precomputed_ref_chosen is not None:
306
+ print(f"[{token}] reusing precomputed ref_chosen")
307
+ ref_chosen = precomputed_ref_chosen
308
+ else:
309
+ ref_chosen = _load_or_compute_ref(
310
+ bundle,
311
+ chosen_enc,
312
+ batch_size=config.ref_cache_batch_size,
313
+ desc=f"[{token}] ref chosen",
314
+ cache_dir=cache_dir,
315
+ cache_key=_ref_cache_key(bundle.config.model.model_name, prompt, train_ids, f"train_{chosen_key}{ws_suffix}"),
316
+ )
317
+ if precomputed_ref_rejected is not None:
318
+ print(f"[{token}] reusing precomputed ref_rejected")
319
+ ref_rejected = precomputed_ref_rejected
320
+ else:
321
+ ref_rejected = _load_or_compute_ref(
322
+ bundle,
323
+ rejected_enc,
324
+ batch_size=config.ref_cache_batch_size,
325
+ desc=f"[{token}] ref rejected",
326
+ cache_dir=cache_dir,
327
+ cache_key=_ref_cache_key(bundle.config.model.model_name, prompt, train_ids, f"train_{rejected_key}{ws_suffix}"),
328
+ )
329
+
330
+ total_steps = config.train_steps if config.train_steps is not None else len(train_pairs) // config.batch_size
331
+ if total_steps == 0:
332
+ raise ValueError(f"Not enough train pairs ({len(train_pairs)}) for batch_size={config.batch_size}.")
333
+
334
+ rng = random.Random(seed)
335
+ order = list(range(len(train_pairs)))
336
+ rng.shuffle(order)
337
+ total_needed = total_steps * config.batch_size
338
+ if total_needed > len(order):
339
+ order = order * (total_needed // len(order) + 1)
340
+ order = order[:total_needed]
341
+
342
+ best_val_accuracy = 0.0
343
+ final_val_accuracy = eval_pair_accuracy(
344
+ bundle,
345
+ holdout_pairs,
346
+ prompt=prompt,
347
+ chosen_key=chosen_key,
348
+ rejected_key=rejected_key,
349
+ n=config.eval_subset_size,
350
+ batch_size=config.ref_cache_batch_size,
351
+ )
352
+ best_val_accuracy = max(best_val_accuracy, final_val_accuracy)
353
+
354
+ loss_history: list[float] = []
355
+ progress = tqdm(range(1, total_steps + 1), desc=f"[{token}] train")
356
+ for step in progress:
357
+ optimizer.zero_grad(set_to_none=True)
358
+ batch_ids = order[(step - 1) * config.batch_size : step * config.batch_size]
359
+ batch_chosen = [chosen_enc[pair_index] for pair_index in batch_ids]
360
+ batch_rejected = [rejected_enc[pair_index] for pair_index in batch_ids]
361
+ bundle.model.train()
362
+ chosen_logps = batch_sequence_logprobs(bundle, batch_chosen)
363
+ rejected_logps = batch_sequence_logprobs(bundle, batch_rejected)
364
+ bundle.model.eval()
365
+ ref_c = ref_chosen[batch_ids].to(bundle.model.device)
366
+ ref_r = ref_rejected[batch_ids].to(bundle.model.device)
367
+ dpo_losses = -F.logsigmoid(config.beta * ((chosen_logps - rejected_logps) + (ref_c - ref_r)))
368
+ apo_losses = -F.logsigmoid(config.beta * (chosen_logps - ref_c))
369
+ per_example_losses = dpo_losses + config.apo_alpha * apo_losses
370
+ if pair_weights is not None:
371
+ weights = pair_weights[batch_ids].to(bundle.model.device)
372
+ loss = (per_example_losses * weights).mean()
373
+ else:
374
+ loss = per_example_losses.mean()
375
+ loss.backward()
376
+ step_loss = float(loss.item())
377
+
378
+ # Mask grads to token_id row only + clip just that row (avoids clip_grad_norm_ on huge tensors)
379
+ with torch.no_grad():
380
+ for emb in [input_emb] + secondary_embs:
381
+ if emb.weight.grad is not None:
382
+ row = emb.weight.grad[token_id]
383
+ row_norm = row.norm()
384
+ if row_norm > 1.0:
385
+ row.mul_(1.0 / row_norm)
386
+ emb.weight.grad[:token_id].zero_()
387
+ emb.weight.grad[token_id + 1:].zero_()
388
+
389
+ lr = cosine_with_floor(
390
+ step - 1,
391
+ total_steps,
392
+ learning_rate,
393
+ min_lr=config.min_learning_rate,
394
+ warmup_steps=config.warmup_steps,
395
+ )
396
+ # For secondary embeddings, scale LR proportionally
397
+ for group in optimizer.param_groups:
398
+ scale = group.get("_lr_scale", 1.0)
399
+ group["lr"] = lr * scale
400
+ optimizer.step()
401
+ loss_history.append(step_loss)
402
+ progress.set_postfix(loss=f"{step_loss:.4f}", lr=f"{lr:.2e}")
403
+
404
+ if step % config.eval_every_steps == 0 or step == total_steps:
405
+ final_val_accuracy = eval_pair_accuracy(
406
+ bundle,
407
+ holdout_pairs,
408
+ prompt=prompt,
409
+ chosen_key=chosen_key,
410
+ rejected_key=rejected_key,
411
+ n=config.eval_subset_size,
412
+ batch_size=config.ref_cache_batch_size,
413
+ )
414
+ best_val_accuracy = max(best_val_accuracy, final_val_accuracy)
415
+ msg = f"[{token}] step {step:>5d} val_acc: {final_val_accuracy:.3f}"
416
+ progress.write(msg)
417
+ print(msg, flush=True)
418
+
419
+ embedding = input_emb.weight[token_id].detach().cpu()
420
+ secondary_embeddings = [e.weight[token_id].detach().cpu() for e in secondary_embs] if secondary_embs else None
421
+ gc.collect()
422
+ if torch.cuda.is_available():
423
+ torch.cuda.empty_cache()
424
+ return TrainedTokenArtifacts(
425
+ embedding=embedding,
426
+ loss_history=loss_history,
427
+ best_val_accuracy=best_val_accuracy,
428
+ final_val_accuracy=final_val_accuracy,
429
+ secondary_embeddings=secondary_embeddings,
430
+ ), ref_chosen, ref_rejected
detection_tokens/verbalization.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import asdict
5
+ from pathlib import Path
6
+
7
+ import torch
8
+
9
+ from .checkpoints import load_token_checkpoint
10
+ from .config import PipelineConfig, VerbalizationTokenSetConfig
11
+ from .modeling import ModelBundle
12
+
13
+
14
+ def apply_token_pair(bundle: ModelBundle, token_dir: Path) -> None:
15
+ input_emb = bundle.model.get_input_embeddings()
16
+ ai_ckpt = load_token_checkpoint(token_dir / "ai_token.pt")
17
+ human_ckpt = load_token_checkpoint(token_dir / "human_token.pt")
18
+ ai_id = bundle.tokenizer.convert_tokens_to_ids(bundle.config.model.ai_token)
19
+ human_id = bundle.tokenizer.convert_tokens_to_ids(bundle.config.model.human_token)
20
+ with torch.no_grad():
21
+ input_emb.weight[ai_id].copy_(ai_ckpt.embedding.to(input_emb.weight.device, dtype=input_emb.weight.dtype))
22
+ input_emb.weight[human_id].copy_(
23
+ human_ckpt.embedding.to(input_emb.weight.device, dtype=input_emb.weight.dtype)
24
+ )
25
+
26
+
27
+ @torch.inference_mode()
28
+ def sample_text(
29
+ bundle: ModelBundle,
30
+ prompt: str,
31
+ *,
32
+ max_new_tokens: int,
33
+ do_sample: bool,
34
+ temperature: float,
35
+ top_p: float,
36
+ ) -> str:
37
+ input_ids = bundle.tokenizer(prompt, return_tensors="pt", add_special_tokens=False)["input_ids"].to(
38
+ bundle.model.device
39
+ )
40
+ output = bundle.model.generate(
41
+ input_ids=input_ids,
42
+ max_new_tokens=max_new_tokens,
43
+ do_sample=do_sample,
44
+ temperature=temperature,
45
+ top_p=top_p,
46
+ pad_token_id=bundle.tokenizer.eos_token_id,
47
+ )
48
+ return bundle.tokenizer.decode(output[0, input_ids.shape[1] :], skip_special_tokens=True).strip()
49
+
50
+
51
+ def _chat_prompt(bundle: ModelBundle, content: str) -> str:
52
+ return bundle.tokenizer.apply_chat_template(
53
+ [{"role": "user", "content": content}],
54
+ tokenize=False,
55
+ add_generation_prompt=True,
56
+ )
57
+
58
+
59
+ @torch.inference_mode()
60
+ def verbalize_token(bundle: ModelBundle, token: str, *, max_new_tokens: int, do_sample: bool) -> str:
61
+ prompt = _chat_prompt(bundle, f"Describe what qualities of text would be implied by {token}.")
62
+ prompt += f"{token} text typically describes text that"
63
+ return sample_text(
64
+ bundle,
65
+ prompt,
66
+ max_new_tokens=max_new_tokens,
67
+ do_sample=do_sample,
68
+ temperature=1.0,
69
+ top_p=0.95 if do_sample else 1.0,
70
+ )
71
+
72
+
73
+ @torch.inference_mode()
74
+ def verbalize_difference(bundle: ModelBundle, *, max_new_tokens: int, do_sample: bool) -> str:
75
+ ai_token = bundle.config.model.ai_token
76
+ human_token = bundle.config.model.human_token
77
+ prompt = _chat_prompt(
78
+ bundle,
79
+ (
80
+ f"What is the difference between {ai_token} text and {human_token} text? "
81
+ f"Refer to {ai_token} text as A-type text and {human_token} text as B-type text. "
82
+ "Do not discuss the literal token strings; describe the passage qualities they accompany."
83
+ ),
84
+ )
85
+ prompt += (
86
+ f"{ai_token}, which I will refer to as A-type text, and {human_token}, "
87
+ "B-type text, have many similarities and differences."
88
+ )
89
+ return sample_text(
90
+ bundle,
91
+ prompt,
92
+ max_new_tokens=max_new_tokens,
93
+ do_sample=do_sample,
94
+ temperature=1.0,
95
+ top_p=0.95 if do_sample else 1.0,
96
+ )
97
+
98
+
99
+ def verbalize_token_set(
100
+ bundle: ModelBundle,
101
+ token_set: VerbalizationTokenSetConfig,
102
+ config: PipelineConfig,
103
+ ) -> dict:
104
+ apply_token_pair(bundle, token_set.token_dir)
105
+ payload = {
106
+ "name": token_set.name,
107
+ "token_dir": str(token_set.token_dir),
108
+ "model_name": config.model.model_name,
109
+ "ai_token": config.model.ai_token,
110
+ "human_token": config.model.human_token,
111
+ "greedy": {
112
+ "ai": verbalize_token(
113
+ bundle,
114
+ config.model.ai_token,
115
+ max_new_tokens=config.verbalization.max_new_tokens,
116
+ do_sample=False,
117
+ ),
118
+ "human": verbalize_token(
119
+ bundle,
120
+ config.model.human_token,
121
+ max_new_tokens=config.verbalization.max_new_tokens,
122
+ do_sample=False,
123
+ ),
124
+ "difference": verbalize_difference(
125
+ bundle,
126
+ max_new_tokens=config.verbalization.max_new_tokens,
127
+ do_sample=False,
128
+ ),
129
+ },
130
+ "samples": [],
131
+ }
132
+ for _ in range(config.verbalization.n_samples):
133
+ payload["samples"].append(
134
+ {
135
+ "ai": verbalize_token(
136
+ bundle,
137
+ config.model.ai_token,
138
+ max_new_tokens=config.verbalization.max_new_tokens,
139
+ do_sample=True,
140
+ ),
141
+ "human": verbalize_token(
142
+ bundle,
143
+ config.model.human_token,
144
+ max_new_tokens=config.verbalization.max_new_tokens,
145
+ do_sample=True,
146
+ ),
147
+ "difference": verbalize_difference(
148
+ bundle,
149
+ max_new_tokens=config.verbalization.max_new_tokens,
150
+ do_sample=True,
151
+ ),
152
+ }
153
+ )
154
+ return payload
155
+
156
+
157
+ def save_verbalizations(output_dir: Path, results: list[dict]) -> None:
158
+ output_dir.mkdir(parents=True, exist_ok=True)
159
+ (output_dir / "verbalizations.json").write_text(json.dumps(results, indent=2), encoding="utf-8")
160
+ for result in results:
161
+ (output_dir / f"{result['name']}.json").write_text(json.dumps(result, indent=2), encoding="utf-8")
162
+