stackformers 4.2.1__tar.gz → 4.3.1__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.
Files changed (97) hide show
  1. {stackformers-4.2.1 → stackformers-4.3.1}/CHANGELOG.md +52 -0
  2. {stackformers-4.2.1 → stackformers-4.3.1}/CLAUDE.md +1 -0
  3. {stackformers-4.2.1 → stackformers-4.3.1}/PKG-INFO +1 -1
  4. {stackformers-4.2.1 → stackformers-4.3.1}/pyproject.toml +1 -1
  5. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/__init__.py +14 -0
  6. stackformers-4.3.1/stackformers/mlm/README.md +54 -0
  7. stackformers-4.3.1/stackformers/mlm/config.py +10 -0
  8. stackformers-4.3.1/stackformers/mlm/head.py +27 -0
  9. stackformers-4.3.1/stackformers/mlm/masking.py +30 -0
  10. stackformers-4.3.1/stackformers/mlm/protocols.py +47 -0
  11. stackformers-4.3.1/stackformers/mlm/wrapper.py +85 -0
  12. stackformers-4.3.1/tests/mlm/test_head.py +37 -0
  13. stackformers-4.3.1/tests/mlm/test_masking.py +53 -0
  14. stackformers-4.3.1/tests/mlm/test_wrapper.py +222 -0
  15. stackformers-4.3.1/tests/presets/__init__.py +0 -0
  16. {stackformers-4.2.1 → stackformers-4.3.1}/uv.lock +1 -1
  17. {stackformers-4.2.1 → stackformers-4.3.1}/.claudeignore +0 -0
  18. {stackformers-4.2.1 → stackformers-4.3.1}/.gitignore +0 -0
  19. {stackformers-4.2.1 → stackformers-4.3.1}/.python-version +0 -0
  20. {stackformers-4.2.1 → stackformers-4.3.1}/.vscode/settings.json +0 -0
  21. {stackformers-4.2.1 → stackformers-4.3.1}/Justfile +0 -0
  22. {stackformers-4.2.1 → stackformers-4.3.1}/LICENSE +0 -0
  23. {stackformers-4.2.1 → stackformers-4.3.1}/PLAN.md +0 -0
  24. {stackformers-4.2.1 → stackformers-4.3.1}/README.md +0 -0
  25. {stackformers-4.2.1 → stackformers-4.3.1}/run-remote-tests.sh +0 -0
  26. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/attention/README.md +0 -0
  27. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/attention/__init__.py +0 -0
  28. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/attention/bias.py +0 -0
  29. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/attention/config.py +0 -0
  30. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/attention/cross_attn.py +0 -0
  31. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/attention/distance_bias.py +0 -0
  32. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/attention/factory.py +0 -0
  33. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/attention/ops.py +0 -0
  34. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/attention/protocols.py +0 -0
  35. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/attention/self_attn.py +0 -0
  36. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/attention/varlen_backend.py +0 -0
  37. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/config.py +0 -0
  38. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/cross_attender.py +0 -0
  39. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/decoder.py +0 -0
  40. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/encoder.py +0 -0
  41. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/feedforward/README.md +0 -0
  42. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/feedforward/__init__.py +0 -0
  43. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/feedforward/config.py +0 -0
  44. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/feedforward/factory.py +0 -0
  45. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/feedforward/geglu.py +0 -0
  46. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/feedforward/protocols.py +0 -0
  47. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/feedforward/relu_squared.py +0 -0
  48. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/feedforward/swiglu.py +0 -0
  49. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/layers.py +0 -0
  50. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/norm/README.md +0 -0
  51. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/norm/__init__.py +0 -0
  52. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/norm/config.py +0 -0
  53. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/norm/factory.py +0 -0
  54. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/norm/protocols.py +0 -0
  55. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/positional/README.md +0 -0
  56. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/positional/__init__.py +0 -0
  57. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/positional/config.py +0 -0
  58. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/positional/factory.py +0 -0
  59. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/positional/learned.py +0 -0
  60. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/positional/none.py +0 -0
  61. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/positional/protocols.py +0 -0
  62. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/positional/rope1d.py +0 -0
  63. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/positional/rope2d.py +0 -0
  64. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/positional/rope_nd.py +0 -0
  65. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/presets/README.md +0 -0
  66. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/presets/__init__.py +0 -0
  67. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/presets/cross_attender.py +0 -0
  68. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/presets/decoder.py +0 -0
  69. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/presets/encoder.py +0 -0
  70. {stackformers-4.2.1 → stackformers-4.3.1}/stackformers/sequence.py +0 -0
  71. {stackformers-4.2.1 → stackformers-4.3.1}/tests/__init__.py +0 -0
  72. {stackformers-4.2.1 → stackformers-4.3.1}/tests/attention/__init__.py +0 -0
  73. {stackformers-4.2.1 → stackformers-4.3.1}/tests/attention/test_cross_attn.py +0 -0
  74. {stackformers-4.2.1 → stackformers-4.3.1}/tests/attention/test_distance_bias.py +0 -0
  75. {stackformers-4.2.1 → stackformers-4.3.1}/tests/attention/test_kernels.py +0 -0
  76. {stackformers-4.2.1 → stackformers-4.3.1}/tests/attention/test_ops.py +0 -0
  77. {stackformers-4.2.1 → stackformers-4.3.1}/tests/attention/test_self_attn.py +0 -0
  78. {stackformers-4.2.1 → stackformers-4.3.1}/tests/attention/test_varlen_backend.py +0 -0
  79. {stackformers-4.2.1 → stackformers-4.3.1}/tests/conftest.py +0 -0
  80. {stackformers-4.2.1 → stackformers-4.3.1}/tests/feedforward/__init__.py +0 -0
  81. {stackformers-4.2.1 → stackformers-4.3.1}/tests/feedforward/test_geglu.py +0 -0
  82. {stackformers-4.2.1 → stackformers-4.3.1}/tests/feedforward/test_relu_squared.py +0 -0
  83. {stackformers-4.2.1 → stackformers-4.3.1}/tests/feedforward/test_swiglu.py +0 -0
  84. {stackformers-4.2.1/tests/positional → stackformers-4.3.1/tests/mlm}/__init__.py +0 -0
  85. {stackformers-4.2.1/tests/presets → stackformers-4.3.1/tests/positional}/__init__.py +0 -0
  86. {stackformers-4.2.1 → stackformers-4.3.1}/tests/positional/test_learned_pos.py +0 -0
  87. {stackformers-4.2.1 → stackformers-4.3.1}/tests/positional/test_rope.py +0 -0
  88. {stackformers-4.2.1 → stackformers-4.3.1}/tests/positional/test_rope_nd.py +0 -0
  89. {stackformers-4.2.1 → stackformers-4.3.1}/tests/presets/test_cross_attender.py +0 -0
  90. {stackformers-4.2.1 → stackformers-4.3.1}/tests/presets/test_decoder.py +0 -0
  91. {stackformers-4.2.1 → stackformers-4.3.1}/tests/presets/test_encoder.py +0 -0
  92. {stackformers-4.2.1 → stackformers-4.3.1}/tests/test_cross_attender.py +0 -0
  93. {stackformers-4.2.1 → stackformers-4.3.1}/tests/test_decoder.py +0 -0
  94. {stackformers-4.2.1 → stackformers-4.3.1}/tests/test_encoder.py +0 -0
  95. {stackformers-4.2.1 → stackformers-4.3.1}/tests/test_layers.py +0 -0
  96. {stackformers-4.2.1 → stackformers-4.3.1}/tests/test_norm.py +0 -0
  97. {stackformers-4.2.1 → stackformers-4.3.1}/tests/test_sequence.py +0 -0
@@ -6,6 +6,58 @@ 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.1] — 2026-07-17
10
+
11
+ ### Fixed
12
+
13
+ - **`MLMWrapper`'s masked pass leaked gradient to whatever produced `input.x`, despite
14
+ the target being detached.** `corrupted_x` was built from the live (undetached)
15
+ `input.x` at unmasked positions; self-attention mixes those into the masked
16
+ positions' predictions, so `mlm_loss` still reached back through every unmasked
17
+ position — confirmed empirically at 432/512 nonzero gradient entries under a
18
+ realistic 15% mask ratio. The existing test only covered 100% masking, which zeroes
19
+ that path as a side effect of `torch.where`'s backward and never exercised the
20
+ realistic partial-masking case.
21
+
22
+ Fixed by detaching `input.x` once, up front
23
+ (`input = input._replace(x=input.x.detach())`), so both `corrupted_x` and `target`
24
+ are built from the same severed copy. `mlm_loss` now trains only `mask_token`,
25
+ `encoder`, and `head` — never whatever produced `input.x` — regardless of mask
26
+ ratio. Added a regression test at the realistic mask ratio
27
+ (`test_wrapper_severs_gradient_to_upstream_input_under_partial_masking`) alongside
28
+ the existing full-masking one.
29
+
30
+ ## [4.3.0] — 2026-07-17
31
+
32
+ ### Added
33
+
34
+ - **`MLMWrapper`** (`stackformers/mlm/`) — a masked-token-reconstruction auxiliary loss over any
35
+ encoder satisfying `EncoderLike`, domain-agnostic about what a token represents. Takes the
36
+ encoder at call time (`forward(input, encoder)`) rather than owning it, so nothing is
37
+ registered as a submodule and `mlm_wrapper.parameters()` never includes the encoder's weights.
38
+ - `MLMWrapperConfig`, `MaskingStrategy`/`RandomMasking`, `ReconstructionHead`/`RegressionHead`,
39
+ and `MLMOutput` (`out`, `mlm_loss`).
40
+
41
+ ### Notes
42
+
43
+ - **`out` is always the encoder's clean, unmasked output; only `mlm_loss` ever reflects
44
+ masking.** In training, `forward` runs the encoder twice — once clean (returned as `out`) and
45
+ once on a separately-masked copy (used only to compute `mlm_loss`) — so the main pipeline sees
46
+ byte-identical output whether or not this loss is being trained alongside it. In eval, only the
47
+ clean pass runs and `mlm_loss` is a constant zero, gated on `self.training` the same way
48
+ `nn.Dropout` and `nn.BatchNorm` already are — so callers can invoke it unconditionally in both
49
+ modes with no `if training` branch of their own.
50
+
51
+ - **The reconstruction target is always `input.x.detach()`.** This severs the gradient path from
52
+ the loss back to whatever produced `input.x`, removing the collapse shortcut a trainable
53
+ tokenizer would otherwise have (drive every token toward one constant vector to make
54
+ reconstruction trivial).
55
+
56
+ - **`RandomMasking` needs no packed-sequence boundary awareness** — each token's masking decision
57
+ is independent of every other's, so document identity (`cu_seqlens`) never enters it. That only
58
+ becomes necessary for a contiguous-span (blockwise) strategy, which could otherwise straddle
59
+ two packed documents.
60
+
9
61
  ## [4.2.1] — 2026-07-14
10
62
 
11
63
  ### Removed
@@ -25,6 +25,7 @@ Behavior comes from injected dependencies, not constructor flags.
25
25
  | dh | dim per head |
26
26
  | w | window size |
27
27
  | nt | total tokens in packed sequence |
28
+ | m | number of masked tokens (gathered across batch/pack) |
28
29
 
29
30
  ## File organisation
30
31
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackformers
3
- Version: 4.2.1
3
+ Version: 4.3.1
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "stackformers"
7
- version = "4.2.1"
7
+ version = "4.3.1"
8
8
  description = "Typed, composable, SOLID transformer library for PyTorch"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -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
+ **`input.x` is detached once, before it is used for anything — not only as the target.** The masked pass replaces `input` with `input._replace(x=input.x.detach())` up front, so both the unmasked context fed to the encoder and the reconstruction target come from that same detached copy. Detaching only the target is not sufficient: self-attention still mixes the live, undetached unmasked positions into the masked positions' predictions, so a gradient path back to whatever produced `input.x` would otherwise survive at every unmasked position — worse, it grows with the fraction of tokens left unmasked. Severing the whole input removes the standard collapse shortcut for self-supervised regression targets (drive every token toward one constant vector, and reconstruction becomes trivial) — not just the degenerate all-masked case, but the realistic partial-masking case a target-only detach would silently miss. See `tests/mlm/test_wrapper.py::test_wrapper_severs_gradient_to_upstream_input_under_partial_masking` for the regression test.
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,85 @@
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 masked pass detaches input.x once, up front — both the unmasked context fed to
42
+ the encoder and the reconstruction target come from that same detached copy — so
43
+ mlm_loss trains the encoder, mask_token, and head, never whatever produced input.x.
44
+ Detaching only the target is not sufficient: self-attention still mixes the
45
+ unmasked (undetached) positions into the masked positions' predictions, so a live
46
+ path back to input.x would otherwise survive at every unmasked position. Severing
47
+ it entirely removes the representation-collapse shortcut a trainable tokenizer
48
+ would otherwise have available (drive every token toward a constant vector to make
49
+ reconstruction trivial).
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ config: MLMWrapperConfig,
55
+ masking_strategy: MaskingStrategy | None = None,
56
+ head: ReconstructionHead | None = None,
57
+ ) -> None:
58
+ super().__init__()
59
+ self.mask_token = nn.Parameter(torch.empty(config.dim))
60
+ nn.init.trunc_normal_(self.mask_token, std=0.02, a=-0.04, b=0.04)
61
+ self.masking_strategy = (
62
+ masking_strategy if masking_strategy is not None else RandomMasking(config.mask_ratio)
63
+ )
64
+ self.head = head if head is not None else RegressionHead(config.dim)
65
+
66
+ def _masked_loss(self, input: SequenceInput, encoder: EncoderLike) -> Tensor:
67
+ # Detached once, up front, and reused for both corrupted_x and target below —
68
+ # unmasked positions must not carry a live path back to whatever produced
69
+ # input.x either, or self-attention mixes them into the masked positions'
70
+ # predictions and mlm_loss reaches upstream anyway despite target being detached.
71
+ input = input._replace(x=input.x.detach())
72
+ target = input.x
73
+ should_mask = self.masking_strategy(input)
74
+ # should_mask's shape always matches input.x's leading dims, so this select and
75
+ # the boolean indexing below both work for PaddedInput and PackedInput alike.
76
+ corrupted_x = torch.where(should_mask.unsqueeze(-1), self.mask_token, input.x)
77
+ masked_output = encoder(input._replace(x=corrupted_x))
78
+ return self.head(masked_output[should_mask], target[should_mask])
79
+
80
+ def forward(self, input: SequenceInput, encoder: EncoderLike) -> MLMOutput:
81
+ clean_output = encoder(input)
82
+ if self.training:
83
+ mlm_loss = self._masked_loss(input, encoder)
84
+ return MLMOutput(out=clean_output, mlm_loss=mlm_loss)
85
+ return MLMOutput(out=clean_output, mlm_loss=clean_output.new_zeros(()))
@@ -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,222 @@
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_severs_gradient_to_upstream_input_under_full_masking(
133
+ device: torch.device,
134
+ ) -> None:
135
+ """Collapse guard (design doc §5): a trainable tokenizer upstream of x must get no
136
+ gradient from this loss, or the optimizer can collapse every token to one constant
137
+ vector to make reconstruction trivial.
138
+
139
+ With every position masked, x is detached before corrupted_x is built from it, so
140
+ corrupted_x is pure mask_token — independent of x entirely. x never appears in the
141
+ masked pass's graph at all, so x.grad must be exactly None after backward, not just
142
+ zero-valued.
143
+ """
144
+ config = MLMWrapperConfig(dim=D, mask_ratio=0.5)
145
+ encoder = _build_encoder(device, torch.float32)
146
+ wrapper = MLMWrapper(config, masking_strategy=AllMasking()).to(device)
147
+ x = torch.randn(B, N, D, device=device, requires_grad=True)
148
+ mask = torch.ones(B, N, dtype=torch.bool, device=device)
149
+ res = wrapper(make_padded_input(x, mask), encoder)
150
+ res.mlm_loss.backward()
151
+ assert x.grad is None
152
+
153
+
154
+ def test_wrapper_severs_gradient_to_upstream_input_under_partial_masking(
155
+ device: torch.device,
156
+ ) -> None:
157
+ """Regression test: detaching only the target is not enough to close the collapse
158
+ shortcut. With the default (partial) mask ratio, corrupted_x still holds the live x
159
+ at unmasked positions, and self-attention mixes those into the masked positions'
160
+ predictions — so mlm_loss could still reach x through that path unless x is
161
+ detached before it is used for anything, not only before it is used as the target.
162
+ This is the realistic training case; test_..._full_masking above is the degenerate
163
+ edge case that a target-only detach could still pass.
164
+ """
165
+ config = MLMWrapperConfig(dim=D, mask_ratio=0.15)
166
+ encoder = _build_encoder(device, torch.float32)
167
+ wrapper = MLMWrapper(config).to(device)
168
+ x = torch.randn(B, N, D, device=device, requires_grad=True)
169
+ mask = torch.ones(B, N, dtype=torch.bool, device=device)
170
+ res = wrapper(make_padded_input(x, mask), encoder)
171
+ res.mlm_loss.backward()
172
+ assert x.grad is None
173
+
174
+
175
+ def test_wrapper_accepts_custom_masking_strategy(device: torch.device) -> None:
176
+ config = MLMWrapperConfig(dim=D, mask_ratio=0.5)
177
+ encoder = _build_encoder(device, torch.float32)
178
+ wrapper = MLMWrapper(config, masking_strategy=AllMasking()).to(device)
179
+ x = torch.randn(B, N, D, device=device)
180
+ mask = torch.ones(B, N, dtype=torch.bool, device=device)
181
+ res = wrapper(make_padded_input(x, mask), encoder)
182
+ assert res.mlm_loss.shape == ()
183
+
184
+
185
+ def test_wrapper_training_out_is_always_clean(device: torch.device) -> None:
186
+ """out must never carry masking, even in training — only mlm_loss does. This keeps
187
+ the main pipeline byte-identical whether or not the MLM aux loss trains alongside
188
+ it, so callers never need to special-case which pass produced `out`. AllMasking
189
+ maximizes the contrast: if masking leaked into `out`, every token would be replaced
190
+ by mask_token, making it maximally different from a clean encoder(input) call.
191
+ """
192
+ config = MLMWrapperConfig(dim=D, mask_ratio=0.5)
193
+ encoder = _build_encoder(device, torch.float32)
194
+ wrapper = MLMWrapper(config, masking_strategy=AllMasking()).to(device)
195
+ x = torch.randn(B, N, D, device=device)
196
+ mask = torch.ones(B, N, dtype=torch.bool, device=device)
197
+ input = make_padded_input(x, mask)
198
+
199
+ res = wrapper(input, encoder)
200
+ expected = encoder(input)
201
+
202
+ assert torch.equal(res.out, expected)
203
+
204
+
205
+ def test_wrapper_eval_mode_passes_through_encoder_unmasked(device: torch.device) -> None:
206
+ """In eval mode, forward() must skip masking entirely: out matches a clean encoder
207
+ call exactly, and mlm_loss reports zero — so callers can invoke the wrapper
208
+ unconditionally in both modes without an if-training branch of their own.
209
+ """
210
+ config = MLMWrapperConfig(dim=D, mask_ratio=0.5)
211
+ encoder = _build_encoder(device, torch.float32)
212
+ wrapper = MLMWrapper(config).to(device)
213
+ wrapper.eval()
214
+ x = torch.randn(B, N, D, device=device)
215
+ mask = torch.ones(B, N, dtype=torch.bool, device=device)
216
+ input = make_padded_input(x, mask)
217
+
218
+ res = wrapper(input, encoder)
219
+ expected = encoder(input)
220
+
221
+ assert torch.equal(res.out, expected)
222
+ assert torch.equal(res.mlm_loss, torch.zeros_like(res.mlm_loss))
File without changes
@@ -811,7 +811,7 @@ wheels = [
811
811
 
812
812
  [[package]]
813
813
  name = "stackformers"
814
- version = "4.2.1"
814
+ version = "4.3.1"
815
815
  source = { editable = "." }
816
816
  dependencies = [
817
817
  { name = "beartype" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes