Skip to content

Commit f322d78

Browse files
author
Han Wang
committed
feat(dpmodel): segment_max + numerically-stable mask-aware segment_softmax
Built on the existing xp_maximum_at (no new array_api helper needed). Part of NeighborGraph PR-D (graph-native attention).
1 parent 55d7e79 commit f322d78

3 files changed

Lines changed: 148 additions & 0 deletions

File tree

deepmd/dpmodel/utils/neighbor_graph/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@
3333
pad_and_guard_edges,
3434
)
3535
from .segment import (
36+
segment_max,
3637
segment_mean,
38+
segment_softmax,
3739
segment_sum,
3840
)
3941

@@ -49,6 +51,8 @@
4951
"neighbor_graph_from_ijs",
5052
"node_validity_mask",
5153
"pad_and_guard_edges",
54+
"segment_max",
5255
"segment_mean",
56+
"segment_softmax",
5357
"segment_sum",
5458
]

deepmd/dpmodel/utils/neighbor_graph/segment.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from deepmd.dpmodel.array_api import (
1010
Array,
1111
xp_add_at,
12+
xp_maximum_at,
1213
)
1314

1415

@@ -37,3 +38,52 @@ def segment_mean(data: Array, segment_ids: Array, num_segments: int) -> Array:
3738
# broadcast counts over the trailing dims of summed
3839
shape = (num_segments,) + (1,) * (summed.ndim - 1)
3940
return summed / xp.reshape(safe, shape)
41+
42+
43+
def segment_max(data: Array, segment_ids: Array, num_segments: int) -> Array:
44+
"""out[s] = max of data[i] over i with segment_ids[i] == s.
45+
46+
Shape ``(num_segments, *data.shape[1:])``; empty segments are ``-inf``
47+
(neutral element — callers guard with masks before consuming them).
48+
"""
49+
xp = array_api_compat.array_namespace(data)
50+
out = xp.full(
51+
(num_segments, *tuple(data.shape[1:])),
52+
-xp.inf,
53+
dtype=data.dtype,
54+
device=array_api_compat.device(data),
55+
)
56+
return xp_maximum_at(out, segment_ids, data)
57+
58+
59+
def segment_softmax(
60+
data: Array,
61+
segment_ids: Array,
62+
num_segments: int,
63+
mask: Array | None = None,
64+
) -> Array:
65+
"""Softmax over entries sharing a segment id, numerically stable.
66+
67+
Mirrors the dense ``np_softmax`` max-subtraction trick with a PER-SEGMENT
68+
max. ``mask`` (bool, per entry) removes masked entries from the softmax
69+
entirely (zero weight AND excluded from the denominator). Empty or
70+
fully-masked segments produce all-zero weights (no NaN).
71+
"""
72+
xp = array_api_compat.array_namespace(data)
73+
if mask is not None:
74+
# keep masked entries out of the per-segment max: send them to -inf
75+
neg = xp.full_like(data, -xp.inf)
76+
data_for_max = xp.where(mask, data, neg)
77+
else:
78+
data_for_max = data
79+
seg_max = segment_max(data_for_max, segment_ids, num_segments)
80+
# guard -inf (empty / fully-masked segments) so gather doesn't yield inf-inf
81+
seg_max = xp.where(xp.isinf(seg_max), xp.zeros_like(seg_max), seg_max)
82+
shifted = data - xp.take(seg_max, segment_ids, axis=0)
83+
ex = xp.exp(shifted)
84+
if mask is not None:
85+
ex = ex * xp.astype(mask, ex.dtype)
86+
denom = segment_sum(ex, segment_ids, num_segments)
87+
denom_e = xp.take(denom, segment_ids, axis=0)
88+
safe = xp.where(denom_e > 0, denom_e, xp.ones_like(denom_e))
89+
return ex / safe
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# SPDX-License-Identifier: LGPL-3.0-or-later
2+
"""segment_max / segment_softmax (NeighborGraph PR-D segment toolkit)."""
3+
4+
import numpy as np
5+
6+
from deepmd.dpmodel.utils.neighbor_graph import (
7+
segment_max,
8+
segment_softmax,
9+
)
10+
11+
12+
class TestSegmentMax:
13+
def test_basic(self) -> None:
14+
data = np.array([1.0, 5.0, 2.0, -3.0])
15+
ids = np.array([0, 0, 2, 2], dtype=np.int64)
16+
out = segment_max(data, ids, 3)
17+
assert out[0] == 5.0
18+
assert np.isneginf(out[1]) # empty segment
19+
assert out[2] == 2.0
20+
21+
def test_trailing_dims(self) -> None:
22+
data = np.array([[1.0, -2.0], [3.0, -4.0], [0.0, 9.0]])
23+
ids = np.array([1, 1, 0], dtype=np.int64)
24+
out = segment_max(data, ids, 2)
25+
np.testing.assert_allclose(out[0], [0.0, 9.0])
26+
np.testing.assert_allclose(out[1], [3.0, -2.0])
27+
28+
def test_torch_matches_numpy(self) -> None:
29+
import torch
30+
31+
data = np.array([0.3, 1.2, -0.7, 2.0])
32+
ids = np.array([0, 0, 1, 1], dtype=np.int64)
33+
ref = segment_max(data, ids, 2)
34+
out = segment_max(torch.from_numpy(data), torch.from_numpy(ids), 2)
35+
np.testing.assert_allclose(out.numpy(), ref)
36+
37+
38+
class TestSegmentSoftmax:
39+
def test_matches_dense(self) -> None:
40+
logits = np.array([1.0, 2.0, 0.5, -1.0])
41+
ids = np.array([0, 0, 0, 1], dtype=np.int64)
42+
w = segment_softmax(logits, ids, 2)
43+
ref0 = np.exp(np.array([1.0, 2.0, 0.5]) - 2.0)
44+
ref0 = ref0 / ref0.sum()
45+
np.testing.assert_allclose(w[:3], ref0, atol=1e-12)
46+
np.testing.assert_allclose(w[3], 1.0, atol=1e-12)
47+
48+
def test_stable_large_logits(self) -> None:
49+
logits = np.array([1e30, 1e30 + 1.0])
50+
ids = np.array([0, 0], dtype=np.int64)
51+
w = segment_softmax(logits, ids, 1)
52+
assert not np.any(np.isnan(w))
53+
np.testing.assert_allclose(w.sum(), 1.0, atol=1e-12)
54+
55+
def test_masked_entries_zero(self) -> None:
56+
logits = np.array([1.0, 2.0, 3.0])
57+
ids = np.array([0, 0, 0], dtype=np.int64)
58+
mask = np.array([True, False, True])
59+
w = segment_softmax(logits, ids, 1, mask=mask)
60+
assert w[1] == 0.0
61+
np.testing.assert_allclose(w.sum(), 1.0, atol=1e-12)
62+
# masked entry excluded from the denominator too
63+
ref = np.exp(np.array([1.0, 3.0]) - 3.0)
64+
ref = ref / ref.sum()
65+
np.testing.assert_allclose(w[[0, 2]], ref, atol=1e-12)
66+
67+
def test_all_masked_segment_is_zero_no_nan(self) -> None:
68+
logits = np.array([1.0, 2.0, 5.0])
69+
ids = np.array([0, 0, 1], dtype=np.int64)
70+
mask = np.array([True, True, False])
71+
w = segment_softmax(logits, ids, 2, mask=mask)
72+
assert not np.any(np.isnan(w))
73+
assert w[2] == 0.0
74+
75+
def test_empty_segment_no_nan(self) -> None:
76+
logits = np.array([1.0, 2.0])
77+
ids = np.array([0, 0], dtype=np.int64)
78+
w = segment_softmax(logits, ids, 3)
79+
assert not np.any(np.isnan(w))
80+
81+
def test_torch_matches_numpy(self) -> None:
82+
import torch
83+
84+
logits = np.array([0.3, 1.2, -0.7, 2.0])
85+
ids = np.array([0, 0, 1, 1], dtype=np.int64)
86+
mask = np.array([True, True, True, False])
87+
ref = segment_softmax(logits, ids, 2, mask=mask)
88+
out = segment_softmax(
89+
torch.from_numpy(logits),
90+
torch.from_numpy(ids),
91+
2,
92+
mask=torch.from_numpy(mask),
93+
)
94+
np.testing.assert_allclose(out.numpy(), ref, atol=1e-12)

0 commit comments

Comments
 (0)