ulpwise 0.3.3__tar.gz → 0.3.4__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 (38) hide show
  1. {ulpwise-0.3.3 → ulpwise-0.3.4}/CHANGELOG.md +18 -0
  2. {ulpwise-0.3.3 → ulpwise-0.3.4}/Cargo.lock +1 -1
  3. {ulpwise-0.3.3 → ulpwise-0.3.4}/Cargo.toml +1 -1
  4. {ulpwise-0.3.3 → ulpwise-0.3.4}/PKG-INFO +19 -4
  5. {ulpwise-0.3.3 → ulpwise-0.3.4}/README.md +18 -3
  6. {ulpwise-0.3.3 → ulpwise-0.3.4}/pyproject.toml +1 -1
  7. {ulpwise-0.3.3 → ulpwise-0.3.4}/python/ulpwise/__init__.py +1 -1
  8. {ulpwise-0.3.3 → ulpwise-0.3.4}/python/ulpwise/corpus/cases.json +19 -0
  9. {ulpwise-0.3.3 → ulpwise-0.3.4}/python/ulpwise/scan.py +80 -0
  10. {ulpwise-0.3.3 → ulpwise-0.3.4}/tests/test_scan.py +94 -0
  11. {ulpwise-0.3.3 → ulpwise-0.3.4}/.github/dependabot.yml +0 -0
  12. {ulpwise-0.3.3 → ulpwise-0.3.4}/.github/workflows/ci.yml +0 -0
  13. {ulpwise-0.3.3 → ulpwise-0.3.4}/.github/workflows/release.yml +0 -0
  14. {ulpwise-0.3.3 → ulpwise-0.3.4}/.gitignore +0 -0
  15. {ulpwise-0.3.3 → ulpwise-0.3.4}/CONTRIBUTING.md +0 -0
  16. {ulpwise-0.3.3 → ulpwise-0.3.4}/LICENSE-APACHE +0 -0
  17. {ulpwise-0.3.3 → ulpwise-0.3.4}/LICENSE-MIT +0 -0
  18. {ulpwise-0.3.3 → ulpwise-0.3.4}/examples/sqrt_knife_values.py +0 -0
  19. {ulpwise-0.3.3 → ulpwise-0.3.4}/examples/torch_sqrt_conformance.py +0 -0
  20. {ulpwise-0.3.3 → ulpwise-0.3.4}/python/ulpwise/__main__.py +0 -0
  21. {ulpwise-0.3.3 → ulpwise-0.3.4}/python/ulpwise/corpus/__init__.py +0 -0
  22. {ulpwise-0.3.3 → ulpwise-0.3.4}/python/ulpwise/plugin.py +0 -0
  23. {ulpwise-0.3.3 → ulpwise-0.3.4}/python/ulpwise/survey.py +0 -0
  24. {ulpwise-0.3.3 → ulpwise-0.3.4}/src/edge.rs +0 -0
  25. {ulpwise-0.3.3 → ulpwise-0.3.4}/src/exact.rs +0 -0
  26. {ulpwise-0.3.3 → ulpwise-0.3.4}/src/knife.rs +0 -0
  27. {ulpwise-0.3.3 → ulpwise-0.3.4}/src/lib.rs +0 -0
  28. {ulpwise-0.3.3 → ulpwise-0.3.4}/src/py.rs +0 -0
  29. {ulpwise-0.3.3 → ulpwise-0.3.4}/src/ulp.rs +0 -0
  30. {ulpwise-0.3.3 → ulpwise-0.3.4}/studies/accuracy-survey-2026-09/README.md +0 -0
  31. {ulpwise-0.3.3 → ulpwise-0.3.4}/studies/accuracy-survey-2026-09/results.csv +0 -0
  32. {ulpwise-0.3.3 → ulpwise-0.3.4}/studies/accuracy-survey-2026-09/results.md +0 -0
  33. {ulpwise-0.3.3 → ulpwise-0.3.4}/studies/accuracy-survey-2026-10-half/README.md +0 -0
  34. {ulpwise-0.3.3 → ulpwise-0.3.4}/studies/accuracy-survey-2026-10-half/results.csv +0 -0
  35. {ulpwise-0.3.3 → ulpwise-0.3.4}/studies/accuracy-survey-2026-10-half/results.md +0 -0
  36. {ulpwise-0.3.3 → ulpwise-0.3.4}/tests/test_core.py +0 -0
  37. {ulpwise-0.3.3 → ulpwise-0.3.4}/tests/test_corpus.py +0 -0
  38. {ulpwise-0.3.3 → ulpwise-0.3.4}/tests/test_survey.py +0 -0
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.4 (2026-10-09)
4
+
5
+ - Regression corpus case for pytorch/rl #4504, merged upstream: `MultiCategorical.to_one_hot` iterated over `self.nvec`,
6
+ a 2-D tensor for a spec with a batch shape, so `one_hot` got a tensor as `num_classes` and raised `TypeError`, and the
7
+ 0-D sample of a shape `[1]` spec raised `IndexError` on `val[..., i]`. The case one-hot encodes a sample of
8
+ `MultiCategorical([3, 2], shape=(4, 2))` and the scalar sample of `MultiCategorical([5])`.
9
+ - `ulpwise scan`: new `dropout-never-applied` rule (medium), the first that reads a whole file rather than one
10
+ function: a `Dropout`, `DropPath` or a `ModuleDict` or `ModuleList` under a dropout name is assigned to `self`
11
+ and then never called, never passed on, never returned or iterated and its rate never read, anywhere in the file.
12
+ The option is accepted and does nothing, so the model trains without the regularisation it reports. peft #3830 is
13
+ the example: `OFTLayer` built `module_dropout` into `self.oft_dropout`, filled it in `update_layer` and never
14
+ called it. Reading the rate counts as use (`scaled_dot_product_attention(dropout_p=self.dropout.p)`), as does
15
+ passing the module to another one; filling it does not, and subclasses of `Sequential`, which run every
16
+ attribute, are not read. On kornia, ultralytics, torchvision, diffusers, torchrl, vllm and detectron2 main the
17
+ rule reports nothing; on peft main it reports the #3830 line, on timm main one `attn_drop` in `coat.py` that a
18
+ comment already calls unused, and on transformers main nine lines, four of them modular files whose `forward`
19
+ lives in another file.
20
+
3
21
  ## 0.3.3 (2026-10-08)
4
22
 
5
23
  - Two regression corpus cases for peft, both open upstream with a fix PR: peft #3769, `add_weighted_adapter` with
@@ -120,7 +120,7 @@ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
120
120
 
121
121
  [[package]]
122
122
  name = "ulpwise"
123
- version = "0.3.3"
123
+ version = "0.3.4"
124
124
  dependencies = [
125
125
  "pyo3",
126
126
  ]
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "ulpwise"
3
- version = "0.3.3"
3
+ version = "0.3.4"
4
4
  edition = "2021"
5
5
  rust-version = "1.86"
6
6
  license = "MIT OR Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ulpwise
3
- Version: 0.3.3
3
+ Version: 0.3.4
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Framework :: Pytest
6
6
  Classifier: Intended Audience :: Developers
@@ -185,6 +185,7 @@ PYTHONPATH=/tmp/kornia-0.8.3 ulpwise corpus --repo kornia # 31 present, 0 fi
185
185
  | pytorch/rl #4443 `arange(0, 1, 1/n)` gives `n + 1` positions for 140 values of `n` below 2000 | rounding | 2026-09-20 |
186
186
  | pytorch/rl #4444 `min_value or -inf` drops `min_value=0` | falsy zero | 2026-09-20 |
187
187
  | pytorch/rl #4445 scheduler `state_dict()` contained a module object | crash | 2026-09-20 |
188
+ | pytorch/rl #4504 `MultiCategorical.to_one_hot` crashed for a batched spec and a 0-D sample | crash | 2026-10-07 |
188
189
  | timm #2786 Mars kept a reference to `p.grad` as the previous gradient | aliasing | 2026-09-17 |
189
190
  | timm #2790 AdafactorBigVision clipped updates in the wrong direction | direction | 2026-09-18 |
190
191
  | timm #2791 AdaMuon conv LR scale computed from the wrong dims | scale | 2026-09-18 |
@@ -249,9 +250,9 @@ ulpwise scan path/to/repo --rules one-minus-cos,small-angle-division --top 40
249
250
  ```
250
251
 
251
252
  The scan is static and needs nothing installed: it parses each file with `ast`, walks every
252
- function and matches fifteen patterns, each with a severity, the reason it loses digits,
253
- overflows or loses its gradient, the usual replacement and, where one exists, the upstream bug it
254
- comes from.
253
+ function and matches sixteen patterns, each with a severity, the reason it loses digits,
254
+ overflows or loses its gradient, or in one case does nothing at all, the usual replacement and,
255
+ where one exists, the upstream bug it comes from.
255
256
 
256
257
  | rule | severity | pattern |
257
258
  |---|---|---|
@@ -269,6 +270,7 @@ comes from.
269
270
  | `eps-floor` | medium | `x * (1 - eps) + eps`: every value moves, 0 becomes `eps`, so a one-hot's entries sum to `1 + (C - 1) eps` and a perfect prediction scores a loss that grows with the image (kornia #5538, found by the kornia conventions audit) |
270
271
  | `where-nan-gradient` | medium | `where(d > eps, f(d), other)` with `f` a division by `d` or a `sqrt`, `log`, `acos` or `asin` of it: `where` evaluates both branches and hands the discarded one a zero gradient, and the backward of `f` at the singularity turns that zero into `0 / 0 = nan`, so the guard protects the value and not the gradient; quiet for `numpy.where`, for a comparison against a number above 1, for a floor named `eps`, `tol` or `floor` on the other side, and once `d` is re-bound to a `where`, `clamp` or `maximum` of itself (kornia #5579, found by the kornia conventions audit) |
271
272
  | `clamp-at-singularity` | medium | `clamp(x, min=0).sqrt()`, `sqrt(clamp(x, min=0))`, `clamp(c, -1, 1).acos()`: the bound is the point where the next function has an infinite derivative, and clamp's derivative at its own bound is not the same across torch versions, 1 on 2.5.1 and 2.9.1 and 0 on 2.14, so the gradient there is `inf` or `nan` on the older half of a supported range; a bound strictly inside the domain, `min=1e-8`, is a floor and is not reported, and `numpy` is quiet (kornia #4229, found by the kornia conventions audit; kornia #5500) |
273
+ | `dropout-never-applied` | medium | a `Dropout`, `DropPath` or a `ModuleDict` of them assigned to `self` and then never called, never passed on and its rate never read, anywhere in the file: the option is accepted and does nothing, so the model trains without the regularisation it reports (peft #3830) |
272
274
 
273
275
  On kornia `main` at `e05b0ee` the scan takes 4 s for 506 files and reports 35 findings. The
274
276
  `one-minus-cos` and `small-angle-division` findings are the four lines of `So3.right_jacobian` and
@@ -284,6 +286,19 @@ report seven lines: three `clamp` bounds in `_solve_cubic_real`, and four `where
284
286
  boxes and polygons. On ultralytics `main` at `8df3534` they report three lines, all in metrics or
285
287
  inference code that is never differentiated, and nothing in torchvision.
286
288
 
289
+ `dropout-never-applied` reads the whole file rather than one function, since the module is built
290
+ in `__init__` and used, if at all, in `forward` or in a sibling class. Calling it, calling it
291
+ through a subscript, passing it to another module, returning it, iterating over it and reading
292
+ its rate (`scaled_dot_product_attention(dropout_p=self.dropout.p)`) all count as use; filling it
293
+ (`self.oft_dropout.update(...)`) does not, and a subclass of `Sequential` runs every attribute and
294
+ is not read. On kornia `main` at `6d579a72`, ultralytics `6d51b7e`, torchvision `9a8d545`,
295
+ diffusers `d961a38`, torchrl `648f50c`, vllm `73c742b` and detectron2 `1e3e13b` it reports
296
+ nothing. On peft `main` at `f6d8480` it reports `OFTLayer.oft_dropout`, which is #3830; on timm
297
+ `83e6eb5` it reports `FactorAttnConvRelPosEnc.attn_drop` in `coat.py`, which a comment on that
298
+ line already calls unused; on transformers `9167f73` it reports nine lines, four of them in
299
+ modular files whose `forward` is inherited from another file, which the rule cannot see, and
300
+ five attention, embedding and head dropouts built in `__init__` and used nowhere in their file.
301
+
287
302
  `--run` adds the dynamic half. The module level functions with the most elementary math are
288
303
  imported and called with the same 91 point grid (both signs of `1e-8` to `1e3`, and zero) for every
289
304
  required argument, in float32 and in float64, torch first and numpy second, and the float32 result
@@ -156,6 +156,7 @@ PYTHONPATH=/tmp/kornia-0.8.3 ulpwise corpus --repo kornia # 31 present, 0 fi
156
156
  | pytorch/rl #4443 `arange(0, 1, 1/n)` gives `n + 1` positions for 140 values of `n` below 2000 | rounding | 2026-09-20 |
157
157
  | pytorch/rl #4444 `min_value or -inf` drops `min_value=0` | falsy zero | 2026-09-20 |
158
158
  | pytorch/rl #4445 scheduler `state_dict()` contained a module object | crash | 2026-09-20 |
159
+ | pytorch/rl #4504 `MultiCategorical.to_one_hot` crashed for a batched spec and a 0-D sample | crash | 2026-10-07 |
159
160
  | timm #2786 Mars kept a reference to `p.grad` as the previous gradient | aliasing | 2026-09-17 |
160
161
  | timm #2790 AdafactorBigVision clipped updates in the wrong direction | direction | 2026-09-18 |
161
162
  | timm #2791 AdaMuon conv LR scale computed from the wrong dims | scale | 2026-09-18 |
@@ -220,9 +221,9 @@ ulpwise scan path/to/repo --rules one-minus-cos,small-angle-division --top 40
220
221
  ```
221
222
 
222
223
  The scan is static and needs nothing installed: it parses each file with `ast`, walks every
223
- function and matches fifteen patterns, each with a severity, the reason it loses digits,
224
- overflows or loses its gradient, the usual replacement and, where one exists, the upstream bug it
225
- comes from.
224
+ function and matches sixteen patterns, each with a severity, the reason it loses digits,
225
+ overflows or loses its gradient, or in one case does nothing at all, the usual replacement and,
226
+ where one exists, the upstream bug it comes from.
226
227
 
227
228
  | rule | severity | pattern |
228
229
  |---|---|---|
@@ -240,6 +241,7 @@ comes from.
240
241
  | `eps-floor` | medium | `x * (1 - eps) + eps`: every value moves, 0 becomes `eps`, so a one-hot's entries sum to `1 + (C - 1) eps` and a perfect prediction scores a loss that grows with the image (kornia #5538, found by the kornia conventions audit) |
241
242
  | `where-nan-gradient` | medium | `where(d > eps, f(d), other)` with `f` a division by `d` or a `sqrt`, `log`, `acos` or `asin` of it: `where` evaluates both branches and hands the discarded one a zero gradient, and the backward of `f` at the singularity turns that zero into `0 / 0 = nan`, so the guard protects the value and not the gradient; quiet for `numpy.where`, for a comparison against a number above 1, for a floor named `eps`, `tol` or `floor` on the other side, and once `d` is re-bound to a `where`, `clamp` or `maximum` of itself (kornia #5579, found by the kornia conventions audit) |
242
243
  | `clamp-at-singularity` | medium | `clamp(x, min=0).sqrt()`, `sqrt(clamp(x, min=0))`, `clamp(c, -1, 1).acos()`: the bound is the point where the next function has an infinite derivative, and clamp's derivative at its own bound is not the same across torch versions, 1 on 2.5.1 and 2.9.1 and 0 on 2.14, so the gradient there is `inf` or `nan` on the older half of a supported range; a bound strictly inside the domain, `min=1e-8`, is a floor and is not reported, and `numpy` is quiet (kornia #4229, found by the kornia conventions audit; kornia #5500) |
244
+ | `dropout-never-applied` | medium | a `Dropout`, `DropPath` or a `ModuleDict` of them assigned to `self` and then never called, never passed on and its rate never read, anywhere in the file: the option is accepted and does nothing, so the model trains without the regularisation it reports (peft #3830) |
243
245
 
244
246
  On kornia `main` at `e05b0ee` the scan takes 4 s for 506 files and reports 35 findings. The
245
247
  `one-minus-cos` and `small-angle-division` findings are the four lines of `So3.right_jacobian` and
@@ -255,6 +257,19 @@ report seven lines: three `clamp` bounds in `_solve_cubic_real`, and four `where
255
257
  boxes and polygons. On ultralytics `main` at `8df3534` they report three lines, all in metrics or
256
258
  inference code that is never differentiated, and nothing in torchvision.
257
259
 
260
+ `dropout-never-applied` reads the whole file rather than one function, since the module is built
261
+ in `__init__` and used, if at all, in `forward` or in a sibling class. Calling it, calling it
262
+ through a subscript, passing it to another module, returning it, iterating over it and reading
263
+ its rate (`scaled_dot_product_attention(dropout_p=self.dropout.p)`) all count as use; filling it
264
+ (`self.oft_dropout.update(...)`) does not, and a subclass of `Sequential` runs every attribute and
265
+ is not read. On kornia `main` at `6d579a72`, ultralytics `6d51b7e`, torchvision `9a8d545`,
266
+ diffusers `d961a38`, torchrl `648f50c`, vllm `73c742b` and detectron2 `1e3e13b` it reports
267
+ nothing. On peft `main` at `f6d8480` it reports `OFTLayer.oft_dropout`, which is #3830; on timm
268
+ `83e6eb5` it reports `FactorAttnConvRelPosEnc.attn_drop` in `coat.py`, which a comment on that
269
+ line already calls unused; on transformers `9167f73` it reports nine lines, four of them in
270
+ modular files whose `forward` is inherited from another file, which the rule cannot see, and
271
+ five attention, embedding and head dropouts built in `__init__` and used nowhere in their file.
272
+
258
273
  `--run` adds the dynamic half. The module level functions with the most elementary math are
259
274
  imported and called with the same 91 point grid (both signs of `1e-8` to `1e3`, and zero) for every
260
275
  required argument, in float32 and in float64, torch first and numpy second, and the float32 result
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "ulpwise"
7
- version = "0.3.3"
7
+ version = "0.3.4"
8
8
  description = "Numerical conformance testing for ML code: float edge values, exact rounding oracles, knife-edge finders and a pytest plugin"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT OR Apache-2.0" }
@@ -30,7 +30,7 @@ from ._core import special as _special_core
30
30
  from ._core import ulp_distance as _ulp_distance_core
31
31
  from ._core import ulp_distances as _ulp_distances_core
32
32
 
33
- __version__ = "0.3.3"
33
+ __version__ = "0.3.4"
34
34
 
35
35
  __all__ = [
36
36
  "UNARY_OPS",
@@ -143,6 +143,25 @@
143
143
  },
144
144
  "before_fix": "TypeError: cannot pickle 'module' object"
145
145
  },
146
+ {
147
+ "id": "torchrl-4504-multicategorical-to-one-hot-batched-spec",
148
+ "repo": "pytorch/rl",
149
+ "pr": 4504,
150
+ "merged_at": "2026-10-07T10:30:35Z",
151
+ "kind": "crash",
152
+ "title": "MultiCategorical.to_one_hot crashed for a spec with a batch shape and for the scalar sample of a shape [1] spec",
153
+ "symptom": "to_one_hot iterated over self.nvec, which is a 2-D tensor for a spec with a batch shape, so one_hot received a tensor as num_classes and raised TypeError; a shape [1] spec returns 0-D samples and val[..., i] on them raised IndexError.",
154
+ "requires": [
155
+ "torch",
156
+ "torchrl"
157
+ ],
158
+ "fixed_in_release": null,
159
+ "repro": "import torch\nfrom torchrl.data import MultiCategorical\nspec = MultiCategorical([3, 2], shape=(4, 2))\nhot = spec.to_one_hot(spec.rand())\nscalar = MultiCategorical([5]).to_one_hot(torch.tensor(3))\nactual = [list(hot.shape), hot.dtype == torch.bool, int(hot.sum()), list(scalar.shape), int(scalar.sum()), bool(scalar[3])]\nexpected = [[4, 5], True, 8, [5], 1, True]\n",
160
+ "check": {
161
+ "type": "equal"
162
+ },
163
+ "before_fix": "TypeError: one_hot(): argument 'num_classes' (position 2) must be int, not Tensor for the batched spec; IndexError: too many indices for tensor of dimension 0 for the scalar sample"
164
+ },
146
165
  {
147
166
  "id": "timm-2786-mars-last-grad-aliasing",
148
167
  "repo": "huggingface/pytorch-image-models",
@@ -136,6 +136,12 @@ RULES = {
136
136
  "take the value from the real argument and the gradient from a safe one, f(where(at_bound, safe, x)) with the value fixed up by where, or atan2 instead of acos for an angle",
137
137
  "kornia #4229 (found by the kornia conventions audit), _cdist's clamp(min=0.0).sqrt() gave nan gradients for identical float16 descriptors on torch 2.5.1; kornia #5500, clamp(-1, 1).acos() in angle_error_mat had a -inf backward at exactly 0 and 180 degrees on torch 2.5.1",
138
138
  ),
139
+ "dropout-never-applied": (
140
+ "medium",
141
+ "a Dropout, DropPath or a ModuleDict of them is assigned to self and then never called, never passed on and its rate never read, anywhere in the file: the option is accepted and does nothing, so the model trains without the regularisation it reports and every training forward of one input agrees",
142
+ "apply it in forward, or hand its rate to the function that does (scaled_dot_product_attention(dropout_p=self.dropout.p)), or drop the argument so the caller learns it is not supported",
143
+ "peft #3830, OFTLayer built module_dropout into self.oft_dropout and never called it, so module_dropout=0.5 trained like 0",
144
+ ),
139
145
  }
140
146
  SEVERITY_ORDER = {"high": 0, "medium": 1, "info": 2}
141
147
 
@@ -653,6 +659,79 @@ def python_files(root: str, include_tests: bool = False) -> List[str]:
653
659
  return out
654
660
 
655
661
 
662
+ DROPOUT_CLASSES = re.compile(r"^(Dropout\w*|AlphaDropout|FeatureAlphaDropout|DropPath|StochasticDepth|DropBlock\w*)$")
663
+ DROPOUT_NAMES = re.compile(r"drop_?out|drop_?path|stochastic_?depth|drop_?block", re.I)
664
+ DROPOUT_HOLDERS = {"ModuleDict", "ModuleList", "Identity"}
665
+ RATE_ATTRS = {"p", "prob", "rate", "drop_prob", "drop_rate", "dropout_p", "dropout", "drop_path_rate"}
666
+
667
+
668
+ def _dropout_like(value: ast.AST, attr: str) -> bool:
669
+ """Whether ``self.<attr> = value`` builds a dropout: a Dropout-class call, either branch of a conditional
670
+ expression, or a ModuleDict, ModuleList or Identity under a name that says dropout (filled in later)."""
671
+ if isinstance(value, ast.IfExp):
672
+ return _dropout_like(value.body, attr) or _dropout_like(value.orelse, attr)
673
+ if not isinstance(value, ast.Call):
674
+ return False
675
+ name, _ = _callee(value)
676
+ if name is None:
677
+ return False
678
+ return bool(DROPOUT_CLASSES.match(name)) or (name in DROPOUT_HOLDERS and bool(DROPOUT_NAMES.search(attr)))
679
+
680
+
681
+ def _applied(node: ast.Attribute, parents: dict) -> bool:
682
+ """Whether this read of ``self.x`` uses the module: a call, a call through a subscript, an argument, a return
683
+ or yield, an operand, a loop target, an alias to a local, or a read of its rate. Assigning to it, filling it
684
+ (``self.x.update(...)``, ``self.x[k] = ...``) and reading other attributes of it are not uses."""
685
+ parent = parents.get(node)
686
+ if isinstance(parent, ast.Subscript) and parent.value is node:
687
+ node, parent = parent, parents.get(parent)
688
+ if isinstance(parent, ast.Call):
689
+ return parent.func is node or node in parent.args or any(k.value is node for k in parent.keywords)
690
+ if isinstance(parent, ast.Attribute):
691
+ return parent.attr in RATE_ATTRS
692
+ if isinstance(parent, ast.Assign):
693
+ return parent.value is node
694
+ if isinstance(parent, (ast.AnnAssign, ast.AugAssign)):
695
+ return parent.value is node
696
+ if isinstance(parent, ast.For):
697
+ return parent.iter is node
698
+ return isinstance(parent, (ast.Return, ast.Yield, ast.BinOp, ast.Compare, ast.IfExp, ast.Tuple, ast.List,
699
+ ast.Dict, ast.Starred, ast.keyword, ast.FormattedValue, ast.Await, ast.BoolOp))
700
+
701
+
702
+ def _dropouts_never_applied(tree: ast.Module, path: str, source_lines: Sequence[str]) -> List[Finding]:
703
+ """The ``dropout-never-applied`` rule: a file-level pass, since the module is built in one method and used, if
704
+ at all, in another, or in a sibling class of the same file. Subclasses of ``Sequential`` run every attribute
705
+ in order and are not read."""
706
+ parents: dict = {}
707
+ for node in ast.walk(tree):
708
+ for child in ast.iter_child_nodes(node):
709
+ parents[child] = node
710
+ used: set = set()
711
+ for node in ast.walk(tree):
712
+ if isinstance(node, ast.Attribute) and _applied(node, parents):
713
+ used.add(node.attr)
714
+ findings: List[Finding] = []
715
+ for cls in ast.walk(tree):
716
+ if not isinstance(cls, ast.ClassDef) or any(ast.unparse(b).endswith("Sequential") for b in cls.bases):
717
+ continue
718
+ for fn in cls.body:
719
+ if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
720
+ continue
721
+ for node in _own_nodes(fn):
722
+ if not (isinstance(node, ast.Assign) and len(node.targets) == 1):
723
+ continue
724
+ target = node.targets[0]
725
+ if not (isinstance(target, ast.Attribute) and isinstance(target.value, ast.Name)
726
+ and target.value.id == "self"):
727
+ continue
728
+ if target.attr not in used and _dropout_like(node.value, target.attr):
729
+ qualname = cls.name + "." + fn.name
730
+ findings.append(Finding("dropout-never-applied", path, node.lineno, qualname,
731
+ source_lines[node.lineno - 1].strip()[:160]))
732
+ return findings
733
+
734
+
656
735
  def scan_source(source: str, path: str = "<string>") -> Tuple[List[Finding], List[HotSpot]]:
657
736
  try:
658
737
  with warnings.catch_warnings():
@@ -669,6 +748,7 @@ def scan_source(source: str, path: str = "<string>") -> Tuple[List[Finding], Lis
669
748
  findings.extend(f)
670
749
  if spot.calls:
671
750
  spots.append(spot)
751
+ findings.extend(_dropouts_never_applied(tree, path, lines))
672
752
  return findings, spots
673
753
 
674
754
 
@@ -165,6 +165,17 @@ SNIPPET = textwrap.dedent(
165
165
  e = (-(x.pow(2))).exp()
166
166
  f = torch.exp(-torch.square(x))
167
167
  return a, b, c, d, e, f
168
+
169
+
170
+ class Unregularised(torch.nn.Module):
171
+ def __init__(self, dim, p):
172
+ super().__init__()
173
+ self.proj = torch.nn.Linear(dim, dim)
174
+ self.attn_drop = torch.nn.Dropout(p)
175
+ self.proj_drop = torch.nn.Dropout(p)
176
+
177
+ def forward(self, x):
178
+ return self.proj_drop(self.proj(x))
168
179
  '''
169
180
  )
170
181
 
@@ -182,6 +193,7 @@ EXPECTED = {
182
193
  ("acos-for-angle", "angle_between"),
183
194
  ("eps-floor", "one_hot_floor"),
184
195
  ("eps-floor", "Smoother.forward"),
196
+ ("dropout-never-applied", "Unregularised.__init__"),
185
197
  ("one-minus-cos", "so3_jacobian"),
186
198
  ("small-angle-division", "so3_jacobian"),
187
199
  ("where-nan-gradient", "so3_jacobian_guarded"),
@@ -215,6 +227,88 @@ def test_every_rule_fires_once_on_its_snippet_and_the_guard_silences_it():
215
227
  assert busiest.function == "misc" and busiest.calls == 7 and "exp" in busiest.names
216
228
 
217
229
 
230
+ DROPOUTS = textwrap.dedent(
231
+ '''
232
+ import torch
233
+ from torch import nn
234
+ import torch.nn.functional as F
235
+
236
+
237
+ class Attention(nn.Module):
238
+ def __init__(self, dim, p):
239
+ super().__init__()
240
+ self.dropout = nn.Dropout(p) # its rate is read and handed to the kernel
241
+
242
+ def forward(self, q, k, v):
243
+ p = self.dropout.p if self.training else 0.0
244
+ return F.scaled_dot_product_attention(q, k, v, dropout_p=p)
245
+
246
+
247
+ class Block(nn.Module):
248
+ def __init__(self, dim, p, drop_path):
249
+ super().__init__()
250
+ self.drop_path = DropPath(drop_path) if drop_path > 0.0 else nn.Identity() # never applied
251
+ self.drop = nn.Dropout(p) if p > 0.0 else nn.Identity()
252
+ self.mlp = Mlp(dim, drop=self.drop) # passed on
253
+
254
+ def forward(self, x):
255
+ return x + self.mlp(x)
256
+
257
+
258
+ class Mlp(nn.Sequential):
259
+ def __init__(self, dim, drop):
260
+ super().__init__()
261
+ self.fc = nn.Linear(dim, dim)
262
+ self.drop = nn.Dropout(0.5) # a Sequential runs every attribute
263
+
264
+
265
+ class Adapters(nn.Module):
266
+ def __init__(self):
267
+ super().__init__()
268
+ self.lora_dropout = nn.ModuleDict({}) # filled and called through a subscript
269
+ self.oft_dropout = nn.ModuleDict({}) # filled and never called
270
+
271
+ def update_layer(self, name, p):
272
+ self.lora_dropout.update(nn.ModuleDict({name: nn.Dropout(p)}))
273
+ self.oft_dropout.update(nn.ModuleDict({name: nn.Dropout(p)}))
274
+
275
+ def forward(self, x, name):
276
+ return self.lora_dropout[name](x)
277
+
278
+
279
+ class Head(nn.Module):
280
+ def __init__(self, dim, p):
281
+ super().__init__()
282
+ self.dense = nn.Linear(dim, dim)
283
+ self.dropout = nn.Dropout(p) # applied by a sibling class of this file
284
+ self.drops = nn.ModuleList([nn.Dropout(p) for _ in range(2)]) # iterated
285
+
286
+ def forward(self, x):
287
+ for drop in self.drops:
288
+ x = drop(x)
289
+ return x
290
+
291
+
292
+ class Wrapped(nn.Module):
293
+ def forward(self, x):
294
+ return self.inner.dropout(self.dense(x))
295
+ '''
296
+ )
297
+
298
+
299
+ def test_dropout_never_applied_reads_the_whole_file_and_knows_the_ways_a_module_is_used():
300
+ findings, _ = scan.scan_source(DROPOUTS, "dropouts.py")
301
+ assert [(f.function, f.line) for f in findings if f.rule == "dropout-never-applied"] == [
302
+ ("Block.__init__", 20),
303
+ ("Adapters.__init__", 39),
304
+ ]
305
+ assert findings[0].snippet.startswith("self.drop_path = DropPath(drop_path)")
306
+ assert findings[1].snippet.startswith("self.oft_dropout = nn.ModuleDict({})")
307
+ # a float rate is not a module, and a function is not a class
308
+ quiet = scan.scan_source("class A:\n def __init__(self, p):\n self.dropout = p\n\ndef f(self):\n self.dropout = Dropout(0.1)\n", "q.py")[0]
309
+ assert not quiet
310
+
311
+
218
312
  def test_scan_is_quiet_about_the_escape_sequences_of_the_scanned_file():
219
313
  source = 'import torch\n\n\ndef f(x):\n return "\\%" + str(torch.exp(x))\n'
220
314
  with warnings.catch_warnings():
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