stackformers 4.3.0__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.3.0 → stackformers-4.3.1}/CHANGELOG.md +21 -0
  2. {stackformers-4.3.0 → stackformers-4.3.1}/PKG-INFO +1 -1
  3. {stackformers-4.3.0 → stackformers-4.3.1}/pyproject.toml +1 -1
  4. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/mlm/README.md +1 -1
  5. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/mlm/wrapper.py +13 -4
  6. {stackformers-4.3.0 → stackformers-4.3.1}/tests/mlm/test_wrapper.py +29 -8
  7. {stackformers-4.3.0 → stackformers-4.3.1}/uv.lock +1 -1
  8. {stackformers-4.3.0 → stackformers-4.3.1}/.claudeignore +0 -0
  9. {stackformers-4.3.0 → stackformers-4.3.1}/.gitignore +0 -0
  10. {stackformers-4.3.0 → stackformers-4.3.1}/.python-version +0 -0
  11. {stackformers-4.3.0 → stackformers-4.3.1}/.vscode/settings.json +0 -0
  12. {stackformers-4.3.0 → stackformers-4.3.1}/CLAUDE.md +0 -0
  13. {stackformers-4.3.0 → stackformers-4.3.1}/Justfile +0 -0
  14. {stackformers-4.3.0 → stackformers-4.3.1}/LICENSE +0 -0
  15. {stackformers-4.3.0 → stackformers-4.3.1}/PLAN.md +0 -0
  16. {stackformers-4.3.0 → stackformers-4.3.1}/README.md +0 -0
  17. {stackformers-4.3.0 → stackformers-4.3.1}/run-remote-tests.sh +0 -0
  18. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/__init__.py +0 -0
  19. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/attention/README.md +0 -0
  20. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/attention/__init__.py +0 -0
  21. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/attention/bias.py +0 -0
  22. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/attention/config.py +0 -0
  23. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/attention/cross_attn.py +0 -0
  24. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/attention/distance_bias.py +0 -0
  25. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/attention/factory.py +0 -0
  26. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/attention/ops.py +0 -0
  27. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/attention/protocols.py +0 -0
  28. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/attention/self_attn.py +0 -0
  29. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/attention/varlen_backend.py +0 -0
  30. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/config.py +0 -0
  31. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/cross_attender.py +0 -0
  32. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/decoder.py +0 -0
  33. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/encoder.py +0 -0
  34. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/feedforward/README.md +0 -0
  35. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/feedforward/__init__.py +0 -0
  36. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/feedforward/config.py +0 -0
  37. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/feedforward/factory.py +0 -0
  38. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/feedforward/geglu.py +0 -0
  39. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/feedforward/protocols.py +0 -0
  40. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/feedforward/relu_squared.py +0 -0
  41. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/feedforward/swiglu.py +0 -0
  42. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/layers.py +0 -0
  43. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/mlm/config.py +0 -0
  44. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/mlm/head.py +0 -0
  45. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/mlm/masking.py +0 -0
  46. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/mlm/protocols.py +0 -0
  47. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/norm/README.md +0 -0
  48. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/norm/__init__.py +0 -0
  49. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/norm/config.py +0 -0
  50. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/norm/factory.py +0 -0
  51. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/norm/protocols.py +0 -0
  52. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/positional/README.md +0 -0
  53. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/positional/__init__.py +0 -0
  54. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/positional/config.py +0 -0
  55. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/positional/factory.py +0 -0
  56. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/positional/learned.py +0 -0
  57. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/positional/none.py +0 -0
  58. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/positional/protocols.py +0 -0
  59. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/positional/rope1d.py +0 -0
  60. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/positional/rope2d.py +0 -0
  61. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/positional/rope_nd.py +0 -0
  62. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/presets/README.md +0 -0
  63. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/presets/__init__.py +0 -0
  64. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/presets/cross_attender.py +0 -0
  65. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/presets/decoder.py +0 -0
  66. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/presets/encoder.py +0 -0
  67. {stackformers-4.3.0 → stackformers-4.3.1}/stackformers/sequence.py +0 -0
  68. {stackformers-4.3.0 → stackformers-4.3.1}/tests/__init__.py +0 -0
  69. {stackformers-4.3.0 → stackformers-4.3.1}/tests/attention/__init__.py +0 -0
  70. {stackformers-4.3.0 → stackformers-4.3.1}/tests/attention/test_cross_attn.py +0 -0
  71. {stackformers-4.3.0 → stackformers-4.3.1}/tests/attention/test_distance_bias.py +0 -0
  72. {stackformers-4.3.0 → stackformers-4.3.1}/tests/attention/test_kernels.py +0 -0
  73. {stackformers-4.3.0 → stackformers-4.3.1}/tests/attention/test_ops.py +0 -0
  74. {stackformers-4.3.0 → stackformers-4.3.1}/tests/attention/test_self_attn.py +0 -0
  75. {stackformers-4.3.0 → stackformers-4.3.1}/tests/attention/test_varlen_backend.py +0 -0
  76. {stackformers-4.3.0 → stackformers-4.3.1}/tests/conftest.py +0 -0
  77. {stackformers-4.3.0 → stackformers-4.3.1}/tests/feedforward/__init__.py +0 -0
  78. {stackformers-4.3.0 → stackformers-4.3.1}/tests/feedforward/test_geglu.py +0 -0
  79. {stackformers-4.3.0 → stackformers-4.3.1}/tests/feedforward/test_relu_squared.py +0 -0
  80. {stackformers-4.3.0 → stackformers-4.3.1}/tests/feedforward/test_swiglu.py +0 -0
  81. {stackformers-4.3.0 → stackformers-4.3.1}/tests/mlm/__init__.py +0 -0
  82. {stackformers-4.3.0 → stackformers-4.3.1}/tests/mlm/test_head.py +0 -0
  83. {stackformers-4.3.0 → stackformers-4.3.1}/tests/mlm/test_masking.py +0 -0
  84. {stackformers-4.3.0 → stackformers-4.3.1}/tests/positional/__init__.py +0 -0
  85. {stackformers-4.3.0 → stackformers-4.3.1}/tests/positional/test_learned_pos.py +0 -0
  86. {stackformers-4.3.0 → stackformers-4.3.1}/tests/positional/test_rope.py +0 -0
  87. {stackformers-4.3.0 → stackformers-4.3.1}/tests/positional/test_rope_nd.py +0 -0
  88. {stackformers-4.3.0 → stackformers-4.3.1}/tests/presets/__init__.py +0 -0
  89. {stackformers-4.3.0 → stackformers-4.3.1}/tests/presets/test_cross_attender.py +0 -0
  90. {stackformers-4.3.0 → stackformers-4.3.1}/tests/presets/test_decoder.py +0 -0
  91. {stackformers-4.3.0 → stackformers-4.3.1}/tests/presets/test_encoder.py +0 -0
  92. {stackformers-4.3.0 → stackformers-4.3.1}/tests/test_cross_attender.py +0 -0
  93. {stackformers-4.3.0 → stackformers-4.3.1}/tests/test_decoder.py +0 -0
  94. {stackformers-4.3.0 → stackformers-4.3.1}/tests/test_encoder.py +0 -0
  95. {stackformers-4.3.0 → stackformers-4.3.1}/tests/test_layers.py +0 -0
  96. {stackformers-4.3.0 → stackformers-4.3.1}/tests/test_norm.py +0 -0
  97. {stackformers-4.3.0 → stackformers-4.3.1}/tests/test_sequence.py +0 -0
@@ -6,6 +6,27 @@ 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
+
9
30
  ## [4.3.0] — 2026-07-17
10
31
 
11
32
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackformers
3
- Version: 4.3.0
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.3.0"
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" }
@@ -41,7 +41,7 @@ One call site covers both modes: in training, `mlm_wrapper` runs the encoder twi
41
41
 
42
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
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.
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
45
 
46
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
47
 
@@ -38,9 +38,13 @@ class MLMWrapper(nn.Module):
38
38
  invoke this unconditionally in both modes without an if-training branch of their
39
39
  own — the same role self.training already plays in nn.Dropout or nn.BatchNorm.
40
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
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
44
48
  would otherwise have available (drive every token toward a constant vector to make
45
49
  reconstruction trivial).
46
50
  """
@@ -60,8 +64,13 @@ class MLMWrapper(nn.Module):
60
64
  self.head = head if head is not None else RegressionHead(config.dim)
61
65
 
62
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
63
73
  should_mask = self.masking_strategy(input)
64
- target = input.x.detach()
65
74
  # should_mask's shape always matches input.x's leading dims, so this select and
66
75
  # the boolean indexing below both work for PaddedInput and PackedInput alike.
67
76
  corrupted_x = torch.where(should_mask.unsqueeze(-1), self.mask_token, input.x)
@@ -129,16 +129,17 @@ def test_wrapper_does_not_own_encoder(device: torch.device) -> None:
129
129
  assert encoder_param_ids.isdisjoint(wrapper_param_ids)
130
130
 
131
131
 
132
- def test_wrapper_detaches_target_from_upstream_input(device: torch.device) -> None:
132
+ def test_wrapper_severs_gradient_to_upstream_input_under_full_masking(
133
+ device: torch.device,
134
+ ) -> None:
133
135
  """Collapse guard (design doc §5): a trainable tokenizer upstream of x must get no
134
136
  gradient from this loss, or the optimizer can collapse every token to one constant
135
137
  vector to make reconstruction trivial.
136
138
 
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.
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.
142
143
  """
143
144
  config = MLMWrapperConfig(dim=D, mask_ratio=0.5)
144
145
  encoder = _build_encoder(device, torch.float32)
@@ -147,8 +148,28 @@ def test_wrapper_detaches_target_from_upstream_input(device: torch.device) -> No
147
148
  mask = torch.ones(B, N, dtype=torch.bool, device=device)
148
149
  res = wrapper(make_padded_input(x, mask), encoder)
149
150
  res.mlm_loss.backward()
150
- assert x.grad is not None
151
- assert torch.all(x.grad == 0)
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
152
173
 
153
174
 
154
175
  def test_wrapper_accepts_custom_masking_strategy(device: torch.device) -> None:
@@ -811,7 +811,7 @@ wheels = [
811
811
 
812
812
  [[package]]
813
813
  name = "stackformers"
814
- version = "4.3.0"
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
File without changes