Skip to content

Commit efc7dc9

Browse files
authored
fix(math/compat): gelu int input, unflatten negative dim, segment_mean Array (#844)
fix(math/compat): gelu integer input, unflatten negative dim, segment_mean Array - gelu was silently wrong on integer input (both approximate and exact branches); promote to floating dtype like jax.nn.gelu (High) - compat_pytorch.unflatten ignored negative dim (raised on torch-style dim=-1); normalize and range-check dim (High) - compat_tensorflow segment_mean/unsorted_segment_mean/unsorted_segment_sqrt_n passed an un-converted brainpy Array to jnp.ones_like (relied on the removed implicit __jax_array__); convert once (Medium) Findings recorded in docs/issues-found-20260619-math-compat.md
1 parent 7a629ee commit efc7dc9

5 files changed

Lines changed: 345 additions & 18 deletions

File tree

brainpy/math/activations.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,12 +169,21 @@ def gelu(x, approximate=True):
169169
whether to use the approximate or exact formulation.
170170
"""
171171
x = x.value if isinstance(x, Array) else x
172+
# Promote integer / boolean inputs to a floating dtype before computing.
173+
# Without this the ``sqrt(2/pi)`` and ``0.044715`` constants are truncated to
174+
# zero (approximate branch) or the float result is cast back to integer
175+
# (exact branch), silently producing wrong values. This mirrors the
176+
# ``promote_args_inexact`` step in ``jax.nn.gelu``.
177+
x = jnp.asarray(x)
178+
if not jnp.issubdtype(x.dtype, jnp.floating):
179+
x = x.astype(jnp.promote_types(x.dtype, jnp.float32))
172180
if approximate:
173181
sqrt_2_over_pi = np.sqrt(2 / np.pi).astype(x.dtype)
174182
cdf = 0.5 * (1.0 + jnp.tanh(sqrt_2_over_pi * (x + 0.044715 * (x ** 3))))
175183
y = x * cdf
176184
else:
177-
y = jnp.array(x * (jax.lax.erf(x / np.sqrt(2)) + 1) / 2, dtype=x.dtype)
185+
sqrt_2 = np.sqrt(2).astype(x.dtype)
186+
y = jnp.array(x * (jax.scipy.special.erf(x / sqrt_2) + 1) / 2, dtype=x.dtype)
178187
return y
179188

180189

brainpy/math/compat_pytorch.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,11 +117,18 @@ def unflatten(x: Union[jax.Array, Array], dim: int, sizes: Sequence[int]) -> Arr
117117
The returned tensor has one more dimension than the input tensor.
118118
The returned tensor shares the same underlying data with this tensor.
119119
"""
120-
assert x.ndim > dim, ('The dimension to be unflattened should be less than the tensor dimension. '
121-
f'Got {dim} and {x.ndim}.')
122120
x = _as_jax_array_(x)
121+
ndim = x.ndim
122+
# Normalise a negative ``dim`` to PyTorch semantics, where ``dim`` indexes
123+
# into ``x.shape`` and may be in ``[-ndim, ndim)``.
124+
canon_dim = dim + ndim if dim < 0 else dim
125+
if not 0 <= canon_dim < ndim:
126+
raise ValueError(
127+
f'Dimension out of range (expected to be in range of [{-ndim}, {ndim - 1}], '
128+
f'but got {dim}).'
129+
)
123130
shape = x.shape
124-
new_shape = shape[:dim] + tuple(sizes) + shape[dim + 1:]
131+
new_shape = shape[:canon_dim] + tuple(sizes) + shape[canon_dim + 1:]
125132
r = jnp.reshape(x, new_shape)
126133
return _return(r)
127134

brainpy/math/compat_tensorflow.py

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -137,12 +137,10 @@ def segment_mean(data, segment_ids):
137137
See https://tensorflow.google.cn/api_docs/python/tf/math/segment_mean
138138
139139
"""
140-
r = jax.ops.segment_sum(_as_jax_array_(data),
141-
_as_jax_array_(segment_ids),
142-
indices_are_sorted=False)
143-
d = jax.ops.segment_sum(jnp.ones_like(data),
144-
_as_jax_array_(segment_ids),
145-
indices_are_sorted=False)
140+
data = _as_jax_array_(data)
141+
segment_ids = _as_jax_array_(segment_ids)
142+
r = jax.ops.segment_sum(data, segment_ids, indices_are_sorted=False)
143+
d = jax.ops.segment_sum(jnp.ones_like(data), segment_ids, indices_are_sorted=False)
146144
return _return(jnp.nan_to_num(r / d))
147145

148146

@@ -204,12 +202,12 @@ def unsorted_segment_sqrt_n(data, segment_ids, num_segments):
204202
See https://tensorflow.google.cn/api_docs/python/tf/math/unsorted_segment_sqrt_n
205203
206204
"""
207-
r = jax.ops.segment_sum(_as_jax_array_(data),
208-
_as_jax_array_(segment_ids),
205+
data = _as_jax_array_(data)
206+
segment_ids = _as_jax_array_(segment_ids)
207+
r = jax.ops.segment_sum(data, segment_ids,
209208
num_segments=num_segments,
210209
indices_are_sorted=False)
211-
d = jax.ops.segment_sum(jnp.ones_like(data),
212-
_as_jax_array_(segment_ids),
210+
d = jax.ops.segment_sum(jnp.ones_like(data), segment_ids,
213211
num_segments=num_segments,
214212
indices_are_sorted=False)
215213
return _return(jnp.nan_to_num(r / jnp.sqrt(d)))
@@ -221,12 +219,12 @@ def unsorted_segment_mean(data, segment_ids, num_segments):
221219
See https://tensorflow.google.cn/api_docs/python/tf/math/unsorted_segment_mean
222220
223221
"""
224-
r = jax.ops.segment_sum(_as_jax_array_(data),
225-
_as_jax_array_(segment_ids),
222+
data = _as_jax_array_(data)
223+
segment_ids = _as_jax_array_(segment_ids)
224+
r = jax.ops.segment_sum(data, segment_ids,
226225
num_segments=num_segments,
227226
indices_are_sorted=False)
228-
d = jax.ops.segment_sum(jnp.ones_like(data),
229-
_as_jax_array_(segment_ids),
227+
d = jax.ops.segment_sum(jnp.ones_like(data), segment_ids,
230228
num_segments=num_segments,
231229
indices_are_sorted=False)
232230
return _return(jnp.nan_to_num(r / d))
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
# -*- coding: utf-8 -*-
2+
"""Regression tests for the 2026-06-19 ``math-compat`` audit (P3-* findings).
3+
4+
Covered findings (see ``docs/issues-found-20260619-math-compat.md``):
5+
6+
* P3-H1 (``activations.py``) -- ``gelu`` must promote integer inputs to a
7+
floating dtype before computing; both the approximate and exact branches were
8+
silently wrong on integer input.
9+
* P3-H2 (``compat_pytorch.py``) -- ``unflatten`` must honour a negative ``dim``
10+
(PyTorch semantics) and reject out-of-range dims.
11+
* P3-M1 (``compat_tensorflow.py``) -- ``segment_mean`` / ``unsorted_segment_mean``
12+
/ ``unsorted_segment_sqrt_n`` must convert ``data`` to a jax array before
13+
``jnp.ones_like`` (do not rely on the deprecated implicit ``__jax_array__``).
14+
"""
15+
16+
import jax
17+
import jax.nn as jnn
18+
import jax.numpy as jnp
19+
import numpy as np
20+
import pytest
21+
22+
import brainpy.math as bm
23+
from brainpy.math import (
24+
activations as act,
25+
compat_pytorch as cpt,
26+
compat_tensorflow as ctf,
27+
)
28+
29+
30+
def _j(x):
31+
return bm.as_jax(x)
32+
33+
34+
def _finite(x):
35+
return bool(jnp.all(jnp.isfinite(_j(x))))
36+
37+
38+
# ---------------------------------------------------------------------------
39+
# P3-H1: gelu integer-input promotion
40+
# ---------------------------------------------------------------------------
41+
42+
def test_gelu_integer_input_matches_float_approximate():
43+
"""P3-H1: approximate gelu on int input must equal the float computation."""
44+
xi = jnp.array([1, 2, 3], dtype=jnp.int32)
45+
xf = jnp.array([1., 2., 3.])
46+
ri = np.asarray(_j(act.gelu(xi, approximate=True)))
47+
rf = np.asarray(_j(act.gelu(xf, approximate=True)))
48+
np.testing.assert_allclose(ri, rf, atol=1e-6)
49+
# and it must agree with jax.nn.gelu (the reference implementation)
50+
np.testing.assert_allclose(ri, np.asarray(jnn.gelu(xi, approximate=True)), atol=1e-6)
51+
# specifically: NOT the truncated x/2 result the bug produced
52+
assert not np.allclose(ri, np.asarray(xf) / 2.0)
53+
54+
55+
def test_gelu_integer_input_matches_float_exact():
56+
"""P3-H1: exact gelu on int input must not be truncated back to int."""
57+
xi = jnp.array([1, 2, 3], dtype=jnp.int32)
58+
xf = jnp.array([1., 2., 3.])
59+
ri = act.gelu(xi, approximate=False)
60+
assert jnp.issubdtype(_j(ri).dtype, jnp.floating)
61+
np.testing.assert_allclose(
62+
np.asarray(_j(ri)),
63+
np.asarray(_j(act.gelu(xf, approximate=False))),
64+
atol=1e-6,
65+
)
66+
# reference parity
67+
np.testing.assert_allclose(
68+
np.asarray(_j(ri)), np.asarray(jnn.gelu(xi, approximate=False)), atol=1e-5)
69+
70+
71+
def test_gelu_float_unchanged():
72+
"""The float path must be unchanged by the promotion fix."""
73+
x = bm.asarray([-1., 0., 1., 2.])
74+
for approx in (True, False):
75+
np.testing.assert_allclose(
76+
np.asarray(_j(act.gelu(x, approximate=approx))),
77+
np.asarray(jnn.gelu(_j(x), approximate=approx)),
78+
atol=1e-5,
79+
)
80+
81+
82+
def test_gelu_accepts_brainpy_array_and_is_finite():
83+
x = bm.asarray([-3., -1., 0., 1., 3.])
84+
assert _finite(act.gelu(x, approximate=True))
85+
assert _finite(act.gelu(x, approximate=False))
86+
87+
88+
# ---------------------------------------------------------------------------
89+
# P3-H2: unflatten negative dim
90+
# ---------------------------------------------------------------------------
91+
92+
def test_unflatten_negative_dim():
93+
"""P3-H2: negative dim must be normalised like torch.unflatten."""
94+
x = bm.asarray(jnp.arange(6.))
95+
r = cpt.unflatten(x, -1, (2, 3))
96+
assert _j(r).shape == (2, 3)
97+
# equivalent to the positive-dim call
98+
np.testing.assert_allclose(
99+
np.asarray(_j(r)), np.asarray(_j(cpt.unflatten(x, 0, (2, 3)))))
100+
101+
102+
def test_unflatten_negative_dim_higher_rank():
103+
x = bm.asarray(jnp.arange(24.).reshape(2, 12))
104+
r = cpt.unflatten(x, -1, (3, 4))
105+
assert _j(r).shape == (2, 3, 4)
106+
r2 = cpt.unflatten(x, -2, (1, 2))
107+
assert _j(r2).shape == (1, 2, 12)
108+
109+
110+
def test_unflatten_positive_dim_still_works():
111+
x = bm.asarray(jnp.arange(6.))
112+
assert _j(cpt.unflatten(x, 0, (2, 3))).shape == (2, 3)
113+
assert _j(cpt.unflatten(x, 0, (-1, 3))).shape == (2, 3)
114+
115+
116+
def test_unflatten_dim_out_of_range():
117+
x = bm.asarray(jnp.arange(6.))
118+
with pytest.raises((ValueError, AssertionError, IndexError)):
119+
cpt.unflatten(x, 5, (2, 3))
120+
with pytest.raises((ValueError, AssertionError, IndexError)):
121+
cpt.unflatten(x, -5, (2, 3))
122+
123+
124+
# ---------------------------------------------------------------------------
125+
# P3-M1: TF segment helpers must not lean on implicit __jax_array__
126+
# ---------------------------------------------------------------------------
127+
128+
def test_segment_mean_array_input():
129+
data = bm.asarray([1., 2., 3., 4.])
130+
seg = bm.asarray([0, 0, 1, 1])
131+
np.testing.assert_allclose(np.asarray(_j(ctf.segment_mean(data, seg))), [1.5, 3.5])
132+
133+
134+
def test_unsorted_segment_mean_array_input():
135+
data = bm.asarray([1., 2., 3., 4.])
136+
seg = bm.asarray([0, 0, 1, 1])
137+
np.testing.assert_allclose(
138+
np.asarray(_j(ctf.unsorted_segment_mean(data, seg, 2))), [1.5, 3.5])
139+
140+
141+
def test_unsorted_segment_sqrt_n_array_input():
142+
data = bm.asarray([1., 1., 1., 1.])
143+
seg = bm.asarray([0, 0, 1, 1])
144+
# sum over 2-element segments divided by sqrt(2)
145+
np.testing.assert_allclose(
146+
np.asarray(_j(ctf.unsorted_segment_sqrt_n(data, seg, 2))),
147+
[2.0 / np.sqrt(2.0), 2.0 / np.sqrt(2.0)],
148+
atol=1e-6,
149+
)
150+
151+
152+
def test_unsorted_segment_mean_under_jit():
153+
"""The denominator (``jnp.ones_like``) must trace cleanly under jit.
154+
155+
``unsorted_segment_mean`` takes a static ``num_segments`` so it is
156+
jit-compatible (unlike ``segment_mean`` which infers it from the data).
157+
"""
158+
data = jnp.array([1., 2., 3., 4.])
159+
seg = jnp.array([0, 0, 1, 1])
160+
f = jax.jit(lambda d: bm.as_jax(ctf.unsorted_segment_mean(bm.asarray(d), bm.asarray(seg), 2)))
161+
np.testing.assert_allclose(np.asarray(f(data)), [1.5, 3.5])

0 commit comments

Comments
 (0)