token-xray-ml 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- token_xray/__init__.py +6 -0
- token_xray/api.py +66 -0
- token_xray/cli.py +51 -0
- token_xray/ddp.py +148 -0
- token_xray/ddp_trainer_entry.py +79 -0
- token_xray/intended.py +162 -0
- token_xray/leak.py +75 -0
- token_xray/probe.py +95 -0
- token_xray/readout.py +213 -0
- token_xray/runners.py +202 -0
- token_xray/scenarios.py +378 -0
- token_xray/trl_runners.py +335 -0
- token_xray_ml-0.1.0.dist-info/METADATA +197 -0
- token_xray_ml-0.1.0.dist-info/RECORD +18 -0
- token_xray_ml-0.1.0.dist-info/WHEEL +5 -0
- token_xray_ml-0.1.0.dist-info/entry_points.txt +2 -0
- token_xray_ml-0.1.0.dist-info/licenses/LICENSE +21 -0
- token_xray_ml-0.1.0.dist-info/top_level.txt +1 -0
token_xray/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Token X-ray: measured per-token gradient weights and cross-sequence leaks through real training stacks."""
|
|
2
|
+
from .api import Report, xray
|
|
3
|
+
from .probe import build_probe, make_batch
|
|
4
|
+
|
|
5
|
+
__all__ = ["xray", "Report", "build_probe", "make_batch"]
|
|
6
|
+
__version__ = "0.1.0"
|
token_xray/api.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Public API: X-ray your own training step.
|
|
3
|
+
|
|
4
|
+
from token_xray import xray
|
|
5
|
+
|
|
6
|
+
def my_step(model, sequences):
|
|
7
|
+
# build batches exactly as your trainer does, run forward + backward (no optimizer step needed)
|
|
8
|
+
...
|
|
9
|
+
|
|
10
|
+
report = xray(my_step, lengths=[5, 9, 3, 12, 7, 4, 10, 6])
|
|
11
|
+
report.assert_token_mean() # every target token got weight 1/N
|
|
12
|
+
print(report.table())
|
|
13
|
+
|
|
14
|
+
The probe model is a tiny LLaMA whose token ids are unique tracers. `sequences` is a list of lists of
|
|
15
|
+
token ids; treat them like tokenized documents. Labels follow the usual convention (labels = input ids,
|
|
16
|
+
shifted inside the loss).
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Callable
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
|
|
25
|
+
from . import intended
|
|
26
|
+
from .probe import build_probe, make_batch
|
|
27
|
+
from .readout import XRay, read, recording, unit_gradients
|
|
28
|
+
|
|
29
|
+
Step = Callable[[object, list[list[int]]], None]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Report:
|
|
34
|
+
xray: XRay
|
|
35
|
+
lengths: list[int]
|
|
36
|
+
|
|
37
|
+
def deviation_from(self, want: list[list[float]]) -> float:
|
|
38
|
+
got = self.xray.normalized
|
|
39
|
+
return max(abs(g - w) for gr, wr in zip(got, want) for g, w in zip(gr, wr))
|
|
40
|
+
|
|
41
|
+
def assert_token_mean(self, tol: float = 1e-6) -> None:
|
|
42
|
+
dev = self.deviation_from(intended.token_mean(self.lengths))
|
|
43
|
+
if dev > tol:
|
|
44
|
+
raise AssertionError(
|
|
45
|
+
f"token weights deviate from a global token mean by up to {dev:.3g} (tol {tol:g}).\n" + self.table()
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def assert_pure_rescaling(self, tol: float = 1e-9) -> None:
|
|
49
|
+
if self.xray.min_cos < 1 - tol:
|
|
50
|
+
raise AssertionError(f"gradient is not a pure rescaling of each token's own gradient (min cos {self.xray.min_cos:.9f})")
|
|
51
|
+
|
|
52
|
+
def table(self) -> str:
|
|
53
|
+
lines = ["seq len mean(w*N) min(w*N) max(w*N)"]
|
|
54
|
+
for i, row in enumerate(self.xray.normalized):
|
|
55
|
+
a = np.array(row)
|
|
56
|
+
lines.append(f"{i:3d} {self.lengths[i]:4d} {a.mean():9.4f} {a.min():9.4f} {a.max():9.4f}")
|
|
57
|
+
return "\n".join(lines)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def xray(step: Step, lengths: list[int], seed: int = 0) -> Report:
|
|
61
|
+
batch = make_batch(lengths)
|
|
62
|
+
unit = unit_gradients(build_probe(batch.vocab_size, seed=seed), batch)
|
|
63
|
+
model = build_probe(batch.vocab_size, seed=seed)
|
|
64
|
+
with recording(model) as rec:
|
|
65
|
+
step(model, [list(s) for s in batch.sequences])
|
|
66
|
+
return Report(xray=read(rec.grads, unit, batch), lengths=lengths)
|
token_xray/cli.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""token-xray command line.
|
|
2
|
+
|
|
3
|
+
token-xray report print the frozen scenarios as a table
|
|
4
|
+
token-xray export public/xray.json write measured results for the website
|
|
5
|
+
token-xray legacy-ga out.json measure the gradient-accumulation step with the INSTALLED
|
|
6
|
+
transformers (run in .venv-hf445 for the pre-fix 4.45.2 Trainer)
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main(argv: list[str] | None = None) -> int:
|
|
16
|
+
p = argparse.ArgumentParser(prog="token-xray")
|
|
17
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
18
|
+
sub.add_parser("report")
|
|
19
|
+
e = sub.add_parser("export")
|
|
20
|
+
e.add_argument("out")
|
|
21
|
+
g = sub.add_parser("legacy-ga")
|
|
22
|
+
g.add_argument("out")
|
|
23
|
+
a = p.parse_args(argv)
|
|
24
|
+
|
|
25
|
+
if a.cmd == "legacy-ga":
|
|
26
|
+
from .scenarios import legacy_ga_panel
|
|
27
|
+
|
|
28
|
+
with open(a.out, "w") as f:
|
|
29
|
+
json.dump(legacy_ga_panel(), f)
|
|
30
|
+
return 0
|
|
31
|
+
|
|
32
|
+
from .scenarios import export
|
|
33
|
+
|
|
34
|
+
data = export()
|
|
35
|
+
if a.cmd == "export":
|
|
36
|
+
with open(a.out, "w") as f:
|
|
37
|
+
json.dump(data, f, separators=(",", ":"))
|
|
38
|
+
print(f"wrote {a.out}")
|
|
39
|
+
return 0
|
|
40
|
+
print("versions:", data["versions"])
|
|
41
|
+
print("\nAggregation (max |w*N - intended|, min cosine)")
|
|
42
|
+
for pnl in data["aggregation"]["panels"]:
|
|
43
|
+
print(f" [{pnl['kind']:9s}] {pnl['title']:52s} {pnl['maxDeviation']:.2e} {pnl['minCos']:.6f}")
|
|
44
|
+
print("\nLeaks (max off-diagonal)")
|
|
45
|
+
for pnl in data["leak"]["panels"]:
|
|
46
|
+
print(f" [{pnl['kind']:9s}] {pnl['title']:52s} {pnl['maxLeak']:.2e}")
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
sys.exit(main())
|
token_xray/ddp.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data-parallel runs on CPU (gloo): does the weight a token receives depend on how the batch was split
|
|
3
|
+
across ranks?
|
|
4
|
+
|
|
5
|
+
Activation gradients are local to a rank; DDP averages PARAMETER gradients in the all-reduce after
|
|
6
|
+
backward. So these runs read the input-embedding parameter gradient (`readout.embedding_row_grads`)
|
|
7
|
+
after the all-reduce, on rank 0, where every rank's contribution has arrived.
|
|
8
|
+
|
|
9
|
+
Two launchers:
|
|
10
|
+
* `ddp_step`: torch.multiprocessing.spawn + DistributedDataParallel around the probe, with a
|
|
11
|
+
hand-written loss in one of two reductions (global token mean, per-rank mean).
|
|
12
|
+
* `hf_trainer_ddp`: the unmodified transformers Trainer under torchrun (2 CPU processes, gloo).
|
|
13
|
+
|
|
14
|
+
Every run records which sequences each rank actually received, so the intended weights are computed
|
|
15
|
+
from the observed split rather than an assumed one.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import socket
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import tempfile
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
|
|
27
|
+
import torch
|
|
28
|
+
import torch.distributed as dist
|
|
29
|
+
import torch.multiprocessing as mp
|
|
30
|
+
import torch.nn.functional as F
|
|
31
|
+
|
|
32
|
+
from .probe import TracerBatch, build_probe, make_batch
|
|
33
|
+
from .readout import embedding_row_grads
|
|
34
|
+
from .runners import collate
|
|
35
|
+
|
|
36
|
+
REDUCTIONS = ("global_token_mean", "per_rank_mean")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class DDPResult:
|
|
41
|
+
rows: dict[str, dict[int, torch.Tensor]] # reduction -> token id -> all-reduced embedding gradient row
|
|
42
|
+
assignment: list[list[int]] # sequence indices seen by each rank, in order (duplicates included)
|
|
43
|
+
local_targets: list[int] # target tokens per rank
|
|
44
|
+
world: int
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _free_port() -> int:
|
|
48
|
+
with socket.socket() as s:
|
|
49
|
+
s.bind(("127.0.0.1", 0))
|
|
50
|
+
return s.getsockname()[1]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _shard(n_seqs: int, world: int, split, sampler: bool) -> list[list[int]]:
|
|
54
|
+
if sampler:
|
|
55
|
+
from torch.utils.data import DistributedSampler
|
|
56
|
+
|
|
57
|
+
return [list(DistributedSampler(range(n_seqs), num_replicas=world, rank=r, shuffle=False, drop_last=False))
|
|
58
|
+
for r in range(world)]
|
|
59
|
+
return [list(s) for s in split]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _worker(rank: int, world: int, port: int, lengths: list[int], split, sampler: bool, reductions, out: str):
|
|
63
|
+
os.environ.update(MASTER_ADDR="127.0.0.1", MASTER_PORT=str(port))
|
|
64
|
+
torch.set_num_threads(1)
|
|
65
|
+
dist.init_process_group("gloo", rank=rank, world_size=world)
|
|
66
|
+
try:
|
|
67
|
+
batch = make_batch(lengths)
|
|
68
|
+
mine = _shard(len(lengths), world, split, sampler)[rank]
|
|
69
|
+
b = collate([{"input_ids": batch.sequences[i]} for i in mine])
|
|
70
|
+
tgt = b["labels"][:, 1:]
|
|
71
|
+
n_local = torch.tensor(float((tgt != -100).sum()), dtype=torch.float64)
|
|
72
|
+
n_all = [torch.zeros_like(n_local) for _ in range(world)]
|
|
73
|
+
dist.all_gather(n_all, n_local)
|
|
74
|
+
rows = {}
|
|
75
|
+
for red in reductions:
|
|
76
|
+
model = build_probe(batch.vocab_size)
|
|
77
|
+
ddp = torch.nn.parallel.DistributedDataParallel(model)
|
|
78
|
+
logits = ddp(input_ids=b["input_ids"], attention_mask=b["attention_mask"]).logits
|
|
79
|
+
ce = F.cross_entropy(logits[:, :-1].reshape(-1, logits.shape[-1]), tgt.reshape(-1),
|
|
80
|
+
ignore_index=-100, reduction="sum")
|
|
81
|
+
if red == "global_token_mean":
|
|
82
|
+
# every rank divides by the GLOBAL token count; ×world undoes DDP's average
|
|
83
|
+
loss = ce / sum(n_all) * world
|
|
84
|
+
elif red == "per_rank_mean":
|
|
85
|
+
loss = ce / n_local # each rank's own token mean; DDP then averages the ranks
|
|
86
|
+
else:
|
|
87
|
+
raise ValueError(red)
|
|
88
|
+
loss.backward() # DDP all-reduces (averages) the embedding gradient here
|
|
89
|
+
rows[red] = embedding_row_grads(model, range(1, batch.vocab_size))
|
|
90
|
+
if rank == 0:
|
|
91
|
+
torch.save({"rows": rows, "assignment": _shard(len(lengths), world, split, sampler),
|
|
92
|
+
"local_targets": [int(x) for x in n_all]}, out)
|
|
93
|
+
finally:
|
|
94
|
+
dist.destroy_process_group()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def ddp_step(
|
|
98
|
+
lengths: list[int],
|
|
99
|
+
world: int,
|
|
100
|
+
split: list[list[int]] | None = None,
|
|
101
|
+
sampler: bool = False,
|
|
102
|
+
reductions: tuple[str, ...] = REDUCTIONS,
|
|
103
|
+
) -> DDPResult:
|
|
104
|
+
"""One backward pass under DDP with `world` CPU processes.
|
|
105
|
+
|
|
106
|
+
split[r] lists the sequence indices given to rank r. sampler=True instead lets
|
|
107
|
+
torch.utils.data.DistributedSampler (shuffle off, drop_last off) decide, including its padding.
|
|
108
|
+
"""
|
|
109
|
+
if split is None and not sampler:
|
|
110
|
+
split = [list(range(len(lengths)))] if world == 1 else None
|
|
111
|
+
if split is None:
|
|
112
|
+
raise ValueError("give a split or set sampler=True")
|
|
113
|
+
with tempfile.TemporaryDirectory() as d:
|
|
114
|
+
out = os.path.join(d, "rank0.pt")
|
|
115
|
+
mp.spawn(_worker, args=(world, _free_port(), lengths, split, sampler, tuple(reductions), out),
|
|
116
|
+
nprocs=world, join=True)
|
|
117
|
+
r = torch.load(out)
|
|
118
|
+
return DDPResult(rows=r["rows"], assignment=r["assignment"], local_targets=r["local_targets"], world=world)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ── the transformers Trainer under torchrun ────────────────────────────────
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def hf_trainer_ddp(lengths: list[int], per_device_batch: int, average_tokens_across_devices: bool = True,
|
|
125
|
+
world: int = 2, timeout: float = 300.0) -> dict:
|
|
126
|
+
"""One optimizer step of the unmodified Trainer under `torchrun --nproc_per_node world` (CPU, gloo).
|
|
127
|
+
Returns the all-reduced embedding rows (rank 0) and the sequences each rank actually received."""
|
|
128
|
+
with tempfile.TemporaryDirectory() as d:
|
|
129
|
+
spec = {"lengths": lengths, "per_device_batch": per_device_batch,
|
|
130
|
+
"average_tokens_across_devices": average_tokens_across_devices, "out": d}
|
|
131
|
+
path = os.path.join(d, "spec.json")
|
|
132
|
+
with open(path, "w") as f:
|
|
133
|
+
json.dump(spec, f)
|
|
134
|
+
env = {**os.environ, "OMP_NUM_THREADS": "1", "TOKENIZERS_PARALLELISM": "false"}
|
|
135
|
+
# Static rendezvous on loopback. `--standalone` resolves the machine's host name, which on some
|
|
136
|
+
# networks is a reverse-DNS name that never answers, and the store then waits 300 s per attempt.
|
|
137
|
+
cmd = [sys.executable, "-m", "torch.distributed.run", "--nnodes=1", f"--nproc_per_node={world}",
|
|
138
|
+
"--master_addr=127.0.0.1", f"--master_port={_free_port()}",
|
|
139
|
+
"-m", "token_xray.ddp_trainer_entry", path]
|
|
140
|
+
try:
|
|
141
|
+
p = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=timeout, start_new_session=True)
|
|
142
|
+
except subprocess.TimeoutExpired as e:
|
|
143
|
+
raise RuntimeError(f"torchrun did not finish within {timeout} s; stderr tail:\n{(e.stderr or '')[-2000:]}")
|
|
144
|
+
if p.returncode != 0:
|
|
145
|
+
raise RuntimeError(f"torchrun failed ({p.returncode}):\n{p.stderr[-3000:]}")
|
|
146
|
+
r = torch.load(os.path.join(d, "rank0.pt"))
|
|
147
|
+
seen = [json.load(open(os.path.join(d, f"seen{k}.json"))) for k in range(world)]
|
|
148
|
+
return {"rows": r["rows"], "seen": seen, "world": world, "args": r["args"]}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
torchrun entry point: one optimizer step of the unmodified transformers Trainer on the probe, on CPU
|
|
3
|
+
with the gloo backend. Launched by `ddp.hf_trainer_ddp`; not meant to be run by hand.
|
|
4
|
+
|
|
5
|
+
Writes, into the spec's output directory:
|
|
6
|
+
rank0.pt all-reduced input-embedding gradient rows, captured before the optimizer step
|
|
7
|
+
seen<r>.json the sequence indices rank r received, in order
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
|
|
16
|
+
import torch
|
|
17
|
+
from transformers import Trainer, TrainerCallback, TrainingArguments
|
|
18
|
+
|
|
19
|
+
from .probe import build_probe, make_batch
|
|
20
|
+
from .readout import embedding_row_grads
|
|
21
|
+
from .runners import _ListDataset, collate
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main(path: str) -> None:
|
|
25
|
+
spec = json.load(open(path))
|
|
26
|
+
rank = int(os.environ.get("RANK", "0"))
|
|
27
|
+
batch = make_batch(spec["lengths"])
|
|
28
|
+
model = build_probe(batch.vocab_size)
|
|
29
|
+
ds = _ListDataset(batch)
|
|
30
|
+
seen: list[int] = []
|
|
31
|
+
|
|
32
|
+
class Capture(TrainerCallback):
|
|
33
|
+
def on_pre_optimizer_step(self, args, state, control, **kw):
|
|
34
|
+
# DDP has all-reduced (averaged) the parameter gradients during the last backward
|
|
35
|
+
if rank == 0:
|
|
36
|
+
rows = embedding_row_grads(model, range(1, batch.vocab_size))
|
|
37
|
+
torch.save({"rows": rows, "args": {"average_tokens_across_devices": args.average_tokens_across_devices,
|
|
38
|
+
"world_size": args.world_size}},
|
|
39
|
+
os.path.join(spec["out"], "rank0.pt"))
|
|
40
|
+
|
|
41
|
+
with tempfile.TemporaryDirectory() as out:
|
|
42
|
+
args = TrainingArguments(
|
|
43
|
+
output_dir=out,
|
|
44
|
+
per_device_train_batch_size=spec["per_device_batch"],
|
|
45
|
+
gradient_accumulation_steps=1,
|
|
46
|
+
max_steps=1,
|
|
47
|
+
learning_rate=1e-3,
|
|
48
|
+
max_grad_norm=0.0,
|
|
49
|
+
report_to=[],
|
|
50
|
+
use_cpu=True,
|
|
51
|
+
ddp_backend="gloo",
|
|
52
|
+
save_strategy="no",
|
|
53
|
+
logging_strategy="no",
|
|
54
|
+
seed=0,
|
|
55
|
+
data_seed=0,
|
|
56
|
+
remove_unused_columns=False,
|
|
57
|
+
dataloader_drop_last=False,
|
|
58
|
+
disable_tqdm=True,
|
|
59
|
+
average_tokens_across_devices=spec["average_tokens_across_devices"],
|
|
60
|
+
)
|
|
61
|
+
trainer = Trainer(model=model, args=args, train_dataset=ds, data_collator=lambda f: collate(f),
|
|
62
|
+
callbacks=[Capture()])
|
|
63
|
+
trainer._get_train_sampler = lambda *a, **k: torch.utils.data.SequentialSampler(ds)
|
|
64
|
+
compute = trainer.compute_loss
|
|
65
|
+
|
|
66
|
+
def spy(m, inputs, *a, **k):
|
|
67
|
+
for row in inputs["input_ids"].tolist():
|
|
68
|
+
first = next(t for t in row if t != 0)
|
|
69
|
+
seen.append(batch.seq_of_token[first])
|
|
70
|
+
return compute(m, inputs, *a, **k)
|
|
71
|
+
|
|
72
|
+
trainer.compute_loss = spy
|
|
73
|
+
trainer.train()
|
|
74
|
+
with open(os.path.join(spec["out"], f"seen{rank}.json"), "w") as f:
|
|
75
|
+
json.dump(seen, f)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
main(sys.argv[1])
|
token_xray/intended.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Intended weights: what the equation says each token should receive (closed form).
|
|
3
|
+
|
|
4
|
+
All functions return per-sequence lists of weights for target tokens (length L_i - 1 each),
|
|
5
|
+
already multiplied by N (total target tokens), so a correct global token mean is 1.0 everywhere.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _targets(lengths: list[int]) -> list[int]:
|
|
13
|
+
return [L - 1 for L in lengths]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def token_mean(lengths: list[int]) -> list[list[float]]:
|
|
17
|
+
"""Global mean over all target tokens in the effective batch: w = 1/N."""
|
|
18
|
+
return [[1.0] * n for n in _targets(lengths)]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def sequence_mean(lengths: list[int]) -> list[list[float]]:
|
|
22
|
+
"""Mean over tokens within each sequence, then over sequences: w = 1/(B * n_i)."""
|
|
23
|
+
n = _targets(lengths)
|
|
24
|
+
N, B = sum(n), len(n)
|
|
25
|
+
return [[N / (B * ni)] * ni for ni in n]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def mean_of_microbatch_means(lengths: list[int], micro: int) -> list[list[float]]:
|
|
29
|
+
"""Token mean inside each micro-batch, then averaged over micro-batches (sequential split)."""
|
|
30
|
+
n = _targets(lengths)
|
|
31
|
+
N = sum(n)
|
|
32
|
+
groups = [n[i : i + micro] for i in range(0, len(n), micro)]
|
|
33
|
+
k = len(groups)
|
|
34
|
+
out = []
|
|
35
|
+
for g in groups:
|
|
36
|
+
Ng = sum(g)
|
|
37
|
+
out += [[N / (k * Ng)] * ni for ni in g]
|
|
38
|
+
return out
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def first_microbatch_only(lengths: list[int], micro: int) -> list[list[float]]:
|
|
42
|
+
"""Only the first micro-batch survives; its tokens keep the global normalizer."""
|
|
43
|
+
n = _targets(lengths)
|
|
44
|
+
return [[1.0 if i < micro else 0.0] * ni for i, ni in enumerate(n)]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def labels_equal_to_pad_masked(lengths: list[int]) -> list[list[float]]:
|
|
49
|
+
"""Each sequence ends with an EOS whose id is also the pad id. A collator that sets every label equal
|
|
50
|
+
to the pad id to -100 drops each sequence's last target (the EOS): it gets weight 0, and the
|
|
51
|
+
remaining tokens share the normaliser, w·N = N / (N - B). Lengths include the EOS."""
|
|
52
|
+
n = _targets(lengths)
|
|
53
|
+
N, B = sum(n), len(n)
|
|
54
|
+
return [[N / (N - B)] * (ni - 1) + [0.0] for ni in n]
|
|
55
|
+
|
|
56
|
+
# ── preference and RL objectives ───────────────────────────────────────────
|
|
57
|
+
#
|
|
58
|
+
# These return the raw weight on each completion token's cross-entropy, w = −∂L/∂log π(token)
|
|
59
|
+
# (positive = the token is reinforced), not multiplied by N. Rows are completions, in batch order.
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _sigmoid(x: float) -> float:
|
|
63
|
+
return 1.0 / (1.0 + math.exp(-x)) if x >= 0 else math.exp(x) / (1.0 + math.exp(x))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def dpo_sigmoid(
|
|
67
|
+
chosen_lens: list[int], rejected_lens: list[int], deltas: list[float], beta: float
|
|
68
|
+
) -> tuple[list[list[float]], list[list[float]]]:
|
|
69
|
+
"""DPO (Rafailov et al., 2023), sigmoid loss averaged over P pairs:
|
|
70
|
+
|
|
71
|
+
L = −1/P Σ_i log σ(β Δ_i), Δ_i = (log π_c − log π_ref,c) − (log π_r − log π_ref,r)
|
|
72
|
+
|
|
73
|
+
with sequence log-probs summed over completion tokens, so every token of a completion gets the
|
|
74
|
+
same weight: +β σ(−β Δ_i) / P on the chosen tokens, −β σ(−β Δ_i) / P on the rejected ones.
|
|
75
|
+
"""
|
|
76
|
+
P = len(deltas)
|
|
77
|
+
if not (len(chosen_lens) == len(rejected_lens) == P):
|
|
78
|
+
raise ValueError("one chosen length, one rejected length and one margin per pair")
|
|
79
|
+
k = [beta * _sigmoid(-beta * d) / P for d in deltas]
|
|
80
|
+
return [[k[i]] * n for i, n in enumerate(chosen_lens)], [[-k[i]] * n for i, n in enumerate(rejected_lens)]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def group_advantages(rewards: list[float], scale: bool = True, eps: float = 1e-4) -> list[float]:
|
|
84
|
+
"""GRPO advantages for one group: (r − mean) / (std + eps), std with ddof = 1 (torch default)."""
|
|
85
|
+
G = len(rewards)
|
|
86
|
+
m = sum(rewards) / G
|
|
87
|
+
c = [r - m for r in rewards]
|
|
88
|
+
if not scale:
|
|
89
|
+
return c
|
|
90
|
+
sd = math.sqrt(sum(x * x for x in c) / (G - 1))
|
|
91
|
+
return [x / (sd + eps) for x in c]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def group_policy_gradient(
|
|
95
|
+
lengths: list[int],
|
|
96
|
+
rewards: list[float],
|
|
97
|
+
loss_type: str,
|
|
98
|
+
ratios: list[list[float]] | None = None,
|
|
99
|
+
epsilon_low: float = 0.2,
|
|
100
|
+
epsilon_high: float | None = None,
|
|
101
|
+
max_completion_length: int | None = None,
|
|
102
|
+
scale_rewards: bool = True,
|
|
103
|
+
) -> tuple[list[float], list[list[float]]]:
|
|
104
|
+
"""One group of G completions, one optimizer step, KL off (beta = 0), clipped surrogate
|
|
105
|
+
|
|
106
|
+
ℓ_t = −min(ρ_t A_i, clip(ρ_t, 1 − ε_low, 1 + ε_high) A_i)
|
|
107
|
+
|
|
108
|
+
reduced as the installed TRL does for each loss_type (single process, no accumulation):
|
|
109
|
+
grpo : mean over completions of the per-completion token mean (norm G · |o_i|)
|
|
110
|
+
bnpo : token mean over the batch (norm Σ_j |o_j|)
|
|
111
|
+
dapo : token mean over the generation batch (norm Σ_j |o_j|)
|
|
112
|
+
dr_grpo : sum over tokens / (G · max_completion_length) (norm G · L_max)
|
|
113
|
+
|
|
114
|
+
Weight on token t of completion i: A_i ρ_t / norm, or 0 where the clipped branch is selected
|
|
115
|
+
(A > 0 and ρ > 1 + ε_high, or A < 0 and ρ < 1 − ε_low). Returns (advantages, weights).
|
|
116
|
+
"""
|
|
117
|
+
G = len(lengths)
|
|
118
|
+
eh = epsilon_low if epsilon_high is None else epsilon_high
|
|
119
|
+
A = group_advantages(rewards, scale=scale_rewards)
|
|
120
|
+
total = sum(lengths)
|
|
121
|
+
rho = ratios if ratios is not None else [[1.0] * L for L in lengths]
|
|
122
|
+
out = []
|
|
123
|
+
for i, L in enumerate(lengths):
|
|
124
|
+
if loss_type == "grpo":
|
|
125
|
+
norm = G * L
|
|
126
|
+
elif loss_type in ("bnpo", "dapo"):
|
|
127
|
+
norm = total
|
|
128
|
+
elif loss_type == "dr_grpo":
|
|
129
|
+
if max_completion_length is None:
|
|
130
|
+
raise ValueError("dr_grpo normalises by max_completion_length")
|
|
131
|
+
norm = G * max_completion_length
|
|
132
|
+
else:
|
|
133
|
+
raise ValueError(f"unsupported loss_type {loss_type!r}")
|
|
134
|
+
row = []
|
|
135
|
+
for r in rho[i]:
|
|
136
|
+
clipped = (A[i] > 0 and r > 1 + eh) or (A[i] < 0 and r < 1 - epsilon_low)
|
|
137
|
+
row.append(0.0 if clipped else A[i] * r / norm)
|
|
138
|
+
out.append(row)
|
|
139
|
+
return A, out
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ── data parallel ──────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def per_rank_mean(lengths: list[int], rank_of: list[int], world: int) -> list[list[float]]:
|
|
146
|
+
"""Token mean on each rank, then DDP's average over ranks: w = 1 / (W · N_r), times N.
|
|
147
|
+
Equal to the global token mean only when every rank holds the same number of target tokens."""
|
|
148
|
+
n = _targets(lengths)
|
|
149
|
+
N = sum(n)
|
|
150
|
+
per = [0] * world
|
|
151
|
+
for ni, r in zip(n, rank_of):
|
|
152
|
+
per[r] += ni
|
|
153
|
+
return [[N / (world * per[r])] * ni for ni, r in zip(n, rank_of)]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def with_multiplicity(lengths: list[int], copies: list[int]) -> list[list[float]]:
|
|
157
|
+
"""Global token mean over a batch in which sequence i appears copies[i] times (sampler padding):
|
|
158
|
+
each copy counts in the normaliser and each adds its gradient, so w·N = copies_i · N / N_seen."""
|
|
159
|
+
n = _targets(lengths)
|
|
160
|
+
N = sum(n)
|
|
161
|
+
seen = sum(c * ni for c, ni in zip(copies, n))
|
|
162
|
+
return [[c * N / seen] * ni for c, ni in zip(copies, n)]
|
token_xray/leak.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Leak mode: does one sequence's loss send gradient into another sequence?
|
|
3
|
+
|
|
4
|
+
With attention live, run K backward passes. In pass i, only sequence i keeps its labels. The
|
|
5
|
+
gradient that reaches the positions of any other sequence j is a leak. On a correct stack the leak
|
|
6
|
+
matrix is exactly zero off the diagonal.
|
|
7
|
+
|
|
8
|
+
Layouts:
|
|
9
|
+
* padded: one sequence per row, right padding, attention mask. Reference: no leaks.
|
|
10
|
+
* packed_naive: sequences concatenated into one row with no boundary information.
|
|
11
|
+
* packed_position_ids: concatenated, position_ids restart at 0 for each sequence (the
|
|
12
|
+
padding-free / flattening convention), no attention mask.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import torch
|
|
18
|
+
import torch.nn.functional as F
|
|
19
|
+
|
|
20
|
+
from .probe import TracerBatch, build_probe
|
|
21
|
+
from .readout import recording
|
|
22
|
+
from .runners import collate
|
|
23
|
+
|
|
24
|
+
LAYOUTS = ("padded", "packed_naive", "packed_position_ids")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _inputs(batch: TracerBatch, layout: str, keep: int):
|
|
28
|
+
seqs = batch.sequences
|
|
29
|
+
if layout == "padded":
|
|
30
|
+
b = collate([{"input_ids": s} for s in seqs])
|
|
31
|
+
lab = b["labels"].clone()
|
|
32
|
+
for i in range(len(seqs)):
|
|
33
|
+
if i != keep:
|
|
34
|
+
lab[i] = -100
|
|
35
|
+
return {"input_ids": b["input_ids"], "attention_mask": b["attention_mask"]}, lab
|
|
36
|
+
ids = torch.tensor([[t for s in seqs for t in s]])
|
|
37
|
+
lab = torch.full_like(ids, -100)
|
|
38
|
+
start = sum(len(s) for s in seqs[:keep])
|
|
39
|
+
L = len(seqs[keep])
|
|
40
|
+
lab[0, start : start + L] = ids[0, start : start + L]
|
|
41
|
+
# the first token of a sequence must never be a target of the previous sequence's last token
|
|
42
|
+
lab[0, start] = -100
|
|
43
|
+
inputs = {"input_ids": ids}
|
|
44
|
+
if layout == "packed_position_ids":
|
|
45
|
+
inputs["position_ids"] = torch.tensor([[p for s in seqs for p in range(len(s))]])
|
|
46
|
+
return inputs, lab
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def leak_matrix(batch: TracerBatch, layout: str, attn_implementation: str = "sdpa", seed: int = 0) -> np.ndarray:
|
|
50
|
+
"""M[i, j] = ||grad reaching sequence j from sequence i's loss|| / ||grad reaching sequence i||."""
|
|
51
|
+
if layout not in LAYOUTS:
|
|
52
|
+
raise ValueError(f"layout must be one of {LAYOUTS}")
|
|
53
|
+
K = len(batch.sequences)
|
|
54
|
+
M = np.zeros((K, K))
|
|
55
|
+
for i in range(K):
|
|
56
|
+
model = build_probe(batch.vocab_size, "leak", seed=seed, attn_implementation=attn_implementation)
|
|
57
|
+
inputs, lab = _inputs(batch, layout, i)
|
|
58
|
+
with recording(model) as rec:
|
|
59
|
+
logits = model(**inputs).logits
|
|
60
|
+
loss = F.cross_entropy(
|
|
61
|
+
logits[:, :-1].reshape(-1, logits.shape[-1]), lab[:, 1:].reshape(-1), ignore_index=-100, reduction="sum"
|
|
62
|
+
)
|
|
63
|
+
loss.backward()
|
|
64
|
+
norms = np.zeros(K)
|
|
65
|
+
for tok, g in rec.grads.items():
|
|
66
|
+
norms[batch.seq_of_token[tok]] += float(g.norm()) ** 2
|
|
67
|
+
norms = np.sqrt(norms)
|
|
68
|
+
M[i] = norms / norms[i] if norms[i] > 0 else norms
|
|
69
|
+
return M
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def max_leak(M: np.ndarray) -> float:
|
|
73
|
+
off = M.copy()
|
|
74
|
+
np.fill_diagonal(off, 0.0)
|
|
75
|
+
return float(off.max())
|