Instructions to use danielfein/raid-ce-gemma4-e4b with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use danielfein/raid-ce-gemma4-e4b with Transformers:
# Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("danielfein/raid-ce-gemma4-e4b", device_map="auto") - Notebooks
- Google Colab
- Kaggle
Add training support package
Browse files- detection_tokens/__init__.py +21 -0
- detection_tokens/checkpoints.py +58 -0
- detection_tokens/config.py +158 -0
- detection_tokens/data.py +300 -0
- detection_tokens/modeling.py +263 -0
- detection_tokens/pipeline.py +172 -0
- detection_tokens/scoring.py +189 -0
- detection_tokens/training.py +430 -0
- detection_tokens/verbalization.py +162 -0
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 |
+
|