token-xray-ml 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kaushik
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,197 @@
1
+ Metadata-Version: 2.4
2
+ Name: token-xray-ml
3
+ Version: 0.1.0
4
+ Summary: See what your training stack actually does to every token: measured per-token gradient weights and cross-sequence gradient leaks, read from the library's own training loop.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://lineage-ml.pages.dev/
7
+ Project-URL: Source, https://github.com/kaushikdhola/lineage-ml/tree/main/xray
8
+ Project-URL: Issues, https://github.com/kaushikdhola/lineage-ml/issues
9
+ Keywords: llm,training,gradient,debugging,transformers,trl,grpo
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
14
+ Requires-Python: >=3.10.2
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: torch>=2.3
18
+ Requires-Dist: transformers>=4.46
19
+ Requires-Dist: numpy>=1.26
20
+ Provides-Extra: trl
21
+ Requires-Dist: trl<2,>=1.0; extra == "trl"
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=8; extra == "dev"
24
+ Dynamic: license-file
25
+
26
+ # token-xray
27
+
28
+ **Measure the gradient weight your loss gives every token.**
29
+
30
+ Many silent training bugs, and several objective design choices, show up in one quantity: the gradient
31
+ weight each token receives. Others leak gradient from one sequence into another. Token X-ray measures
32
+ both by running your batching and loss code on a tracer model, and compares them with what the equation
33
+ says.
34
+
35
+ ```python
36
+ from token_xray import xray
37
+
38
+ def my_step(model, sequences):
39
+ # batch `sequences` exactly as your trainer does, then forward + backward
40
+ ...
41
+
42
+ report = xray(my_step, lengths=[5, 9, 3, 12, 7, 4, 10, 6])
43
+ report.assert_token_mean() # every target token received weight 1/N
44
+ print(report.table())
45
+ ```
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ pip install "git+https://github.com/kaushikdhola/lineage-ml#subdirectory=xray"
51
+ ```
52
+
53
+ The package is not on PyPI yet; this installs it from the repository (https://github.com/kaushikdhola/lineage-ml).
54
+
55
+ ## How it works
56
+
57
+ A tiny LLaMA "probe" gives every token in the batch a unique id. In aggregation mode the attention
58
+ output is zeroed and frozen, so a position's loss depends only on its own embedding. The gradient the
59
+ stack produces at token _i_ is then `w_i · r_i`, where `r_i` is the token's unit-weight gradient
60
+ (computed on an identical probe), and the readout recovers:
61
+
62
+ - `w_i = ⟨g_i, r_i⟩ / ‖r_i‖²` — the weight the stack applied (×N, so a global token mean is 1.0)
63
+ - `cos(g_i, r_i)` — below 1 when the stack changed the gradient in a way that is not a rescaling
64
+
65
+ In leak mode attention is live. One backward per sequence, with only that sequence labelled; any
66
+ gradient reaching another sequence is a leak. On a correct stack it is exactly zero.
67
+
68
+ When one input token predicts several targets (the prompt's last token in preference and RL batches), the
69
+ weights are solved for by least squares on the targets' unit gradients; the condition number and the
70
+ unexplained remainder are reported, and the readout refuses systems it cannot identify (at most h − 1
71
+ targets per input for hidden size h). Under data parallelism the readout uses the embedding's parameter
72
+ gradient after the all-reduce. Gradient that reaches padding is collected, not dropped.
73
+
74
+ ### What it cannot see
75
+
76
+ - **After backward.** The readout sees how the loss weights tokens, before gradient clipping and the
77
+ optimizer. Bugs that live in the optimizer step, such as the DeepSpeed ZeRO-1/2 CPU-offload gradient copy
78
+ emulated below, are outside its view.
79
+ - **Model state.** The probe is untrained, so its predictions are near-uniform. Anything that depends on
80
+ the model's state (label smoothing's cosine, z-loss, DPO's margin, GRPO's ratios and clipping, the size
81
+ of a leak) is measured for this probe, not for a trained model. The equations it checks hold either way.
82
+
83
+ ### What each real run changes from the defaults
84
+
85
+ - the sample order is fixed with a sequential sampler (shuffled order is tested separately);
86
+ - gradient clipping is off (`max_grad_norm=0`) and bf16 is off (TRL's configs default to bf16);
87
+ - GRPO's completions are scripted: `model.generate` returns fixed tracer sequences, each ending with its
88
+ own EOS id, and a fixed reward function scores them;
89
+ - the training step (or `compute_loss`) is wrapped only to record gradients and the batches seen.
90
+
91
+ ## What is checked today
92
+
93
+ | Check | Runs | Result |
94
+ | --------------------------------------------------------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
95
+ | Gradient accumulation 1×8 vs 4×2 | real `Trainer` | matches the token mean (≤ 2e-7) |
96
+ | Sample order (shuffled) | real `Trainer` | invariant |
97
+ | Logged loss equals the objective | real `Trainer` | equal |
98
+ | Gradient accumulation, transformers **4.45.2** | real `Trainer` (side venv) | **mean of micro-batch means**, as fixed in Oct 2024; matches the closed form |
99
+ | Left vs right padding | real `Trainer` | **differs**: with labels = input ids, the pad before each left-padded sequence predicts its first token, adding a target (labelling convention) |
100
+ | Label smoothing | real `Trainer` | flagged: cosine < 1 (declared configuration; tiny on the untrained probe) |
101
+ | pad = EOS with `DataCollatorForLanguageModeling` | real collator | EOS targets get zero weight |
102
+ | Mean of micro-batch means | emulation | detected; matches closed form |
103
+ | Later micro-batches dropped | emulation | detected; matches closed form |
104
+ | Data parallel, 2 ranks, `average_tokens_across_devices` default | real `Trainer`, torchrun | token mean |
105
+ | Data parallel, `average_tokens_across_devices=False` | real `Trainer`, torchrun | per-rank mean, matches closed form |
106
+ | World size 1 vs 2, equal and uneven tokens per rank | DDP (spawn, gloo) | identical weights |
107
+ | `DistributedSampler` padding | DDP (spawn, gloo) | the repeated sample counts twice |
108
+ | TRL SFT, `padding_free` and `packing` (bfd) | real `SFTTrainer` | token mean; packed documents do not leak |
109
+ | TRL DPO, sigmoid loss | real `DPOTrainer` | matches β·σ(−βΔ)/P per token |
110
+ | TRL GRPO, default (`dapo`), `grpo`, `dr_grpo`, two iterations | real `GRPOTrainer` | matches the clipped surrogate, including clipped tokens at 0 |
111
+ | Padded rows | real model | no leak |
112
+ | Packed, no boundaries | real model | leaks (later documents into earlier) |
113
+ | Packed, `position_ids` reset, `use_cache=False` | real model | no leak |
114
+ | Packed, `position_ids` reset, default `use_cache` | real model | **leaks**, no warning — known issue, see below |
115
+
116
+ Versions: transformers 5.22.2, TRL 1.17.1, torch 2.15 (the 4.45.2 row uses torch 2.4.1).
117
+
118
+ **Emulations** reproduce, in plain PyTorch, the documented mechanism of a bug in code that cannot be run
119
+ here. They are always labelled and never presented as the library itself.
120
+
121
+ ### Gradient accumulation before the October 2024 fix (the hero plate)
122
+
123
+ The site's first plate is a real run of the transformers **4.45.2** `Trainer` with four accumulation
124
+ steps, in a separate environment (`.venv-hf445`, pinned in `requirements-hf445.txt`, created by
125
+ `scripts/setup.sh`). `token-xray legacy-ga` runs the probe, the unit gradients and the readout entirely
126
+ inside that environment, so nothing mixes library versions. The weights match the mean of micro-batch
127
+ means in closed form; the current release matches the token mean.
128
+
129
+ ### Data parallel: relation R2
130
+
131
+ R2: a token's weight must not depend on how the batch is split across data-parallel ranks. Activation
132
+ gradients are local to a rank (DDP averages _parameter_ gradients in the all-reduce), so these runs read
133
+ the input embedding's parameter gradient after the all-reduce. Tracer rows are unique, so row _t_ holds
134
+ token _t_'s gradient; on one process this row readout equals the activation readout.
135
+
136
+ - `torch.multiprocessing.spawn`, gloo, DDP around the probe: with a global token mean the weights for world
137
+ size 1 and 2 are identical (equal and uneven tokens per rank); an average of per-rank token means breaks
138
+ R2 whenever ranks hold different token counts, and matches `intended.per_rank_mean`.
139
+ - The real `Trainer` under `torchrun` on two CPU processes: the default
140
+ `average_tokens_across_devices=True` gives the token mean; `False` gives the per-rank mean.
141
+ - **`DistributedSampler` padding.** With `drop_last=False` and a dataset that does not divide evenly, the
142
+ sampler repeats samples. The repeat carries the same tracer ids, so its rows hold the sum of both copies,
143
+ which is exactly what reaches the parameters: the repeated sample counts twice in that step
144
+ (`intended.with_multiplicity`). This is documented sampler behaviour, not a bug.
145
+
146
+ ### TRL runs
147
+
148
+ Real runs of `SFTTrainer`, `DPOTrainer` and `GRPOTrainer` (TRL 1.17.1) on the probe:
149
+
150
+ - **SFT** with `padding_free=True` and with `packing=True` (bfd): a global token mean across accumulation;
151
+ the leak matrix built through the trainer (one run per document, labels on that document only) is
152
+ exactly zero, because SFT passes `use_cache=False`.
153
+ - **DPO**, sigmoid loss: every completion token gets β·σ(−βΔ)/P, positive on chosen and negative on
154
+ rejected answers (verified against autograd and measured through the trainer).
155
+ - **GRPO** with scripted completions (each ending with its own EOS id) and `num_iterations=2`: the default
156
+ `loss_type` is `dapo`; `dapo`, `grpo` and `dr_grpo` all match the clipped surrogate at both steps,
157
+ including the tokens the clip zeroes once ratios move. TRL's `dr_grpo` keeps `scale_rewards="group"`
158
+ unless set to `"none"`; the published Dr. GRPO drops it. Step-1 weights are checked against
159
+ `src/core/aggregation.ts`.
160
+
161
+ ### Known issue: packed sequences and `use_cache`
162
+
163
+ transformers detects packed sequences from `position_ids` only when no `attention_mask` is passed and no
164
+ cache object exists. Most model configs default to `use_cache=True`, which creates a cache even in
165
+ training mode, so a hand-written loop that feeds packed `position_ids` without `use_cache=False` attends
166
+ across documents silently. Loops with gradient checkpointing are unaffected (it turns the cache off), and
167
+ so is the `Trainer`, which sets `model.config.use_cache` from `TrainingArguments.use_cache`.
168
+
169
+ This is already reported in [transformers#50419](https://github.com/huggingface/transformers/issues/50419).
170
+ A maintainer first replied that a cache should not be used during training and suggested documenting
171
+ that requirement, then noted that `use_cache` defaults to true and linked the issue to a proposed fix.
172
+ Fixes are proposed in [#50190](https://github.com/huggingface/transformers/pull/50190) and
173
+ [#50421](https://github.com/huggingface/transformers/pull/50421). Until one lands, pass `use_cache=False`
174
+ when you train on packed sequences. Reproduction: [`repro/packed_position_ids_use_cache.py`](repro/packed_position_ids_use_cache.py).
175
+
176
+ ## Precision
177
+
178
+ LLaMA's RMSNorm and the HF loss compute in float32 inside a float64 model, so measured weights carry
179
+ up to ~2e-7 relative error. Tests use a tolerance of 1e-6. TRL's default bf16 mixed precision is tested
180
+ against bounds derived from bf16's unit roundoff u = 2⁻⁸: per token ≤ 2u, per sequence mean ≤ u (the mean
181
+ bound does not shrink with the number of targets, because the measured rounding errors within a sequence
182
+ are correlated). A 1% re-weighting exceeds it 2.6×, and a planted 1% normaliser fault fails the test. These
183
+ bounds replace a number that was first guessed and then raised after measuring; see
184
+ `docs/DECISIONS.md`. The `eager` attention path in float64 produces NaN
185
+ on fully padded rows; the probe uses `sdpa`, the default.
186
+
187
+ ## Development
188
+
189
+ ```bash
190
+ pip install -e ".[dev]"
191
+ pytest
192
+ token-xray report
193
+ token-xray export ../public/xray/data.json # needs ../.venv-hf445 for the 4.45.2 run (scripts/setup.sh)
194
+ node ../scripts/py.mjs ../scripts/mutate.py # the mutation test, ~25 minutes
195
+ ```
196
+
197
+ Every check is mutation-tested. `scripts/mutate.py` plants 43 faults, one at a time: 35 in the instrument itself (readout, recorder, probe, closed forms, leak harness, runners, data-parallel launchers) and 8 realistic bugs injected into transformers and TRL (`xray/tests/conftest.py`). Every fault is caught, and each of the 52 tests catches at least one. It runs in CI on pushes to `main` and nightly.
@@ -0,0 +1,172 @@
1
+ # token-xray
2
+
3
+ **Measure the gradient weight your loss gives every token.**
4
+
5
+ Many silent training bugs, and several objective design choices, show up in one quantity: the gradient
6
+ weight each token receives. Others leak gradient from one sequence into another. Token X-ray measures
7
+ both by running your batching and loss code on a tracer model, and compares them with what the equation
8
+ says.
9
+
10
+ ```python
11
+ from token_xray import xray
12
+
13
+ def my_step(model, sequences):
14
+ # batch `sequences` exactly as your trainer does, then forward + backward
15
+ ...
16
+
17
+ report = xray(my_step, lengths=[5, 9, 3, 12, 7, 4, 10, 6])
18
+ report.assert_token_mean() # every target token received weight 1/N
19
+ print(report.table())
20
+ ```
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pip install "git+https://github.com/kaushikdhola/lineage-ml#subdirectory=xray"
26
+ ```
27
+
28
+ The package is not on PyPI yet; this installs it from the repository (https://github.com/kaushikdhola/lineage-ml).
29
+
30
+ ## How it works
31
+
32
+ A tiny LLaMA "probe" gives every token in the batch a unique id. In aggregation mode the attention
33
+ output is zeroed and frozen, so a position's loss depends only on its own embedding. The gradient the
34
+ stack produces at token _i_ is then `w_i · r_i`, where `r_i` is the token's unit-weight gradient
35
+ (computed on an identical probe), and the readout recovers:
36
+
37
+ - `w_i = ⟨g_i, r_i⟩ / ‖r_i‖²` — the weight the stack applied (×N, so a global token mean is 1.0)
38
+ - `cos(g_i, r_i)` — below 1 when the stack changed the gradient in a way that is not a rescaling
39
+
40
+ In leak mode attention is live. One backward per sequence, with only that sequence labelled; any
41
+ gradient reaching another sequence is a leak. On a correct stack it is exactly zero.
42
+
43
+ When one input token predicts several targets (the prompt's last token in preference and RL batches), the
44
+ weights are solved for by least squares on the targets' unit gradients; the condition number and the
45
+ unexplained remainder are reported, and the readout refuses systems it cannot identify (at most h − 1
46
+ targets per input for hidden size h). Under data parallelism the readout uses the embedding's parameter
47
+ gradient after the all-reduce. Gradient that reaches padding is collected, not dropped.
48
+
49
+ ### What it cannot see
50
+
51
+ - **After backward.** The readout sees how the loss weights tokens, before gradient clipping and the
52
+ optimizer. Bugs that live in the optimizer step, such as the DeepSpeed ZeRO-1/2 CPU-offload gradient copy
53
+ emulated below, are outside its view.
54
+ - **Model state.** The probe is untrained, so its predictions are near-uniform. Anything that depends on
55
+ the model's state (label smoothing's cosine, z-loss, DPO's margin, GRPO's ratios and clipping, the size
56
+ of a leak) is measured for this probe, not for a trained model. The equations it checks hold either way.
57
+
58
+ ### What each real run changes from the defaults
59
+
60
+ - the sample order is fixed with a sequential sampler (shuffled order is tested separately);
61
+ - gradient clipping is off (`max_grad_norm=0`) and bf16 is off (TRL's configs default to bf16);
62
+ - GRPO's completions are scripted: `model.generate` returns fixed tracer sequences, each ending with its
63
+ own EOS id, and a fixed reward function scores them;
64
+ - the training step (or `compute_loss`) is wrapped only to record gradients and the batches seen.
65
+
66
+ ## What is checked today
67
+
68
+ | Check | Runs | Result |
69
+ | --------------------------------------------------------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
70
+ | Gradient accumulation 1×8 vs 4×2 | real `Trainer` | matches the token mean (≤ 2e-7) |
71
+ | Sample order (shuffled) | real `Trainer` | invariant |
72
+ | Logged loss equals the objective | real `Trainer` | equal |
73
+ | Gradient accumulation, transformers **4.45.2** | real `Trainer` (side venv) | **mean of micro-batch means**, as fixed in Oct 2024; matches the closed form |
74
+ | Left vs right padding | real `Trainer` | **differs**: with labels = input ids, the pad before each left-padded sequence predicts its first token, adding a target (labelling convention) |
75
+ | Label smoothing | real `Trainer` | flagged: cosine < 1 (declared configuration; tiny on the untrained probe) |
76
+ | pad = EOS with `DataCollatorForLanguageModeling` | real collator | EOS targets get zero weight |
77
+ | Mean of micro-batch means | emulation | detected; matches closed form |
78
+ | Later micro-batches dropped | emulation | detected; matches closed form |
79
+ | Data parallel, 2 ranks, `average_tokens_across_devices` default | real `Trainer`, torchrun | token mean |
80
+ | Data parallel, `average_tokens_across_devices=False` | real `Trainer`, torchrun | per-rank mean, matches closed form |
81
+ | World size 1 vs 2, equal and uneven tokens per rank | DDP (spawn, gloo) | identical weights |
82
+ | `DistributedSampler` padding | DDP (spawn, gloo) | the repeated sample counts twice |
83
+ | TRL SFT, `padding_free` and `packing` (bfd) | real `SFTTrainer` | token mean; packed documents do not leak |
84
+ | TRL DPO, sigmoid loss | real `DPOTrainer` | matches β·σ(−βΔ)/P per token |
85
+ | TRL GRPO, default (`dapo`), `grpo`, `dr_grpo`, two iterations | real `GRPOTrainer` | matches the clipped surrogate, including clipped tokens at 0 |
86
+ | Padded rows | real model | no leak |
87
+ | Packed, no boundaries | real model | leaks (later documents into earlier) |
88
+ | Packed, `position_ids` reset, `use_cache=False` | real model | no leak |
89
+ | Packed, `position_ids` reset, default `use_cache` | real model | **leaks**, no warning — known issue, see below |
90
+
91
+ Versions: transformers 5.22.2, TRL 1.17.1, torch 2.15 (the 4.45.2 row uses torch 2.4.1).
92
+
93
+ **Emulations** reproduce, in plain PyTorch, the documented mechanism of a bug in code that cannot be run
94
+ here. They are always labelled and never presented as the library itself.
95
+
96
+ ### Gradient accumulation before the October 2024 fix (the hero plate)
97
+
98
+ The site's first plate is a real run of the transformers **4.45.2** `Trainer` with four accumulation
99
+ steps, in a separate environment (`.venv-hf445`, pinned in `requirements-hf445.txt`, created by
100
+ `scripts/setup.sh`). `token-xray legacy-ga` runs the probe, the unit gradients and the readout entirely
101
+ inside that environment, so nothing mixes library versions. The weights match the mean of micro-batch
102
+ means in closed form; the current release matches the token mean.
103
+
104
+ ### Data parallel: relation R2
105
+
106
+ R2: a token's weight must not depend on how the batch is split across data-parallel ranks. Activation
107
+ gradients are local to a rank (DDP averages _parameter_ gradients in the all-reduce), so these runs read
108
+ the input embedding's parameter gradient after the all-reduce. Tracer rows are unique, so row _t_ holds
109
+ token _t_'s gradient; on one process this row readout equals the activation readout.
110
+
111
+ - `torch.multiprocessing.spawn`, gloo, DDP around the probe: with a global token mean the weights for world
112
+ size 1 and 2 are identical (equal and uneven tokens per rank); an average of per-rank token means breaks
113
+ R2 whenever ranks hold different token counts, and matches `intended.per_rank_mean`.
114
+ - The real `Trainer` under `torchrun` on two CPU processes: the default
115
+ `average_tokens_across_devices=True` gives the token mean; `False` gives the per-rank mean.
116
+ - **`DistributedSampler` padding.** With `drop_last=False` and a dataset that does not divide evenly, the
117
+ sampler repeats samples. The repeat carries the same tracer ids, so its rows hold the sum of both copies,
118
+ which is exactly what reaches the parameters: the repeated sample counts twice in that step
119
+ (`intended.with_multiplicity`). This is documented sampler behaviour, not a bug.
120
+
121
+ ### TRL runs
122
+
123
+ Real runs of `SFTTrainer`, `DPOTrainer` and `GRPOTrainer` (TRL 1.17.1) on the probe:
124
+
125
+ - **SFT** with `padding_free=True` and with `packing=True` (bfd): a global token mean across accumulation;
126
+ the leak matrix built through the trainer (one run per document, labels on that document only) is
127
+ exactly zero, because SFT passes `use_cache=False`.
128
+ - **DPO**, sigmoid loss: every completion token gets β·σ(−βΔ)/P, positive on chosen and negative on
129
+ rejected answers (verified against autograd and measured through the trainer).
130
+ - **GRPO** with scripted completions (each ending with its own EOS id) and `num_iterations=2`: the default
131
+ `loss_type` is `dapo`; `dapo`, `grpo` and `dr_grpo` all match the clipped surrogate at both steps,
132
+ including the tokens the clip zeroes once ratios move. TRL's `dr_grpo` keeps `scale_rewards="group"`
133
+ unless set to `"none"`; the published Dr. GRPO drops it. Step-1 weights are checked against
134
+ `src/core/aggregation.ts`.
135
+
136
+ ### Known issue: packed sequences and `use_cache`
137
+
138
+ transformers detects packed sequences from `position_ids` only when no `attention_mask` is passed and no
139
+ cache object exists. Most model configs default to `use_cache=True`, which creates a cache even in
140
+ training mode, so a hand-written loop that feeds packed `position_ids` without `use_cache=False` attends
141
+ across documents silently. Loops with gradient checkpointing are unaffected (it turns the cache off), and
142
+ so is the `Trainer`, which sets `model.config.use_cache` from `TrainingArguments.use_cache`.
143
+
144
+ This is already reported in [transformers#50419](https://github.com/huggingface/transformers/issues/50419).
145
+ A maintainer first replied that a cache should not be used during training and suggested documenting
146
+ that requirement, then noted that `use_cache` defaults to true and linked the issue to a proposed fix.
147
+ Fixes are proposed in [#50190](https://github.com/huggingface/transformers/pull/50190) and
148
+ [#50421](https://github.com/huggingface/transformers/pull/50421). Until one lands, pass `use_cache=False`
149
+ when you train on packed sequences. Reproduction: [`repro/packed_position_ids_use_cache.py`](repro/packed_position_ids_use_cache.py).
150
+
151
+ ## Precision
152
+
153
+ LLaMA's RMSNorm and the HF loss compute in float32 inside a float64 model, so measured weights carry
154
+ up to ~2e-7 relative error. Tests use a tolerance of 1e-6. TRL's default bf16 mixed precision is tested
155
+ against bounds derived from bf16's unit roundoff u = 2⁻⁸: per token ≤ 2u, per sequence mean ≤ u (the mean
156
+ bound does not shrink with the number of targets, because the measured rounding errors within a sequence
157
+ are correlated). A 1% re-weighting exceeds it 2.6×, and a planted 1% normaliser fault fails the test. These
158
+ bounds replace a number that was first guessed and then raised after measuring; see
159
+ `docs/DECISIONS.md`. The `eager` attention path in float64 produces NaN
160
+ on fully padded rows; the probe uses `sdpa`, the default.
161
+
162
+ ## Development
163
+
164
+ ```bash
165
+ pip install -e ".[dev]"
166
+ pytest
167
+ token-xray report
168
+ token-xray export ../public/xray/data.json # needs ../.venv-hf445 for the 4.45.2 run (scripts/setup.sh)
169
+ node ../scripts/py.mjs ../scripts/mutate.py # the mutation test, ~25 minutes
170
+ ```
171
+
172
+ Every check is mutation-tested. `scripts/mutate.py` plants 43 faults, one at a time: 35 in the instrument itself (readout, recorder, probe, closed forms, leak harness, runners, data-parallel launchers) and 8 realistic bugs injected into transformers and TRL (`xray/tests/conftest.py`). Every fault is caught, and each of the 52 tests catches at least one. It runs in CI on pushes to `main` and nightly.
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "token-xray-ml" # "tokenxray" is taken on PyPI; imports stay token_xray, the CLI stays token-xray
7
+ version = "0.1.0"
8
+ description = "See what your training stack actually does to every token: measured per-token gradient weights and cross-sequence gradient leaks, read from the library's own training loop."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10.2"
12
+ dependencies = ["torch>=2.3", "transformers>=4.46", "numpy>=1.26"]
13
+ keywords = ["llm", "training", "gradient", "debugging", "transformers", "trl", "grpo"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Science/Research",
17
+ "Programming Language :: Python :: 3",
18
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://lineage-ml.pages.dev/"
23
+ Source = "https://github.com/kaushikdhola/lineage-ml/tree/main/xray"
24
+ Issues = "https://github.com/kaushikdhola/lineage-ml/issues"
25
+
26
+ [project.optional-dependencies]
27
+ trl = ["trl>=1.0,<2"]
28
+ dev = ["pytest>=8"]
29
+
30
+ [project.scripts]
31
+ token-xray = "token_xray.cli:main"
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["src"]
35
+
36
+ [tool.pytest.ini_options]
37
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"
@@ -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)
@@ -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())