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 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())