Skip to content

Commit 7a629ee

Browse files
authored
fix(dnn): BatchNorm running-var bias, pooling channel_axis, LayerNorm error (#843)
fix(dnn): BatchNorm running-var bias, pooling channel_axis bound, LayerNorm error - BatchNorm stored the biased batch variance into running_var; apply Bessel's N/(N-1) correction for the running buffer (PyTorch-consistent), keeping the biased variance for in-batch normalization (Medium) - Pooling rejected the leftmost negative channel_axis (== -x_dim) due to an abs() bound; widen to -x_dim <= axis < x_dim (Pool, _MaxPoolNd, AdaptivePool) (Medium) - LayerNorm wrong-shape error did ", ".join(<ints>) -> TypeError masking the intended ValueError; map(str, ...) (Medium) Findings recorded in docs/issues-found-20260619-dnn.md
1 parent ee61019 commit 7a629ee

5 files changed

Lines changed: 219 additions & 5 deletions

File tree

brainpy/dnn/normalization.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,8 +170,20 @@ def update(self, x):
170170
2
171171
)
172172
var = jnp.maximum(0., mean_of_square - _square(mean))
173+
# ``var`` above is the biased (divisor ``N``) variance used to normalize
174+
# the current batch. The running buffer, however, should track the
175+
# unbiased (Bessel-corrected, divisor ``N - 1``) estimate to match the
176+
# conventional BatchNorm running statistic (e.g. PyTorch); otherwise the
177+
# eval-time variance is systematically too small by ``(N - 1) / N`` (M-25).
178+
num_reduced = 1
179+
for ax in self.axis:
180+
num_reduced *= x.shape[ax]
181+
if num_reduced > 1:
182+
unbiased_var = var * (num_reduced / (num_reduced - 1))
183+
else:
184+
unbiased_var = var
173185
self.running_mean.value = (self.momentum * self.running_mean + (1 - self.momentum) * mean)
174-
self.running_var.value = (self.momentum * self.running_var + (1 - self.momentum) * var)
186+
self.running_var.value = (self.momentum * self.running_var + (1 - self.momentum) * unbiased_var)
175187
else:
176188
mean = self.running_mean.value
177189
var = self.running_var.value
@@ -533,7 +545,7 @@ def __init__(
533545

534546
def update(self, x):
535547
if x.shape[-len(self.normalized_shape):] != self.normalized_shape:
536-
raise ValueError(f'Expect the input shape should be (..., {", ".join(self.normalized_shape)}), '
548+
raise ValueError(f'Expect the input shape should be (..., {", ".join(map(str, self.normalized_shape))}), '
537549
f'but we got {x.shape}')
538550
axis = tuple(range(0, x.ndim - len(self.normalized_shape)))
539551
mean = jnp.mean(bm.as_jax(x), axis=axis, keepdims=True)

brainpy/dnn/normalization_test.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,50 @@ def test_InstanceNorm(self):
7474
net = bp.dnn.InstanceNorm(num_channels=6, mode=bm.training_mode)
7575
output = net(input)
7676

77+
def test_LayerNorm_shape_mismatch_raises_valueerror(self):
78+
# Regression for P12-M1: the wrong-shape diagnostic used ``", ".join(<ints>)``
79+
# which raised ``TypeError`` and masked the intended ``ValueError``.
80+
net = bp.dnn.LayerNorm(10, mode=bm.training_mode)
81+
bad_input = bm.random.randn(2, 5, 8) # last dim 8 != 10
82+
with self.assertRaises(ValueError):
83+
net(bad_input)
84+
85+
def test_BatchNorm_running_var_is_unbiased(self):
86+
# Regression for P12-M3: the running variance buffer must use the unbiased
87+
# (Bessel-corrected, divisor N-1) batch variance, matching PyTorch, instead
88+
# of the biased (divisor N) variance used to normalize the current batch.
89+
import jax.numpy as jnp
90+
import numpy as np
91+
bm.random.seed(123)
92+
net = bp.dnn.BatchNorm1d(num_features=3, affine=False, mode=bm.training_mode)
93+
bp.share.save(fit=True)
94+
x = bm.random.randn(4, 5, 3) * 3.0 + 7.0 # N = 4*5 = 20 reduced elements
95+
net(x)
96+
97+
xj = bm.as_jax(x)
98+
n = xj.shape[0] * xj.shape[1]
99+
biased = jnp.var(xj, axis=(0, 1))
100+
unbiased = biased * n / (n - 1)
101+
# After one update: running_var = 0.99 * 1.0 + 0.01 * <var>.
102+
expected_unbiased = 0.99 * 1.0 + 0.01 * unbiased
103+
expected_biased = 0.99 * 1.0 + 0.01 * biased
104+
rv = bm.as_jax(net.running_var.value)
105+
self.assertTrue(bool(jnp.allclose(rv, expected_unbiased, atol=1e-5)))
106+
# And it must NOT match the biased estimate (the previous behaviour).
107+
self.assertFalse(bool(jnp.allclose(rv, expected_biased, atol=1e-5)))
108+
109+
def test_BatchNorm_batch_is_biased_normalized(self):
110+
# The normalization of the current batch itself must remain unit-variance
111+
# (biased), unaffected by the running-buffer correction.
112+
import jax.numpy as jnp
113+
bm.random.seed(7)
114+
net = bp.dnn.BatchNorm1d(num_features=3, affine=False, mode=bm.training_mode)
115+
bp.share.save(fit=True)
116+
x = bm.random.randn(8, 6, 3) * 2.0 - 1.0
117+
out = bm.as_jax(net(x))
118+
self.assertTrue(bool(jnp.allclose(out.mean(axis=(0, 1)), 0.0, atol=1e-5)))
119+
self.assertTrue(bool(jnp.allclose(out.var(axis=(0, 1)), 1.0, atol=1e-4)))
120+
77121

78122
if __name__ == '__main__':
79123
absltest.main()

brainpy/dnn/pooling.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,9 @@ def _infer_shape(self,
115115

116116
# channel axis
117117
channel_axis = self.channel_axis
118-
if channel_axis and not 0 <= abs(channel_axis) < x_dim:
118+
# Valid axes are ``-x_dim <= channel_axis < x_dim``. The previous ``abs()``
119+
# bound wrongly rejected the leftmost negative axis ``-x_dim`` (P12-M2).
120+
if channel_axis and not -x_dim <= channel_axis < x_dim:
119121
raise ValueError(f"Invalid channel axis {channel_axis} for input with {x_dim} dimensions")
120122
if channel_axis and channel_axis < 0:
121123
channel_axis = x_dim + channel_axis
@@ -387,7 +389,9 @@ def update(self, x):
387389

388390
def _infer_shape(self, x_dim, inputs, element):
389391
channel_axis = self.channel_axis
390-
if channel_axis and not 0 <= abs(channel_axis) < x_dim:
392+
# Valid axes are ``-x_dim <= channel_axis < x_dim``. The previous ``abs()``
393+
# bound wrongly rejected the leftmost negative axis ``-x_dim`` (P12-M2).
394+
if channel_axis and not -x_dim <= channel_axis < x_dim:
391395
raise ValueError(f"Invalid channel axis {channel_axis} for input with {x_dim} dimensions")
392396
if channel_axis and channel_axis < 0:
393397
channel_axis = x_dim + channel_axis
@@ -784,7 +788,9 @@ def update(self, x):
784788
channel_axis = self.channel_axis
785789

786790
if channel_axis:
787-
if not 0 <= abs(channel_axis) < x.ndim:
791+
# Valid axes are ``-x.ndim <= channel_axis < x.ndim``. The previous
792+
# ``abs()`` bound wrongly rejected the leftmost negative axis (P12-M2).
793+
if not -x.ndim <= channel_axis < x.ndim:
788794
raise ValueError(f"Invalid channel axis {channel_axis} for {x.shape}")
789795
if channel_axis < 0:
790796
channel_axis = x.ndim + channel_axis

brainpy/dnn/pooling_layers_test.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,5 +243,36 @@ def test_AdaptiveMaxPool3d_v1(self, axis):
243243
output = net(input)
244244

245245

246+
class TestPoolingChannelAxis(parameterized.TestCase):
247+
"""Regression for P12-M2: the leftmost negative ``channel_axis`` (== -x_dim)
248+
was wrongly rejected because the bound check used ``abs(channel_axis)``."""
249+
250+
def test_maxpool2d_leftmost_negative_channel_axis(self):
251+
bm.random.seed()
252+
# channels-first (C, H, W); channel axis is axis 0 == -3.
253+
x = bm.random.randn(6, 8, 8)
254+
net = bp.dnn.MaxPool2d(2, channel_axis=-3)
255+
out = net(x)
256+
self.assertEqual(out.shape, (6, 4, 4))
257+
# Must equal the equivalent positive channel_axis result.
258+
net_pos = bp.dnn.MaxPool2d(2, channel_axis=0)
259+
self.assertEqual(out.shape, net_pos(x).shape)
260+
261+
def test_adaptiveavgpool2d_leftmost_negative_channel_axis(self):
262+
bm.random.seed()
263+
x = bm.random.randn(6, 8, 8) # (C, H, W)
264+
net = bp.dnn.AdaptiveAvgPool2d((4, 4), channel_axis=-3)
265+
out = net(x)
266+
self.assertEqual(out.shape, (6, 4, 4))
267+
268+
def test_pool_leftmost_negative_channel_axis(self):
269+
bm.random.seed()
270+
# ``Pool`` family (MaxPool) with an integer kernel and channel_axis=-3.
271+
x = bm.random.randn(6, 8, 8)
272+
net = bp.dnn.MaxPool((2, 2), 2, channel_axis=-3)
273+
out = net(x)
274+
self.assertEqual(out.shape, (6, 4, 4))
275+
276+
246277
if __name__ == '__main__':
247278
absltest.main()

docs/issues-found-20260619-dnn.md

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# P12 — `brainpy/dnn` expert review (2026-06-19)
2+
3+
Branch: `fix/audit-20260619-dnn`. Scope: `brainpy/dnn/{activations,base,conv,dropout,function,interoperation_flax,linear,normalization,pooling}.py` + co-located tests.
4+
5+
Environment: jax 0.10.2, brainstate 0.5.1, braintools 0.1.10, brainunit 0.5.1 (CPU).
6+
7+
Severity legend: Critical (silently wrong/crash in default usage), High (wrong in realistic cases / broken public API), Medium (edge/fragility/error-handling), Low (style/docs — record only).
8+
9+
---
10+
11+
### P12-M1 — `LayerNorm` shape-mismatch error does `", ".join(<ints>)` → masks real error with `TypeError` [Medium]
12+
- File: brainpy/dnn/normalization.py:536
13+
- Category: edge/error
14+
- What: When the trailing input dims do not match `normalized_shape`, the guard raises
15+
`ValueError(f'... (..., {", ".join(self.normalized_shape)}), but we got {x.shape}')`.
16+
`self.normalized_shape` is a tuple of `int`, so `", ".join(...)` raises
17+
`TypeError: sequence item 0: expected str instance, int found` instead of the intended `ValueError`.
18+
- Why it's a bug: The user-facing diagnostic is replaced by an opaque `TypeError`, hiding the actual
19+
shape problem. Any wrong-shape input to an affine/non-affine `LayerNorm` hits this.
20+
- Repro:
21+
```python
22+
bp.dnn.LayerNorm(10)(bm.random.randn(2, 5, 8)) # -> TypeError, not ValueError
23+
```
24+
- Fix: `", ".join(map(str, self.normalized_shape))` (and format the expected shape readably).
25+
- Tests: `normalization_test.py::Test_Normalization::test_LayerNorm_shape_mismatch_raises_valueerror`
26+
- Status: fixed
27+
- (== prior audit M-26)
28+
29+
---
30+
31+
### P12-M2 — Pooling rejects the leftmost negative `channel_axis` (`channel_axis == -x_dim`) [Medium]
32+
- File: brainpy/dnn/pooling.py:118 (`Pool._infer_shape`), 390 (`_MaxPoolNd._infer_shape`), 787 (`AdaptivePool.update`)
33+
- Category: edge/error
34+
- What: The bound check is `if channel_axis and not 0 <= abs(channel_axis) < x_dim: raise`.
35+
Using `abs(channel_axis)` makes `channel_axis == -x_dim` (the valid leftmost axis, e.g. `-3` for a
36+
3-D `(C, H, W)` input) fail the check and raise `ValueError`.
37+
- Why it's a bug: A legitimate, common channels-first layout (`channel_axis=-3` on `(C,H,W)`, or
38+
`-4` on `(N,C,H,W)`) is wrongly rejected. The correct numpy-style bound is `-x_dim <= axis < x_dim`.
39+
- Repro:
40+
```python
41+
bp.dnn.MaxPool2d(2, channel_axis=-3)(bm.random.randn(6, 4, 4)) # ValueError: Invalid channel axis -3
42+
```
43+
- Fix: Replace the `abs()` bound test with `not -x_dim <= channel_axis < x_dim` in all three sites.
44+
- Tests: `pooling_layers_test.py::TestPoolingChannelAxis::test_maxpool2d_leftmost_negative_channel_axis`,
45+
`...::test_adaptiveavgpool2d_leftmost_negative_channel_axis`
46+
- Status: fixed
47+
- (== prior audit M-27)
48+
49+
---
50+
51+
### P12-M3 — `BatchNorm` stores the *biased* batch variance into `running_var` (PyTorch uses unbiased) [Medium]
52+
- File: brainpy/dnn/normalization.py:172-174
53+
- Category: numerics
54+
- What: In fit mode the running estimate is updated with the biased population variance
55+
`var = mean_of_square - mean**2` (divisor `N`). PyTorch / the conventional BatchNorm running statistic
56+
uses the *unbiased* sample variance (divisor `N-1`, i.e. Bessel's correction) for the running buffer,
57+
while keeping the biased variance only for normalizing the current batch.
58+
- Why it's a bug: The running variance used at eval time is systematically too small by a factor of
59+
`(N-1)/N`. For small batch/window counts `N` this is a meaningful (a few percent) bias in the
60+
eval-time normalization — i.e. inference results drift from the trained reference.
61+
- Repro:
62+
```python
63+
bn = bp.dnn.BatchNorm1d(3, affine=False); bp.share.save(fit=True)
64+
bn(bm.random.randn(4, 5, 3)) # N = 20
65+
# running_var == 0.99*1 + 0.01*biased_var, not unbiased_var
66+
```
67+
- Fix: Scale the variance fed into the `running_var` EMA by `N/(N-1)` (with `N` = number of reduced
68+
elements), guarding `N == 1`. The batch normalization itself keeps the biased `var`.
69+
- Tests: `normalization_test.py::Test_Normalization::test_BatchNorm_running_var_is_unbiased`
70+
- Status: fixed
71+
- (== prior audit M-25)
72+
73+
---
74+
75+
### P12-L1 — `Flatten` default `start_dim=0` contradicts its docstring example and PyTorch (`start_dim=1`) [Low]
76+
- File: brainpy/dnn/function.py:91 (default), 74-87 (docstring example)
77+
- Category: api-drift/style
78+
- What: `Flatten.__init__` defaults `start_dim=0`. The class docstring example claims
79+
`Flatten()` on `(32, 1, 5, 5)` yields `(32, 25)` (PyTorch's `start_dim=1` semantics), but in
80+
`NonBatchingMode` the actual default flattens the batch dim too → `(800,)`.
81+
- Why it's a bug: The documented contract and the implemented default disagree. PyTorch's `nn.Flatten`
82+
default is `start_dim=1`.
83+
- Repro:
84+
```python
85+
bp.dnn.Flatten()(bm.random.randn(32, 1, 5, 5)).shape # (800,), docstring says (32, 25)
86+
```
87+
- Fix: recorded only. NOTE: changing the default to `1` would change the documented `NonBatchingMode`
88+
contract and break `function_test.py::test_flatten_non_batching_mode` (asserts `(600,)` from default
89+
start_dim under `NonBatchingMode`). The discrepancy is documentation/default-value drift, not a
90+
silent numeric error, and a default change is a cross-cutting API change. Left for maintainers to
91+
decide (fix docs vs. change default + migrate the test).
92+
- Tests: none
93+
- Status: recorded-only
94+
95+
---
96+
97+
## Cross-check vs `dev/issues-found-20260618.md` (dnn entries)
98+
99+
- **C-05** (`GroupNorm`/`InstanceNorm` reduce over the group axis): **already fixed** in this tree
100+
(`normalization.py:640` reduces over spatial + within-group channel axis, keeps the group axis).
101+
Verified: `GroupNorm(3,6) != GroupNorm(1,6) != GroupNorm(6,6)`, per-group means ≈ 0. No action.
102+
- **H-51** (`BatchNorm`/affine `LayerNorm`/`GroupNorm` crash out-of-the-box under default mode):
103+
**already fixed** (`BatchNorm` defaults to `training_mode`; affine params wrapped as `Variable` vs
104+
`TrainVar` per mode instead of a hard assert). No action.
105+
- **M-25**: still present → fixed here as **P12-M3**.
106+
- **M-26**: still present → fixed here as **P12-M1**.
107+
- **M-27**: still present → fixed here as **P12-M2**.
108+
- **M-28** (`Flatten` default): still present → recorded as **P12-L1** (see note; default change out of safe scope).
109+
110+
## Checked and found correct (no action)
111+
- `Dropout`: `prob` = keep-probability; `bernoulli(prob)` keeps with prob `prob`; survivors scaled by
112+
`1/prob == 1/(1-rate)`; eval (`fit=False`) is a no-op. Correct.
113+
- `Conv*` / `ConvTranspose*`: kernel shapes, `feature_group_count=groups`, dimension numbers,
114+
bias broadcast (channels-last), non-batching unsqueeze/squeeze, `SAME`/`VALID`/int/tuple padding
115+
normalization. Correct (smoke-checked shapes & a transpose upsample).
116+
- `AvgPool`/`_AvgPoolNd` non-VALID averaging via a second `reduce_window` count. Correct.
117+
- `GroupNorm` affine broadcast via `lax.broadcast_to_rank` to channels-last. Correct.
118+
- Activation wrappers delegate to `bm.*`; formulas match docstrings. `Softmax2d` uses axis `-3`
119+
(channels-first `(N,C,H,W)`/`(C,H,W)`), matching its documented contract.
120+
- `interoperation_flax`: Flax round-trip param flatten/unflatten, `ToFlaxRNNCell` carry handling.
121+
Correct for flax present/absent.

0 commit comments

Comments
 (0)