Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 24 additions & 9 deletions deepmd/dpmodel/utils/neighbor_stat.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,23 +86,38 @@ def call(
- xp.reshape(coord0, (nframes, -1, 3))[:, :, None, :]
)
assert list(diff.shape) == [nframes, nloc, nall, 3]
# remove the diagonal elements
mask = xp.eye(nloc, nall, dtype=xp.bool, device=array_api_compat.device(diff))
mask = xp.tile(mask[None, :, :, None], (nframes, 1, 1, 3))
diff = xp.where(mask, xp.full_like(diff, xp.inf), diff)
# A valid statistics pair must contain two real atoms. In particular,
# virtual centers must not contribute an artificially large neighbor row,
# and virtual neighbors must not drive the minimum distance to zero.
self_pair = xp.eye(
nloc, nall, dtype=xp.bool, device=array_api_compat.device(diff)
)
real_center = atype >= 0
real_neighbor = extend_atype >= 0
Comment on lines +95 to +96

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

real_center and real_neighbor are the fix, and both are correct. The same operator is duplicated in Paddle, which this PR does not touch: deepmd/pd/utils/neighbor_stat.py#L88-L112. It is a hand-written paddle.nn.Layer, not an import of this class, so it does not inherit the change. deepmd/pt, deepmd/jax and deepmd/pt_expt all import NeighborStatOP from here and do pick it up, which leaves Paddle as the only backend behind.

That copy is line-for-line the pre-fix logic: it masks only the eye(nloc, nall) self pair before min_rr2, and its non-mixed branch never excludes virtual centers at all. On this PR's own fixture (coord = [(0,0,0), (0,0,0), (1,0,0), (3,0,0)], atype = [0,-1,0,1], rcut = 1.1) Paddle still returns min_rr2 = [0, 0, 1, 4] and max_nnei = [[2]], against [1, inf, 1, 4] and [[1]] everywhere else. So dp --pd neighbor-stat on a mixed-type dataset whose padding slot lands on a real atom still aborts with RuntimeError: Some atoms are overlapping, and where it does not abort it picks a strictly larger auto-sel than the other backends for the same data. Paddle's own build_neighbor_list already relocates virtual atoms, so that extra sel is pure waste.

CLAUDE.md asks for this explicitly: "Before editing shared code (deepmd/dpmodel/, base classes), find every importer and run all affected backends' tests: backends routinely re-export or subclass generic classes, so one change ripples across tf/pt/pt_expt/dpmodel/jax/pd." The same point came up on #5854 and #5856 and was handled in a follow-up commit each time; the cleanest resolution here is the same.

@njzjz-bot njzjz-bot Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8b594d7. The Paddle implementation now excludes self pairs, virtual centers, and virtual neighbors before both the minimum-distance and neighbor-count reductions, matching the generic operator. I also added direct Paddle coverage for periodic/nonperiodic inputs and mixed/type-separated counts using this fixture.

Validation:

  • pytest source/tests/pd/test_neighbor_stat.py -q: 2 passed, 12 subtests passed
  • generic neighbor-stat regressions: 2 passed, 6 subtests passed
  • ruff format . and ruff check .: passed

Coding agent: Codex
Codex version: codex-cli 0.144.6
Model: gpt-5.6-sol
Reasoning effort: xhigh

valid_pair = (
~self_pair[None, :, :] & real_center[:, :, None] & real_neighbor[:, None, :]
)
Comment thread
njzjz-bot marked this conversation as resolved.
rr2 = xp.sum(xp.square(diff), axis=-1)
rr2 = xp.where(valid_pair, rr2, xp.full_like(rr2, xp.inf))
min_rr2 = xp.min(rr2, axis=-1)
# count the number of neighbors
within_rcut = valid_pair & (rr2 < self.rcut**2)
if not self.mixed_types:
mask = rr2 < self.rcut**2
nneis = []
for ii in range(self.ntypes):
nneis.append(xp.sum(mask & (extend_atype == ii)[:, None, :], axis=-1))
nneis.append(
xp.sum(
xp.astype(
within_rcut & (extend_atype == ii)[:, None, :],
extend_atype.dtype,
),
axis=-1,
)
)
nnei = xp.stack(nneis, axis=-1)
else:
mask = rr2 < self.rcut**2
# virtual type (<0) are not counted
nnei = xp.sum(mask & (extend_atype >= 0)[:, None, :], axis=-1)
# Array API reductions accept numeric rather than boolean inputs.
nnei = xp.sum(xp.astype(within_rcut, extend_atype.dtype), axis=-1)
nnei = xp.reshape(nnei, (nframes, nloc, 1))
max_nnei = xp.max(nnei, axis=1)
return min_rr2, max_nnei
Expand Down
46 changes: 27 additions & 19 deletions deepmd/pd/utils/neighbor_stat.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,30 +85,38 @@ def forward(
1
) - coord0.reshape([nframes, -1, 3]).unsqueeze(2)
assert list(diff.shape) == [nframes, nloc, nall, 3]
# remove the diagonal elements
mask = paddle.eye(nloc, nall).to(dtype=paddle.bool, device=diff.place)
# diff[:, mask] = float("inf")
# diff.masked_fill_(
# paddle.broadcast_to(mask.unsqueeze([0, -1]), diff.shape),
# paddle.to_tensor(float("inf")),
# )
diff[paddle.broadcast_to(mask.unsqueeze([0, -1]), diff.shape)] = float("inf")
# Match the generic neighbor-stat operator: virtual atoms cannot be
# statistics centers or neighbors, and self pairs are always excluded.
self_pair = paddle.eye(nloc, nall).to(dtype=paddle.bool, device=diff.place)
real_center = atype >= 0
real_neighbor = extend_atype >= 0
valid_pair = (
~self_pair.unsqueeze(0)
& real_center.unsqueeze(2)
& real_neighbor.unsqueeze(1)
)
rr2 = paddle.sum(paddle.square(diff), axis=-1)
rr2 = paddle.where(valid_pair, rr2, paddle.full_like(rr2, float("inf")))
min_rr2 = paddle.min(rr2, axis=-1)
# count the number of neighbors
within_rcut = valid_pair & (rr2 < self.rcut**2)
if not self.mixed_types:
mask = rr2 < self.rcut**2
nnei = paddle.zeros([nframes, nloc, self.ntypes], dtype=paddle.int64)
for ii in range(self.ntypes):
nnei[:, :, ii] = paddle.sum(
mask & ((extend_atype == ii)[:, None, :]), axis=-1
)
nnei = paddle.stack(
[
paddle.sum(
(within_rcut & (extend_atype == ii).unsqueeze(1)).astype(
extend_atype.dtype
),
axis=-1,
)
for ii in range(self.ntypes)
],
axis=-1,
)
else:
mask = rr2 < self.rcut**2
# virtual types (<0) are not counted
nnei = paddle.sum(
mask & ((extend_atype >= 0).unsqueeze(1)), axis=-1
).reshape([nframes, nloc, 1])
nnei = paddle.sum(within_rcut.astype(extend_atype.dtype), axis=-1).reshape(
[nframes, nloc, 1]
)
max_nnei = paddle.max(nnei, axis=1)
return min_rr2, max_nnei

Expand Down
51 changes: 51 additions & 0 deletions source/tests/common/dpmodel/array_api/test_neighbor_stat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# SPDX-License-Identifier: LGPL-3.0-or-later
import unittest

import array_api_strict as xp

from deepmd.dpmodel.utils.neighbor_stat import (
NeighborStatOP,
)

from .utils import (
ArrayAPITest,
)


class TestNeighborStatOP(unittest.TestCase, ArrayAPITest):
def test_virtual_atoms_are_masked_before_reductions(self) -> None:
"""Virtual-pair masking and neighbor reductions follow the Array API."""
coord = xp.reshape(
xp.asarray(
[
[0.0, 0.0, 0.0],
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[3.0, 0.0, 0.0],
],
dtype=xp.float64,
),
(1, -1),
)
atype = xp.asarray([[0, -1, 0, 1]], dtype=xp.int64)
expected_min_rr2 = xp.asarray([[1.0, xp.inf, 1.0, 4.0]], dtype=xp.float64)

for mixed_types in (False, True):
with self.subTest(mixed_types=mixed_types):
min_rr2, max_nnei = NeighborStatOP(2, 1.1, mixed_types).call(
coord, atype, None
)
expected_max_nnei = xp.asarray(
[[1]] if mixed_types else [[1, 0]], dtype=xp.int64
)

self.assertTrue(bool(xp.all(min_rr2 == expected_min_rr2)))
self.assertTrue(bool(xp.all(max_nnei == expected_max_nnei)))
self.assert_namespace_equal(min_rr2, coord)
self.assert_namespace_equal(max_nnei, atype)
self.assert_device_equal(min_rr2, coord)
self.assert_device_equal(max_nnei, atype)
self.assert_dtype_equal(min_rr2, coord)
self.assert_dtype_equal(max_nnei, atype)
self.assertEqual(min_rr2.shape, (1, 4))
self.assertEqual(max_nnei.shape, expected_max_nnei.shape)
40 changes: 40 additions & 0 deletions source/tests/common/dpmodel/test_neighbor_stat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# SPDX-License-Identifier: LGPL-3.0-or-later
import unittest

import numpy as np

from deepmd.dpmodel.utils.neighbor_stat import (
NeighborStatOP,
)


class TestNeighborStatOP(unittest.TestCase):
def test_virtual_atoms_do_not_affect_statistics(self) -> None:
"""Ignore virtual atoms as both neighbor-stat centers and neighbors."""
# Atom 1 is virtual and overlaps atom 0. Without a neighbor mask it
# drives the minimum distance to zero; without a center mask it sees both
# type-0 atoms and inflates their maximum neighbor count from one to two.
coord = np.array(
[
[0.0, 0.0, 0.0],
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[3.0, 0.0, 0.0],
],
dtype=np.float64,
).reshape(1, -1)
atype = np.array([[0, -1, 0, 1]], dtype=np.int64)
expected_min_rr2 = np.array([[1.0, np.inf, 1.0, 4.0]])

for cell in (None, 10.0 * np.eye(3).reshape(1, 9)):
for mixed_types in (False, True):
with self.subTest(cell=cell is not None, mixed_types=mixed_types):
min_rr2, max_nnei = NeighborStatOP(
ntypes=2,
rcut=1.1,
mixed_types=mixed_types,
).call(coord, atype, cell)

np.testing.assert_allclose(min_rr2, expected_min_rr2)
expected_max_nnei = [[1]] if mixed_types else [[1, 0]]
np.testing.assert_array_equal(max_nnei, expected_max_nnei)
44 changes: 44 additions & 0 deletions source/tests/pd/test_neighbor_stat.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,17 @@

import dpdata
import numpy as np
import paddle

from deepmd.entrypoints.neighbor_stat import (
neighbor_stat,
)
from deepmd.pd.utils.env import (
DEVICE,
)
from deepmd.pd.utils.neighbor_stat import (
NeighborStatOP,
)

from ..seed import (
GLOBAL_SEED,
Expand Down Expand Up @@ -67,3 +74,40 @@ def test_neighbor_stat(self):
if not mixed_type:
ret.append(0)
np.testing.assert_array_equal(max_nbor_size, ret)


class TestNeighborStatOP(unittest.TestCase):
def test_virtual_atoms_do_not_affect_statistics(self) -> None:
"""Ignore virtual atoms as both statistics centers and neighbors."""
# Atom 1 is virtual and overlaps atom 0. Without both masks, it either
# sets the minimum distance to zero or inflates the maximum type-0 count.
coord = paddle.to_tensor(
[
[
[0.0, 0.0, 0.0],
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[3.0, 0.0, 0.0],
]
],
dtype=paddle.float64,
place=DEVICE,
).reshape([1, -1])
atype = paddle.to_tensor([[0, -1, 0, 1]], dtype=paddle.int64, place=DEVICE)
expected_min_rr2 = np.array([[1.0, np.inf, 1.0, 4.0]])

for cell in (
None,
10.0 * paddle.eye(3, dtype=paddle.float64).reshape([1, 9]).to(DEVICE),
):
for mixed_types in (False, True):
with self.subTest(cell=cell is not None, mixed_types=mixed_types):
min_rr2, max_nnei = NeighborStatOP(
ntypes=2,
rcut=1.1,
mixed_types=mixed_types,
)(coord, atype, cell)

np.testing.assert_allclose(min_rr2.numpy(), expected_min_rr2)
expected_max_nnei = [[1]] if mixed_types else [[1, 0]]
np.testing.assert_array_equal(max_nnei.numpy(), expected_max_nnei)
Comment on lines +99 to +113

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add a periodic self-image regression case.

Line 101 uses cell edges of 10.0 with rcut=1.1. Each periodic self-image is outside the cutoff. This test executes the periodic path, but it cannot verify that the implementation excludes only the original self-pair and retains periodic self-images.

Add a case with at least one cell axis shorter than rcut. Assert that the original self-pair is excluded and that an in-cutoff periodic self-image contributes to min_rr2 and max_nnei.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@source/tests/pd/test_neighbor_stat.py` around lines 99 - 113, Extend the
NeighborStatOP parameterized cell cases in the test around NeighborStatOP to
include a periodic cell with at least one axis shorter than rcut. Add expected
assertions for that case verifying the original self-pair remains excluded while
an in-cutoff periodic self-image increases min_rr2 and max_nnei, without
changing the existing non-periodic and large-cell expectations.

Loading