stackformers 4.2.0__tar.gz → 4.3.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.
- {stackformers-4.2.0 → stackformers-4.3.0}/CHANGELOG.md +65 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/CLAUDE.md +1 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/PKG-INFO +1 -1
- {stackformers-4.2.0 → stackformers-4.3.0}/pyproject.toml +1 -1
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/__init__.py +14 -0
- stackformers-4.3.0/stackformers/mlm/README.md +54 -0
- stackformers-4.3.0/stackformers/mlm/config.py +10 -0
- stackformers-4.3.0/stackformers/mlm/head.py +27 -0
- stackformers-4.3.0/stackformers/mlm/masking.py +30 -0
- stackformers-4.3.0/stackformers/mlm/protocols.py +47 -0
- stackformers-4.3.0/stackformers/mlm/wrapper.py +76 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/positional/README.md +6 -4
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/positional/config.py +20 -18
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/positional/rope_nd.py +18 -8
- stackformers-4.3.0/tests/mlm/test_head.py +37 -0
- stackformers-4.3.0/tests/mlm/test_masking.py +53 -0
- stackformers-4.3.0/tests/mlm/test_wrapper.py +201 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/tests/positional/test_rope_nd.py +35 -9
- stackformers-4.3.0/tests/presets/__init__.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/uv.lock +1 -1
- {stackformers-4.2.0 → stackformers-4.3.0}/.claudeignore +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/.gitignore +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/.python-version +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/.vscode/settings.json +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/Justfile +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/LICENSE +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/PLAN.md +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/README.md +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/run-remote-tests.sh +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/attention/README.md +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/attention/__init__.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/attention/bias.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/attention/config.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/attention/cross_attn.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/attention/distance_bias.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/attention/factory.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/attention/ops.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/attention/protocols.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/attention/self_attn.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/attention/varlen_backend.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/config.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/cross_attender.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/decoder.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/encoder.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/feedforward/README.md +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/feedforward/__init__.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/feedforward/config.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/feedforward/factory.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/feedforward/geglu.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/feedforward/protocols.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/feedforward/relu_squared.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/feedforward/swiglu.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/layers.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/norm/README.md +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/norm/__init__.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/norm/config.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/norm/factory.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/norm/protocols.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/positional/__init__.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/positional/factory.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/positional/learned.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/positional/none.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/positional/protocols.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/positional/rope1d.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/positional/rope2d.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/presets/README.md +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/presets/__init__.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/presets/cross_attender.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/presets/decoder.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/presets/encoder.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/stackformers/sequence.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/tests/__init__.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/tests/attention/__init__.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/tests/attention/test_cross_attn.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/tests/attention/test_distance_bias.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/tests/attention/test_kernels.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/tests/attention/test_ops.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/tests/attention/test_self_attn.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/tests/attention/test_varlen_backend.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/tests/conftest.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/tests/feedforward/__init__.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/tests/feedforward/test_geglu.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/tests/feedforward/test_relu_squared.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/tests/feedforward/test_swiglu.py +0 -0
- {stackformers-4.2.0/tests/positional → stackformers-4.3.0/tests/mlm}/__init__.py +0 -0
- {stackformers-4.2.0/tests/presets → stackformers-4.3.0/tests/positional}/__init__.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/tests/positional/test_learned_pos.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/tests/positional/test_rope.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/tests/presets/test_cross_attender.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/tests/presets/test_decoder.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/tests/presets/test_encoder.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/tests/test_cross_attender.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/tests/test_decoder.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/tests/test_encoder.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/tests/test_layers.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/tests/test_norm.py +0 -0
- {stackformers-4.2.0 → stackformers-4.3.0}/tests/test_sequence.py +0 -0
|
@@ -6,6 +6,71 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
|
6
6
|
adheres to [Semantic Versioning](https://semver.org/): MAJOR for breaking public API changes,
|
|
7
7
|
MINOR for backwards-compatible features, PATCH for bug fixes and internal changes.
|
|
8
8
|
|
|
9
|
+
## [4.3.0] — 2026-07-17
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **`MLMWrapper`** (`stackformers/mlm/`) — a masked-token-reconstruction auxiliary loss over any
|
|
14
|
+
encoder satisfying `EncoderLike`, domain-agnostic about what a token represents. Takes the
|
|
15
|
+
encoder at call time (`forward(input, encoder)`) rather than owning it, so nothing is
|
|
16
|
+
registered as a submodule and `mlm_wrapper.parameters()` never includes the encoder's weights.
|
|
17
|
+
- `MLMWrapperConfig`, `MaskingStrategy`/`RandomMasking`, `ReconstructionHead`/`RegressionHead`,
|
|
18
|
+
and `MLMOutput` (`out`, `mlm_loss`).
|
|
19
|
+
|
|
20
|
+
### Notes
|
|
21
|
+
|
|
22
|
+
- **`out` is always the encoder's clean, unmasked output; only `mlm_loss` ever reflects
|
|
23
|
+
masking.** In training, `forward` runs the encoder twice — once clean (returned as `out`) and
|
|
24
|
+
once on a separately-masked copy (used only to compute `mlm_loss`) — so the main pipeline sees
|
|
25
|
+
byte-identical output whether or not this loss is being trained alongside it. In eval, only the
|
|
26
|
+
clean pass runs and `mlm_loss` is a constant zero, gated on `self.training` the same way
|
|
27
|
+
`nn.Dropout` and `nn.BatchNorm` already are — so callers can invoke it unconditionally in both
|
|
28
|
+
modes with no `if training` branch of their own.
|
|
29
|
+
|
|
30
|
+
- **The reconstruction target is always `input.x.detach()`.** This severs the gradient path from
|
|
31
|
+
the loss back to whatever produced `input.x`, removing the collapse shortcut a trainable
|
|
32
|
+
tokenizer would otherwise have (drive every token toward one constant vector to make
|
|
33
|
+
reconstruction trivial).
|
|
34
|
+
|
|
35
|
+
- **`RandomMasking` needs no packed-sequence boundary awareness** — each token's masking decision
|
|
36
|
+
is independent of every other's, so document identity (`cu_seqlens`) never enters it. That only
|
|
37
|
+
becomes necessary for a contiguous-span (blockwise) strategy, which could otherwise straddle
|
|
38
|
+
two packed documents.
|
|
39
|
+
|
|
40
|
+
## [4.2.1] — 2026-07-14
|
|
41
|
+
|
|
42
|
+
### Removed
|
|
43
|
+
|
|
44
|
+
- **`RoPENDConfig.headroom`.** It was redundant with `r_max`: the ladder only ever depended on
|
|
45
|
+
the product `headroom · r_max`, so the two knobs shared one degree of freedom, and anyone
|
|
46
|
+
wanting more reach at the slow end could get it by raising `r_max` — the parameter that has a
|
|
47
|
+
measurement procedure attached. It was also the last *tuned* number in a module whose premise
|
|
48
|
+
is that the band range is measured, not tuned.
|
|
49
|
+
|
|
50
|
+
Its default has a derivation, so it need not be a parameter at all. Attention sees *signed*
|
|
51
|
+
offsets spanning `[−r_max, +r_max]`, a width of `2·r_max`; asking that the slowest band turn
|
|
52
|
+
through at most half a circle across that width gives `ω_lo = π / (2·r_max)` directly. That is
|
|
53
|
+
bit-identical to the old `headroom=4.0` default at every `r_max`, so callers on the default —
|
|
54
|
+
which is to say every caller, since the field shipped in 4.2.0 — see no change in behaviour.
|
|
55
|
+
|
|
56
|
+
Both ends of the ladder are now the same rule, `ω = π / scale`: a half turn over `r_min` at
|
|
57
|
+
the fast end (Nyquist), and a half turn over `2·r_max` at the slow end (no wrap).
|
|
58
|
+
|
|
59
|
+
Treated as a PATCH rather than a MAJOR bump because `headroom` existed for exactly one
|
|
60
|
+
release, is not known to be set anywhere, and its removal cannot change the behaviour of a
|
|
61
|
+
caller that did not set it. Note that pydantic's default `extra="ignore"` means a leftover
|
|
62
|
+
`RoPENDConfig(headroom=...)` is *dropped silently* rather than raising — a caller who had set
|
|
63
|
+
it to something other than 4.0 will now get different frequencies without being told.
|
|
64
|
+
|
|
65
|
+
### Fixed
|
|
66
|
+
|
|
67
|
+
- **`RoPENDConfig` now requires `dim_head >= 4 * coords`** (at least two bands per axis).
|
|
68
|
+
`dim_head == 2 * coords` divides cleanly and passed validation, but leaves a one-band ladder
|
|
69
|
+
with nowhere to descend: `torch.linspace(hi, lo, 1)` returns `[hi]`, so the lone band lands on
|
|
70
|
+
the fast end, `r_max` is discarded entirely, and the encoding becomes periodic with period
|
|
71
|
+
`2 · r_min` across the whole domain — offsets of `0`, `2·r_min`, `4·r_min` … all produce an
|
|
72
|
+
identical attention logit. It failed silently; it is now rejected at construction.
|
|
73
|
+
|
|
9
74
|
## [4.2.0] — 2026-07-14
|
|
10
75
|
|
|
11
76
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stackformers
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.3.0
|
|
4
4
|
Summary: Typed, composable, SOLID transformer library for PyTorch
|
|
5
5
|
Project-URL: Homepage, https://github.com/Red-Eyed/stackformers
|
|
6
6
|
Project-URL: Repository, https://github.com/Red-Eyed/stackformers
|
|
@@ -17,6 +17,11 @@ from stackformers.feedforward.factory import build_ff
|
|
|
17
17
|
from stackformers.feedforward.protocols import FeedForward
|
|
18
18
|
from stackformers.feedforward.swiglu import SwiGLU
|
|
19
19
|
from stackformers.layers import TransformerLayer
|
|
20
|
+
from stackformers.mlm.config import MLMWrapperConfig
|
|
21
|
+
from stackformers.mlm.head import RegressionHead
|
|
22
|
+
from stackformers.mlm.masking import RandomMasking
|
|
23
|
+
from stackformers.mlm.protocols import EncoderLike, MaskingStrategy, ReconstructionHead
|
|
24
|
+
from stackformers.mlm.wrapper import MLMOutput, MLMWrapper
|
|
20
25
|
from stackformers.norm.config import LayerNormConfig, RMSNormConfig
|
|
21
26
|
from stackformers.norm.factory import NormConfig, build_norm
|
|
22
27
|
from stackformers.norm.protocols import Norm
|
|
@@ -85,6 +90,9 @@ __all__ = [
|
|
|
85
90
|
"CrossAttn",
|
|
86
91
|
"FeedForward",
|
|
87
92
|
"Norm",
|
|
93
|
+
"EncoderLike",
|
|
94
|
+
"MaskingStrategy",
|
|
95
|
+
"ReconstructionHead",
|
|
88
96
|
# configs — attention
|
|
89
97
|
"SelfAttentionConfig",
|
|
90
98
|
"CrossAttentionConfig",
|
|
@@ -133,4 +141,10 @@ __all__ = [
|
|
|
133
141
|
"CrossAttenderConfig",
|
|
134
142
|
"CrossAttender",
|
|
135
143
|
"plain_cross_attender_config",
|
|
144
|
+
# mlm
|
|
145
|
+
"MLMWrapperConfig",
|
|
146
|
+
"RandomMasking",
|
|
147
|
+
"RegressionHead",
|
|
148
|
+
"MLMWrapper",
|
|
149
|
+
"MLMOutput",
|
|
136
150
|
]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# mlm
|
|
2
|
+
|
|
3
|
+
A masked-token-reconstruction auxiliary loss, wrapped around any encoder that satisfies `EncoderLike`. Domain-agnostic — it has no idea what a token represents.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
import torch
|
|
9
|
+
from stackformers import (
|
|
10
|
+
MLMWrapper,
|
|
11
|
+
MLMWrapperConfig,
|
|
12
|
+
TransformerEncoder,
|
|
13
|
+
make_padded_input,
|
|
14
|
+
plain_encoder_config,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
encoder = TransformerEncoder(plain_encoder_config(dim=512, heads=8, num_layers=6))
|
|
18
|
+
mlm_wrapper = MLMWrapper(MLMWrapperConfig(dim=512, mask_ratio=0.15))
|
|
19
|
+
|
|
20
|
+
x = torch.randn(2, 128, 512)
|
|
21
|
+
mask = torch.ones(2, 128, dtype=torch.bool)
|
|
22
|
+
input = make_padded_input(x, mask)
|
|
23
|
+
|
|
24
|
+
res = mlm_wrapper(input, encoder)
|
|
25
|
+
|
|
26
|
+
task_loss = my_task_head(res.out) # res.out is always the clean, unmasked encoder output
|
|
27
|
+
loss = task_loss + 0.1 * res.mlm_loss # zero automatically in eval — nothing to branch on
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
One call site covers both modes: in training, `mlm_wrapper` runs the encoder twice internally — once clean (returned as `res.out`) and once on a separately-masked copy (used only to compute `res.mlm_loss`) — so masking never reaches whatever consumes `res.out`. In eval, it runs the encoder once and `res.mlm_loss` is a constant zero. Pretraining with no main task yet — just drop `task_loss` and train on `res.mlm_loss` alone.
|
|
31
|
+
|
|
32
|
+
`mlm_wrapper` and `encoder` are separate modules — `encoder` is passed in at call time, never stored, so `mlm_wrapper.parameters()` holds only `mask_token`, `masking_strategy`, and `head`. Build one optimizer over both: `itertools.chain(encoder.parameters(), mlm_wrapper.parameters())`. And since they're separate modules, `.train()`/`.eval()` on one does not propagate to the other — call both, or register both as submodules of a shared parent so one call does.
|
|
33
|
+
|
|
34
|
+
## Design
|
|
35
|
+
|
|
36
|
+
**`out` is always clean; only `mlm_loss` ever reflects masking.** `forward(input, encoder)` runs `encoder(input)` unconditionally for `out`. In training it additionally runs `encoder` a second time on a masked copy, purely to produce `mlm_loss` — that corrupted copy is never returned. This means the main pipeline sees byte-identical output whether or not the aux loss is being trained alongside it, and callers never need to special-case which pass produced `out`.
|
|
37
|
+
|
|
38
|
+
**Training/eval is the one branch `forward()` is allowed, because it isn't tensor control flow.** `self.training` gates whether the second (masked) pass runs — the same mechanism `nn.Dropout` and `nn.BatchNorm` already use. It lets callers invoke `mlm_wrapper(input, encoder)` unconditionally in both modes, instead of an external `if training: ...` at every call site.
|
|
39
|
+
|
|
40
|
+
**A wrapper over an encoder supplied at call time, not one it owns.** `MLMWrapper` never stores an encoder — `forward` takes one as an argument, so nothing is registered as a submodule and `mlm_wrapper.parameters()` never includes the encoder's weights.
|
|
41
|
+
|
|
42
|
+
**The mask token is owned state, not an injected collaborator.** It's the wrapper's own learned parameter, analogous to a `nn.Linear`'s weight — not swappable behavior, so it isn't a `Protocol`.
|
|
43
|
+
|
|
44
|
+
**The reconstruction target is always `input.x.detach()`, unconditionally.** Not a config flag: computing a detached target is the only behavior the masked pass ever has, so there is nothing to branch on. This severs the gradient path from the reconstruction loss back to whatever produced `input.x`, which removes the standard collapse shortcut for self-supervised regression targets (drive every token toward one constant vector, and reconstruction becomes trivial). See `tests/mlm/test_wrapper.py` for the gradient test that verifies this directly.
|
|
45
|
+
|
|
46
|
+
**No layout dispatch in the wrapper.** `MaskingStrategy` returns a boolean tensor shaped like `input.x`'s leading dims — `(b, n)` for `PaddedInput`, `(nt,)` for `PackedInput`. `torch.where` and boolean advanced indexing both broadcast/gather correctly against either shape, so `MLMWrapper.forward` never matches on the sequence variant; only `RandomMasking` does, internally.
|
|
47
|
+
|
|
48
|
+
**Random masking needs no packed-sequence boundary awareness.** Each token's masking decision is independent of every other token's, so document identity (`cu_seqlens`) never enters the decision. Boundary-awareness only becomes necessary for a contiguous-span (blockwise) strategy, which could otherwise straddle two packed documents — add that logic when that strategy is added, not before.
|
|
49
|
+
|
|
50
|
+
## Extending
|
|
51
|
+
|
|
52
|
+
To add a masking strategy (e.g. blockwise), write an `nn.Module` satisfying `MaskingStrategy` and pass it as `MLMWrapper(..., masking_strategy=...)`.
|
|
53
|
+
|
|
54
|
+
To add a reconstruction target type (e.g. a discretized/codebook target for BEiT-style cross-entropy, or a contrastive target), write an `nn.Module` satisfying `ReconstructionHead` and pass it as `MLMWrapper(..., head=...)`. Prediction and loss are scored together by one call — they're a coupled choice (a cross-entropy loss over a raw regression output isn't meaningful), not two independently pluggable axes.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MLMWrapperConfig(BaseModel):
|
|
7
|
+
"""Config for MLMWrapper: token dimension and corruption ratio."""
|
|
8
|
+
|
|
9
|
+
dim: int = Field(gt=0)
|
|
10
|
+
mask_ratio: float = Field(default=0.15, gt=0.0, lt=1.0)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import torch.nn as nn
|
|
4
|
+
import torch.nn.functional as F
|
|
5
|
+
from jaxtyping import Float
|
|
6
|
+
from torch import Tensor
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RegressionHead(nn.Module):
|
|
10
|
+
"""Reconstruct masked tokens via a linear projection, scored with MSE.
|
|
11
|
+
|
|
12
|
+
The projection gives the encoder a dedicated place to specialise for
|
|
13
|
+
reconstruction, so the shared representation isn't forced to double as the
|
|
14
|
+
literal token vector at every layer.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, dim: int) -> None:
|
|
18
|
+
super().__init__()
|
|
19
|
+
self.proj = nn.Linear(dim, dim)
|
|
20
|
+
|
|
21
|
+
def forward(
|
|
22
|
+
self,
|
|
23
|
+
encoder_output_at_masked: Float[Tensor, "m d"],
|
|
24
|
+
target_at_masked: Float[Tensor, "m d"],
|
|
25
|
+
) -> Float[Tensor, ""]:
|
|
26
|
+
prediction = self.proj(encoder_output_at_masked)
|
|
27
|
+
return F.mse_loss(prediction, target_at_masked)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import torch
|
|
4
|
+
import torch.nn as nn
|
|
5
|
+
from jaxtyping import Bool
|
|
6
|
+
from torch import Tensor
|
|
7
|
+
|
|
8
|
+
from stackformers.sequence import PackedInput, PaddedInput, SequenceInput
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RandomMasking(nn.Module):
|
|
12
|
+
"""Mask each valid token independently with probability mask_ratio.
|
|
13
|
+
|
|
14
|
+
Every token's masking decision is drawn independently, so packed sequences need
|
|
15
|
+
no document-boundary awareness here — that only becomes necessary for a
|
|
16
|
+
contiguous-span (blockwise) strategy, which could otherwise straddle two packed
|
|
17
|
+
documents.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, mask_ratio: float = 0.15) -> None:
|
|
21
|
+
super().__init__()
|
|
22
|
+
self.mask_ratio = mask_ratio
|
|
23
|
+
|
|
24
|
+
def forward(self, input: SequenceInput) -> Bool[Tensor, "*batch"]:
|
|
25
|
+
match input:
|
|
26
|
+
case PaddedInput(mask=mask):
|
|
27
|
+
candidate = torch.rand(mask.shape, device=mask.device) < self.mask_ratio
|
|
28
|
+
return candidate & mask
|
|
29
|
+
case PackedInput(x=x):
|
|
30
|
+
return torch.rand(x.shape[0], device=x.device) < self.mask_ratio
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Protocol, runtime_checkable
|
|
4
|
+
|
|
5
|
+
from jaxtyping import Bool, Float
|
|
6
|
+
from torch import Tensor
|
|
7
|
+
|
|
8
|
+
from stackformers.sequence import SequenceInput
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@runtime_checkable
|
|
12
|
+
class EncoderLike(Protocol):
|
|
13
|
+
"""Any encoder mapping a sequence to per-token embeddings of the same layout.
|
|
14
|
+
|
|
15
|
+
Implementations: Encoder, or any TransformerEncoderBase subclass.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __call__(self, input: SequenceInput) -> Tensor: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@runtime_checkable
|
|
22
|
+
class MaskingStrategy(Protocol):
|
|
23
|
+
"""Select which token positions to corrupt for reconstruction.
|
|
24
|
+
|
|
25
|
+
Returns True at positions to mask, shaped like the leading (non-feature) dims of
|
|
26
|
+
input.x — (b, n) for PaddedInput, (nt,) for PackedInput. Implementations dispatch
|
|
27
|
+
on the SequenceInput variant internally, so callers never need to know the layout.
|
|
28
|
+
Implementation: RandomMasking.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __call__(self, input: SequenceInput) -> Bool[Tensor, "*batch"]: ...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@runtime_checkable
|
|
35
|
+
class ReconstructionHead(Protocol):
|
|
36
|
+
"""Predict masked tokens from encoder output and score against the clean target.
|
|
37
|
+
|
|
38
|
+
Both arguments are already gathered down to just the masked positions (m = number
|
|
39
|
+
of masked tokens across the batch or pack). Returns a scalar loss.
|
|
40
|
+
Implementation: RegressionHead.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __call__(
|
|
44
|
+
self,
|
|
45
|
+
encoder_output_at_masked: Float[Tensor, "m d"],
|
|
46
|
+
target_at_masked: Float[Tensor, "m d"],
|
|
47
|
+
) -> Float[Tensor, ""]: ...
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import NamedTuple
|
|
4
|
+
|
|
5
|
+
import torch
|
|
6
|
+
import torch.nn as nn
|
|
7
|
+
from jaxtyping import Float
|
|
8
|
+
from torch import Tensor
|
|
9
|
+
|
|
10
|
+
from stackformers.mlm.config import MLMWrapperConfig
|
|
11
|
+
from stackformers.mlm.head import RegressionHead
|
|
12
|
+
from stackformers.mlm.masking import RandomMasking
|
|
13
|
+
from stackformers.mlm.protocols import EncoderLike, MaskingStrategy, ReconstructionHead
|
|
14
|
+
from stackformers.sequence import SequenceInput
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MLMOutput(NamedTuple):
|
|
18
|
+
out: Float[Tensor, "*batch d"]
|
|
19
|
+
mlm_loss: Float[Tensor, ""]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MLMWrapper(nn.Module):
|
|
23
|
+
"""Masked-token-reconstruction auxiliary loss over an encoder supplied at call time.
|
|
24
|
+
|
|
25
|
+
Does not store or own an encoder — none is passed to __init__, and forward() takes
|
|
26
|
+
one as an argument instead of holding a reference across calls. This module's own
|
|
27
|
+
parameter tree (mask_token, masking_strategy, head) is exactly what it owns; the
|
|
28
|
+
encoder stays wherever the caller already keeps it, with no duplicate registration
|
|
29
|
+
under this wrapper.
|
|
30
|
+
|
|
31
|
+
`out` is always the encoder's clean, unmasked output — masking is invisible to
|
|
32
|
+
whatever consumes it, so the main pipeline behaves identically whether or not this
|
|
33
|
+
aux loss is being trained alongside it. Only `mlm_loss` ever reflects that masking
|
|
34
|
+
happened. In training mode that costs a second encoder forward pass on a
|
|
35
|
+
separately-masked copy of input; the two passes share weights but never interfere,
|
|
36
|
+
since the corrupted copy built for the loss is never returned as `out`. In eval mode
|
|
37
|
+
only the one clean pass runs and `mlm_loss` reports a constant zero, so callers can
|
|
38
|
+
invoke this unconditionally in both modes without an if-training branch of their
|
|
39
|
+
own — the same role self.training already plays in nn.Dropout or nn.BatchNorm.
|
|
40
|
+
|
|
41
|
+
The reconstruction target is always the encoder's own clean input embedding,
|
|
42
|
+
detached — so this loss trains the encoder and head, never whatever produced
|
|
43
|
+
input.x. That removes the representation-collapse shortcut a trainable tokenizer
|
|
44
|
+
would otherwise have available (drive every token toward a constant vector to make
|
|
45
|
+
reconstruction trivial).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
config: MLMWrapperConfig,
|
|
51
|
+
masking_strategy: MaskingStrategy | None = None,
|
|
52
|
+
head: ReconstructionHead | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
super().__init__()
|
|
55
|
+
self.mask_token = nn.Parameter(torch.empty(config.dim))
|
|
56
|
+
nn.init.trunc_normal_(self.mask_token, std=0.02, a=-0.04, b=0.04)
|
|
57
|
+
self.masking_strategy = (
|
|
58
|
+
masking_strategy if masking_strategy is not None else RandomMasking(config.mask_ratio)
|
|
59
|
+
)
|
|
60
|
+
self.head = head if head is not None else RegressionHead(config.dim)
|
|
61
|
+
|
|
62
|
+
def _masked_loss(self, input: SequenceInput, encoder: EncoderLike) -> Tensor:
|
|
63
|
+
should_mask = self.masking_strategy(input)
|
|
64
|
+
target = input.x.detach()
|
|
65
|
+
# should_mask's shape always matches input.x's leading dims, so this select and
|
|
66
|
+
# the boolean indexing below both work for PaddedInput and PackedInput alike.
|
|
67
|
+
corrupted_x = torch.where(should_mask.unsqueeze(-1), self.mask_token, input.x)
|
|
68
|
+
masked_output = encoder(input._replace(x=corrupted_x))
|
|
69
|
+
return self.head(masked_output[should_mask], target[should_mask])
|
|
70
|
+
|
|
71
|
+
def forward(self, input: SequenceInput, encoder: EncoderLike) -> MLMOutput:
|
|
72
|
+
clean_output = encoder(input)
|
|
73
|
+
if self.training:
|
|
74
|
+
mlm_loss = self._masked_loss(input, encoder)
|
|
75
|
+
return MLMOutput(out=clean_output, mlm_loss=mlm_loss)
|
|
76
|
+
return MLMOutput(out=clean_output, mlm_loss=clean_output.new_zeros(()))
|
|
@@ -8,12 +8,14 @@ Positional encodings applied to Q and K tensors inside attention — not to the
|
|
|
8
8
|
|
|
9
9
|
RoPE's shortest wavelength is fixed at 2π coordinate units, and `base` only stretches the long end of the band range. So `base` lands the ladder correctly only when tokens sit *one unit apart* — true for text, true for a patch grid, meaningless for scattered continuous coordinates. Positions normalised to `[0, 1]` leave every band longer than the whole domain, so the encoding barely rotates and carries almost no signal; and `base=10000` on a 14×14 patch grid leaves 14 of 16 bands frozen.
|
|
10
10
|
|
|
11
|
-
`RotaryEmbeddingND` (`RoPENDConfig`) drops `base` entirely and builds the ladder from the two numbers that actually mean something, in any number of dimensions
|
|
11
|
+
`RotaryEmbeddingND` (`RoPENDConfig`) drops `base` entirely and builds the ladder from the two numbers that actually mean something, in any number of dimensions. Both ends are the same rule — **a half turn over the scale in question, `ω = π / scale`**:
|
|
12
12
|
|
|
13
|
-
- `r_min` — the finest separation the model must resolve.
|
|
14
|
-
- `r_max` — the domain diameter.
|
|
13
|
+
- `r_min` — the finest separation the model must resolve. Turned through π on the fastest band, so the shortest wavelength is `2·r_min`: the Nyquist limit, past which distinct offsets alias onto the same rotation. Measure it as a low percentile of the nearest-neighbour distance.
|
|
14
|
+
- `r_max` — the domain diameter. Attention sees *signed* offsets, spanning `[−r_max, +r_max]` — a width of `2·r_max`, not `r_max` — so the slowest band is given a half turn over that full width. It cannot wrap, which leaves it monotone in the offset: a coarse ramp the model reads as displacement. Measure it as a high percentile of the pairwise distance distribution.
|
|
15
15
|
|
|
16
|
-
The resulting
|
|
16
|
+
There is no third knob, and no constant to tune: two measured distances fix the ladder. The resulting band range depends only on the *dynamic range* `r_max / r_min`, so metres, pixels and millimetres all give the identical encoding — the property `base` never had. `RoPE1DConfig` and `RoPE2DConfig` keep `base` and are unchanged; use them for text and grids.
|
|
17
|
+
|
|
18
|
+
`dim_head` must be at least `4 · coords`, so that every axis gets at least two bands. At one band per axis the ladder has nowhere to descend: it collapses onto the fast end, `r_max` is never reached, and the encoding silently becomes periodic with period `2·r_min` across the whole domain. `RoPENDConfig` rejects that at construction.
|
|
17
19
|
|
|
18
20
|
**Centre your coordinates.** Not for invariance — RoPE is translation-invariant exactly, since the query and key rotations cancel into `ω·(pᵢ − pⱼ)`. It is for float32: the angle is `ω·p`, and coordinates far from the origin push it into the hundreds of radians and spend the mantissa before the cosine is taken. A 1e5 offset costs three orders of magnitude of accuracy. This cannot be done inside the module — cross-attention must subtract the *same* constant from query and key positions, and the module sees them separately — so subtract the centroid at the input boundary.
|
|
19
21
|
|
|
@@ -53,9 +53,10 @@ class RoPENDConfig(BaseModel):
|
|
|
53
53
|
Takes no ``base``. RoPE's ``base`` ladder pins its fastest band at a wavelength of 2π
|
|
54
54
|
*coordinate units* regardless of the base, so it only lands correctly when tokens sit one
|
|
55
55
|
unit apart — true for text and patch grids, meaningless for continuous coordinates. The
|
|
56
|
-
band range is set by the data instead: r_min fixes the fast end, r_max the slow end
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
band range is set by the data instead: r_min fixes the fast end, r_max the slow end, and
|
|
57
|
+
each is a half turn over the scale it names (ω = π / scale). Nothing else is free. The
|
|
58
|
+
ladder's shape then depends only on the ratio r_max / r_min, so the units the coordinates
|
|
59
|
+
happen to be expressed in stop mattering.
|
|
59
60
|
"""
|
|
60
61
|
|
|
61
62
|
kind: Literal["rope_nd"] = "rope_nd"
|
|
@@ -66,29 +67,22 @@ class RoPENDConfig(BaseModel):
|
|
|
66
67
|
r_min: float = Field(
|
|
67
68
|
gt=0.0,
|
|
68
69
|
description=(
|
|
69
|
-
"Finest separation between two nodes that the model must tell apart.
|
|
70
|
-
" shortest wavelength
|
|
71
|
-
" alias onto the same rotation. Measure it as a
|
|
72
|
-
" nearest-neighbour distance, not the minimum, which is noise."
|
|
70
|
+
"Finest separation between two nodes that the model must tell apart. Gets a half"
|
|
71
|
+
" turn on the fastest band, so the shortest wavelength is 2*r_min — the Nyquist"
|
|
72
|
+
" limit, below which distinct offsets alias onto the same rotation. Measure it as a"
|
|
73
|
+
" low percentile of the nearest-neighbour distance, not the minimum, which is noise."
|
|
73
74
|
),
|
|
74
75
|
)
|
|
75
76
|
r_max: float = Field(
|
|
76
77
|
gt=0.0,
|
|
77
78
|
description=(
|
|
78
|
-
"Diameter of the domain — the largest offset that must stay distinguishable.
|
|
79
|
-
"
|
|
79
|
+
"Diameter of the domain — the largest offset that must stay distinguishable."
|
|
80
|
+
" Attention sees signed offsets spanning [-r_max, +r_max], so the slowest band is"
|
|
81
|
+
" given a half turn over that full width of 2*r_max: it never wraps, which leaves it"
|
|
82
|
+
" monotone in the offset. Measure it as a high percentile of the pairwise distance"
|
|
80
83
|
" distribution, not the maximum, which is an outlier."
|
|
81
84
|
),
|
|
82
85
|
)
|
|
83
|
-
headroom: float = Field(
|
|
84
|
-
default=4.0,
|
|
85
|
-
gt=1.0,
|
|
86
|
-
description=(
|
|
87
|
-
"How far the longest wavelength reaches past r_max. Keeps the slowest band monotone"
|
|
88
|
-
" across the whole domain instead of wrapping, so it can act as a coarse absolute"
|
|
89
|
-
" coordinate. Llama's defaults sit at roughly 13x; 2-4x is ample here."
|
|
90
|
-
),
|
|
91
|
-
)
|
|
92
86
|
|
|
93
87
|
@model_validator(mode="after")
|
|
94
88
|
def _check(self) -> RoPENDConfig:
|
|
@@ -99,6 +93,14 @@ class RoPENDConfig(BaseModel):
|
|
|
99
93
|
f" dim_head is split into {self.coords} per-axis blocks, and each block is"
|
|
100
94
|
" rotated in pairs of channels."
|
|
101
95
|
)
|
|
96
|
+
if self.bands_per_axis < 2:
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"dim_head ({self.dim_head}) leaves {self.bands_per_axis} band per axis; at"
|
|
99
|
+
f" least 2 bands are needed, so dim_head must be at least {4 * self.coords}."
|
|
100
|
+
" A lone band lands on the fast end of the ladder and r_max is never reached,"
|
|
101
|
+
" leaving the encoding periodic with period 2 * r_min across the whole domain:"
|
|
102
|
+
" every offset an integer number of periods apart becomes indistinguishable."
|
|
103
|
+
)
|
|
102
104
|
if self.r_max <= self.r_min:
|
|
103
105
|
raise ValueError(f"r_max ({self.r_max}) must exceed r_min ({self.r_min}).")
|
|
104
106
|
return self
|
|
@@ -18,18 +18,28 @@ def _frequency_ladder(config: RoPENDConfig) -> Tensor:
|
|
|
18
18
|
at ω=1, i.e. a wavelength of exactly 2π coordinate units, whatever the base — so ``base``
|
|
19
19
|
can only stretch the slow end, and the ladder lands correctly only when tokens happen to
|
|
20
20
|
sit one unit apart. That holds for text and for patch grids; it is meaningless for
|
|
21
|
-
continuous coordinates, where the
|
|
21
|
+
continuous coordinates, where the band range is fixed by the data instead.
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
finest separation the model must resolve)
|
|
25
|
-
ω_lo = 2π / (headroom · r_max) → longest wavelength spans the whole domain
|
|
23
|
+
Both ends are a half turn over the scale they name — ω = π / scale:
|
|
26
24
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
ω_hi = π / r_min half-period r_min. The finest separation the model must resolve
|
|
26
|
+
is turned through π, the furthest two points can be driven apart
|
|
27
|
+
on a circle. Any faster is past Nyquist, and distinct offsets
|
|
28
|
+
start aliasing onto the same rotation.
|
|
29
|
+
|
|
30
|
+
ω_lo = π / (2·r_max) half-period 2·r_max. Attention sees *signed* offsets, spanning
|
|
31
|
+
[−r_max, +r_max] — a width of 2·r_max, not r_max — so the slowest
|
|
32
|
+
band turns through at most π across the entire domain. It cannot
|
|
33
|
+
wrap, which leaves it monotone in the offset: a coarse ramp the
|
|
34
|
+
model can read directly as displacement.
|
|
35
|
+
|
|
36
|
+
Nothing here is tuned, and there is no knob left to set: two measured distances fix the
|
|
37
|
+
ladder. Its *shape* depends only on the dynamic range r_max / r_min, so the encoding is
|
|
38
|
+
invariant to the units the coordinates are expressed in — metres, pixels, or normalised to
|
|
39
|
+
[0, 1] all give the same result. That is precisely the property ``base`` does not have.
|
|
30
40
|
"""
|
|
31
41
|
omega_hi = math.pi / config.r_min
|
|
32
|
-
omega_lo =
|
|
42
|
+
omega_lo = math.pi / (2.0 * config.r_max)
|
|
33
43
|
steps = torch.linspace(math.log(omega_hi), math.log(omega_lo), config.bands_per_axis)
|
|
34
44
|
return torch.exp(steps) # fastest band first, matching the 1-D/2-D convention
|
|
35
45
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import torch
|
|
4
|
+
import torch.nn as nn
|
|
5
|
+
|
|
6
|
+
from stackformers.mlm.head import RegressionHead
|
|
7
|
+
|
|
8
|
+
M, D = 12, 16
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_regression_head_output_is_scalar(device_dtype: tuple[torch.device, torch.dtype]) -> None:
|
|
12
|
+
device, dtype = device_dtype
|
|
13
|
+
head = RegressionHead(dim=D).to(device=device, dtype=dtype)
|
|
14
|
+
prediction_in = torch.randn(M, D, device=device, dtype=dtype)
|
|
15
|
+
target = torch.randn(M, D, device=device, dtype=dtype)
|
|
16
|
+
loss = head(prediction_in, target)
|
|
17
|
+
assert loss.shape == ()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_regression_head_zero_when_projection_matches_target(device: torch.device) -> None:
|
|
21
|
+
head = RegressionHead(dim=D).to(device)
|
|
22
|
+
nn.init.zeros_(head.proj.weight)
|
|
23
|
+
nn.init.zeros_(head.proj.bias)
|
|
24
|
+
target = torch.zeros(M, D, device=device)
|
|
25
|
+
prediction_in = torch.randn(M, D, device=device) # proj(x) == 0 regardless, weight/bias are 0
|
|
26
|
+
loss = head(prediction_in, target)
|
|
27
|
+
assert torch.allclose(loss, torch.zeros_like(loss))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_regression_head_gradients_flow(device: torch.device) -> None:
|
|
31
|
+
head = RegressionHead(dim=D).to(device)
|
|
32
|
+
prediction_in = torch.randn(M, D, device=device, requires_grad=True)
|
|
33
|
+
target = torch.randn(M, D, device=device)
|
|
34
|
+
loss = head(prediction_in, target)
|
|
35
|
+
loss.backward()
|
|
36
|
+
assert prediction_in.grad is not None
|
|
37
|
+
assert head.proj.weight.grad is not None
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import torch
|
|
4
|
+
|
|
5
|
+
from stackformers.mlm.masking import RandomMasking
|
|
6
|
+
from stackformers.sequence import make_packed_input, make_padded_input
|
|
7
|
+
|
|
8
|
+
B, N, D = 4, 64, 16
|
|
9
|
+
NT = 20
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_random_masking_padded_shape(device_dtype: tuple[torch.device, torch.dtype]) -> None:
|
|
13
|
+
device, dtype = device_dtype
|
|
14
|
+
x = torch.randn(B, N, D, device=device, dtype=dtype)
|
|
15
|
+
mask = torch.ones(B, N, dtype=torch.bool, device=device)
|
|
16
|
+
input = make_padded_input(x, mask)
|
|
17
|
+
should_mask = RandomMasking(mask_ratio=0.5)(input)
|
|
18
|
+
assert should_mask.shape == mask.shape
|
|
19
|
+
assert should_mask.dtype == torch.bool
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_random_masking_packed_shape(device_dtype: tuple[torch.device, torch.dtype]) -> None:
|
|
23
|
+
device, dtype = device_dtype
|
|
24
|
+
x = torch.randn(NT, D, device=device, dtype=dtype)
|
|
25
|
+
cu = torch.tensor([0, 12, 20], dtype=torch.int32, device=device)
|
|
26
|
+
input = make_packed_input(x, cu, max_seqlen=12)
|
|
27
|
+
should_mask = RandomMasking(mask_ratio=0.5)(input)
|
|
28
|
+
assert should_mask.shape == (NT,)
|
|
29
|
+
assert should_mask.dtype == torch.bool
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_random_masking_never_masks_padding(device_dtype: tuple[torch.device, torch.dtype]) -> None:
|
|
33
|
+
device, dtype = device_dtype
|
|
34
|
+
x = torch.randn(B, N, D, device=device, dtype=dtype)
|
|
35
|
+
mask = torch.ones(B, N, dtype=torch.bool, device=device)
|
|
36
|
+
mask[:, N // 2 :] = False # second half is padding
|
|
37
|
+
input = make_padded_input(x, mask)
|
|
38
|
+
should_mask = RandomMasking(mask_ratio=0.9)(input)
|
|
39
|
+
assert not (should_mask & ~mask).any()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_random_masking_respects_ratio_roughly(
|
|
43
|
+
device_dtype: tuple[torch.device, torch.dtype],
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Statistical check over a large sample — not exact, but far enough from any other ratio."""
|
|
46
|
+
device, dtype = device_dtype
|
|
47
|
+
torch.manual_seed(0)
|
|
48
|
+
x = torch.randn(1, 10_000, D, device=device, dtype=dtype)
|
|
49
|
+
mask = torch.ones(1, 10_000, dtype=torch.bool, device=device)
|
|
50
|
+
input = make_padded_input(x, mask)
|
|
51
|
+
should_mask = RandomMasking(mask_ratio=0.3)(input)
|
|
52
|
+
fraction = should_mask.float().mean().item()
|
|
53
|
+
assert 0.25 < fraction < 0.35
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
import torch
|
|
5
|
+
import torch.nn as nn
|
|
6
|
+
|
|
7
|
+
from stackformers.attention.config import SelfAttentionConfig
|
|
8
|
+
from stackformers.attention.self_attn import SelfAttention
|
|
9
|
+
from stackformers.encoder import Encoder
|
|
10
|
+
from stackformers.feedforward.config import SwiGLUConfig
|
|
11
|
+
from stackformers.feedforward.swiglu import SwiGLU
|
|
12
|
+
from stackformers.layers import TransformerLayer
|
|
13
|
+
from stackformers.mlm.config import MLMWrapperConfig
|
|
14
|
+
from stackformers.mlm.wrapper import MLMOutput, MLMWrapper
|
|
15
|
+
from stackformers.norm.config import RMSNormConfig
|
|
16
|
+
from stackformers.norm.factory import build_norm
|
|
17
|
+
from stackformers.positional.none import NoPosEncoding
|
|
18
|
+
from stackformers.sequence import (
|
|
19
|
+
PackedInput,
|
|
20
|
+
PaddedInput,
|
|
21
|
+
SequenceInput,
|
|
22
|
+
make_packed_input,
|
|
23
|
+
make_padded_input,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
B, N, D, H, DH = 2, 16, 16, 4, 4
|
|
27
|
+
NUM_LAYERS = 2
|
|
28
|
+
NT = 10 # two packed seqs: 6 + 4
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AllMasking(nn.Module):
|
|
32
|
+
"""Test double: marks every valid position for masking."""
|
|
33
|
+
|
|
34
|
+
def forward(self, input: SequenceInput) -> torch.Tensor:
|
|
35
|
+
match input:
|
|
36
|
+
case PaddedInput(mask=mask):
|
|
37
|
+
return mask.clone()
|
|
38
|
+
case PackedInput(x=x):
|
|
39
|
+
return torch.ones(x.shape[0], dtype=torch.bool, device=x.device)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _build_encoder(device: torch.device, dtype: torch.dtype) -> Encoder:
|
|
43
|
+
attn_cfg = SelfAttentionConfig(dim=D, heads=H, dim_head=DH)
|
|
44
|
+
ff_cfg = SwiGLUConfig(dim=D)
|
|
45
|
+
norm_cfg = RMSNormConfig(dim=D)
|
|
46
|
+
layers = [
|
|
47
|
+
TransformerLayer(
|
|
48
|
+
self_attn=SelfAttention(attn_cfg, NoPosEncoding()),
|
|
49
|
+
ff=SwiGLU(ff_cfg),
|
|
50
|
+
norm_attn=build_norm(norm_cfg),
|
|
51
|
+
norm_ff=build_norm(norm_cfg),
|
|
52
|
+
)
|
|
53
|
+
for _ in range(NUM_LAYERS)
|
|
54
|
+
]
|
|
55
|
+
return Encoder(layers=layers, final_norm=build_norm(norm_cfg)).to(device=device, dtype=dtype)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@pytest.fixture
|
|
59
|
+
def config() -> MLMWrapperConfig:
|
|
60
|
+
return MLMWrapperConfig(dim=D, mask_ratio=0.5)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pytest.fixture
|
|
64
|
+
def x_pad(device_dtype: tuple[torch.device, torch.dtype]) -> PaddedInput:
|
|
65
|
+
device, dtype = device_dtype
|
|
66
|
+
x = torch.randn(B, N, D, device=device, dtype=dtype)
|
|
67
|
+
mask = torch.ones(B, N, dtype=torch.bool, device=device)
|
|
68
|
+
return make_padded_input(x, mask)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@pytest.fixture
|
|
72
|
+
def x_packed(device_dtype: tuple[torch.device, torch.dtype]) -> PackedInput:
|
|
73
|
+
device, dtype = device_dtype
|
|
74
|
+
if not device.type == "cuda" or dtype not in (torch.float16, torch.bfloat16):
|
|
75
|
+
pytest.skip("packed attention requires CUDA with float16 or bfloat16")
|
|
76
|
+
x = torch.randn(NT, D, device=device, dtype=dtype)
|
|
77
|
+
cu = torch.tensor([0, 6, 10], dtype=torch.int32, device=device)
|
|
78
|
+
return make_packed_input(x, cu, max_seqlen=6)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_wrapper_padded_output_shapes(
|
|
82
|
+
config: MLMWrapperConfig,
|
|
83
|
+
x_pad: PaddedInput,
|
|
84
|
+
device_dtype: tuple[torch.device, torch.dtype],
|
|
85
|
+
) -> None:
|
|
86
|
+
device, dtype = device_dtype
|
|
87
|
+
encoder = _build_encoder(device, dtype)
|
|
88
|
+
wrapper = MLMWrapper(config).to(device=device, dtype=dtype)
|
|
89
|
+
res = wrapper(x_pad, encoder)
|
|
90
|
+
assert isinstance(res, MLMOutput)
|
|
91
|
+
assert res.mlm_loss.shape == ()
|
|
92
|
+
assert res.out.shape == (B, N, D)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_wrapper_packed_output_shapes(
|
|
96
|
+
config: MLMWrapperConfig,
|
|
97
|
+
x_packed: PackedInput,
|
|
98
|
+
device_dtype: tuple[torch.device, torch.dtype],
|
|
99
|
+
) -> None:
|
|
100
|
+
device, dtype = device_dtype
|
|
101
|
+
encoder = _build_encoder(device, dtype)
|
|
102
|
+
wrapper = MLMWrapper(config).to(device=device, dtype=dtype)
|
|
103
|
+
res = wrapper(x_packed, encoder)
|
|
104
|
+
assert res.mlm_loss.shape == ()
|
|
105
|
+
assert res.out.shape == (NT, D)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_wrapper_mask_token_receives_gradient(device: torch.device) -> None:
|
|
109
|
+
config = MLMWrapperConfig(dim=D, mask_ratio=0.5)
|
|
110
|
+
encoder = _build_encoder(device, torch.float32)
|
|
111
|
+
wrapper = MLMWrapper(config).to(device)
|
|
112
|
+
x = torch.randn(B, N, D, device=device)
|
|
113
|
+
mask = torch.ones(B, N, dtype=torch.bool, device=device)
|
|
114
|
+
res = wrapper(make_padded_input(x, mask), encoder)
|
|
115
|
+
res.mlm_loss.backward()
|
|
116
|
+
assert wrapper.mask_token.grad is not None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_wrapper_does_not_own_encoder(device: torch.device) -> None:
|
|
120
|
+
"""MLMWrapper takes the encoder at call time, not at construction — its own
|
|
121
|
+
parameter tree must never include the encoder's weights, since the encoder is
|
|
122
|
+
owned and registered wherever the caller already keeps it.
|
|
123
|
+
"""
|
|
124
|
+
config = MLMWrapperConfig(dim=D, mask_ratio=0.5)
|
|
125
|
+
encoder = _build_encoder(device, torch.float32)
|
|
126
|
+
wrapper = MLMWrapper(config).to(device)
|
|
127
|
+
encoder_param_ids = {id(p) for p in encoder.parameters()}
|
|
128
|
+
wrapper_param_ids = {id(p) for p in wrapper.parameters()}
|
|
129
|
+
assert encoder_param_ids.isdisjoint(wrapper_param_ids)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_wrapper_detaches_target_from_upstream_input(device: torch.device) -> None:
|
|
133
|
+
"""Collapse guard (design doc §5): a trainable tokenizer upstream of x must get no
|
|
134
|
+
gradient from this loss, or the optimizer can collapse every token to one constant
|
|
135
|
+
vector to make reconstruction trivial.
|
|
136
|
+
|
|
137
|
+
Force every position masked, so torch.where's backward zeroes x's contribution
|
|
138
|
+
through the corrupted-x branch entirely (its condition is True everywhere, so the
|
|
139
|
+
"x" branch of the select is never taken). The only remaining path from loss back to
|
|
140
|
+
x would be through the target — and that's detached — so x's accumulated gradient
|
|
141
|
+
must be exactly zero.
|
|
142
|
+
"""
|
|
143
|
+
config = MLMWrapperConfig(dim=D, mask_ratio=0.5)
|
|
144
|
+
encoder = _build_encoder(device, torch.float32)
|
|
145
|
+
wrapper = MLMWrapper(config, masking_strategy=AllMasking()).to(device)
|
|
146
|
+
x = torch.randn(B, N, D, device=device, requires_grad=True)
|
|
147
|
+
mask = torch.ones(B, N, dtype=torch.bool, device=device)
|
|
148
|
+
res = wrapper(make_padded_input(x, mask), encoder)
|
|
149
|
+
res.mlm_loss.backward()
|
|
150
|
+
assert x.grad is not None
|
|
151
|
+
assert torch.all(x.grad == 0)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_wrapper_accepts_custom_masking_strategy(device: torch.device) -> None:
|
|
155
|
+
config = MLMWrapperConfig(dim=D, mask_ratio=0.5)
|
|
156
|
+
encoder = _build_encoder(device, torch.float32)
|
|
157
|
+
wrapper = MLMWrapper(config, masking_strategy=AllMasking()).to(device)
|
|
158
|
+
x = torch.randn(B, N, D, device=device)
|
|
159
|
+
mask = torch.ones(B, N, dtype=torch.bool, device=device)
|
|
160
|
+
res = wrapper(make_padded_input(x, mask), encoder)
|
|
161
|
+
assert res.mlm_loss.shape == ()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_wrapper_training_out_is_always_clean(device: torch.device) -> None:
|
|
165
|
+
"""out must never carry masking, even in training — only mlm_loss does. This keeps
|
|
166
|
+
the main pipeline byte-identical whether or not the MLM aux loss trains alongside
|
|
167
|
+
it, so callers never need to special-case which pass produced `out`. AllMasking
|
|
168
|
+
maximizes the contrast: if masking leaked into `out`, every token would be replaced
|
|
169
|
+
by mask_token, making it maximally different from a clean encoder(input) call.
|
|
170
|
+
"""
|
|
171
|
+
config = MLMWrapperConfig(dim=D, mask_ratio=0.5)
|
|
172
|
+
encoder = _build_encoder(device, torch.float32)
|
|
173
|
+
wrapper = MLMWrapper(config, masking_strategy=AllMasking()).to(device)
|
|
174
|
+
x = torch.randn(B, N, D, device=device)
|
|
175
|
+
mask = torch.ones(B, N, dtype=torch.bool, device=device)
|
|
176
|
+
input = make_padded_input(x, mask)
|
|
177
|
+
|
|
178
|
+
res = wrapper(input, encoder)
|
|
179
|
+
expected = encoder(input)
|
|
180
|
+
|
|
181
|
+
assert torch.equal(res.out, expected)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_wrapper_eval_mode_passes_through_encoder_unmasked(device: torch.device) -> None:
|
|
185
|
+
"""In eval mode, forward() must skip masking entirely: out matches a clean encoder
|
|
186
|
+
call exactly, and mlm_loss reports zero — so callers can invoke the wrapper
|
|
187
|
+
unconditionally in both modes without an if-training branch of their own.
|
|
188
|
+
"""
|
|
189
|
+
config = MLMWrapperConfig(dim=D, mask_ratio=0.5)
|
|
190
|
+
encoder = _build_encoder(device, torch.float32)
|
|
191
|
+
wrapper = MLMWrapper(config).to(device)
|
|
192
|
+
wrapper.eval()
|
|
193
|
+
x = torch.randn(B, N, D, device=device)
|
|
194
|
+
mask = torch.ones(B, N, dtype=torch.bool, device=device)
|
|
195
|
+
input = make_padded_input(x, mask)
|
|
196
|
+
|
|
197
|
+
res = wrapper(input, encoder)
|
|
198
|
+
expected = encoder(input)
|
|
199
|
+
|
|
200
|
+
assert torch.equal(res.out, expected)
|
|
201
|
+
assert torch.equal(res.mlm_loss, torch.zeros_like(res.mlm_loss))
|
|
@@ -11,7 +11,7 @@ from stackformers.positional.rope_nd import RotaryEmbeddingND
|
|
|
11
11
|
from tests.conftest import atol
|
|
12
12
|
|
|
13
13
|
B, N, H = 2, 16, 4
|
|
14
|
-
R_MIN, R_MAX
|
|
14
|
+
R_MIN, R_MAX = 0.5, 100.0
|
|
15
15
|
|
|
16
16
|
DIMS = [(1, 64), (2, 64), (3, 96)] # (coords, dim_head) — dim_head must divide by 2 * coords
|
|
17
17
|
|
|
@@ -19,9 +19,7 @@ DIMS = [(1, 64), (2, 64), (3, 96)] # (coords, dim_head) — dim_head must divid
|
|
|
19
19
|
@pytest.fixture(params=DIMS, ids=lambda p: f"c{p[0]}")
|
|
20
20
|
def config(request: pytest.FixtureRequest) -> RoPENDConfig:
|
|
21
21
|
coords, dim_head = request.param
|
|
22
|
-
return RoPENDConfig(
|
|
23
|
-
dim_head=dim_head, coords=coords, r_min=R_MIN, r_max=R_MAX, headroom=HEADROOM
|
|
24
|
-
)
|
|
22
|
+
return RoPENDConfig(dim_head=dim_head, coords=coords, r_min=R_MIN, r_max=R_MAX)
|
|
25
23
|
|
|
26
24
|
|
|
27
25
|
@pytest.fixture
|
|
@@ -65,15 +63,32 @@ def test_output_shape(
|
|
|
65
63
|
|
|
66
64
|
|
|
67
65
|
def test_ladder_spans_nyquist_to_domain(config: RoPENDConfig) -> None:
|
|
68
|
-
"""The whole point of dropping `base`: the band range is pinned to the data, not a constant.
|
|
66
|
+
"""The whole point of dropping `base`: the band range is pinned to the data, not a constant.
|
|
67
|
+
|
|
68
|
+
Both ends are a half turn over the scale they name — r_min at the fast end, and the full
|
|
69
|
+
signed offset width 2*r_max at the slow end — so both are stated here as half-periods.
|
|
70
|
+
"""
|
|
69
71
|
inv_freq: torch.Tensor = RotaryEmbeddingND(config).inv_freq # type: ignore[assignment]
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
assert
|
|
73
|
-
assert
|
|
72
|
+
fastest_half_period = math.pi / float(inv_freq.max())
|
|
73
|
+
slowest_half_period = math.pi / float(inv_freq.min())
|
|
74
|
+
assert fastest_half_period == pytest.approx(R_MIN, rel=1e-4) # Nyquist on the finest gap
|
|
75
|
+
assert slowest_half_period == pytest.approx(2 * R_MAX, rel=1e-4) # the whole signed range
|
|
74
76
|
assert inv_freq.shape == (config.bands_per_axis,)
|
|
75
77
|
|
|
76
78
|
|
|
79
|
+
def test_slowest_band_never_wraps(config: RoPENDConfig) -> None:
|
|
80
|
+
"""The slow end must stay monotone over every offset attention can see.
|
|
81
|
+
|
|
82
|
+
Attention sees signed offsets over [-r_max, +r_max], so the slowest band sweeps
|
|
83
|
+
omega_lo * 2 * r_max. Let that exceed pi and the band turns back on itself: two different
|
|
84
|
+
offsets in the domain collide on the same rotation, in the one band whose entire job is to
|
|
85
|
+
tell the coarse end apart.
|
|
86
|
+
"""
|
|
87
|
+
inv_freq: torch.Tensor = RotaryEmbeddingND(config).inv_freq # type: ignore[assignment]
|
|
88
|
+
sweep = float(inv_freq.min()) * 2 * R_MAX
|
|
89
|
+
assert sweep == pytest.approx(math.pi, rel=1e-4)
|
|
90
|
+
|
|
91
|
+
|
|
77
92
|
def test_units_do_not_matter(config: RoPENDConfig, device: torch.device) -> None:
|
|
78
93
|
"""Scale the coordinates and r_min/r_max together — the encoding must not move.
|
|
79
94
|
|
|
@@ -209,6 +224,17 @@ def test_rejects_indivisible_head_dim() -> None:
|
|
|
209
224
|
RoPENDConfig(dim_head=64, coords=3, r_min=R_MIN, r_max=R_MAX)
|
|
210
225
|
|
|
211
226
|
|
|
227
|
+
def test_rejects_single_band_per_axis() -> None:
|
|
228
|
+
"""dim_head == 2 * coords divides cleanly, but leaves a ladder with nowhere to descend.
|
|
229
|
+
|
|
230
|
+
The lone band lands on omega_hi and r_max is never reached, so the encoding is periodic with
|
|
231
|
+
period 2 * r_min across the entire domain — offsets of 0, 2*r_min, 4*r_min ... all give the
|
|
232
|
+
same rotation. It fails silently, which is why it is rejected at construction.
|
|
233
|
+
"""
|
|
234
|
+
with pytest.raises(ValidationError, match="at least 2 bands"):
|
|
235
|
+
RoPENDConfig(dim_head=6, coords=3, r_min=R_MIN, r_max=R_MAX)
|
|
236
|
+
|
|
237
|
+
|
|
212
238
|
def test_rejects_inverted_range() -> None:
|
|
213
239
|
with pytest.raises(ValidationError, match="must exceed r_min"):
|
|
214
240
|
RoPENDConfig(dim_head=64, coords=2, r_min=100.0, r_max=1.0)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|