Skip to content

Commit b634aa3

Browse files
fix(dpmodel): preserve virtual spin atom types (deepmodeling#5854)
Closes deepmodeling#5663. ## Summary - preserve negative placeholder types when creating dense and lower-interface spin partners - use one array-API-safe lookup that maps virtual atom types to zero instead of applying Python negative indexing - zero placeholder spin displacements, virial corrections, magnetic outputs, and magnetic masks - add dense, lower, end-to-end invariance, and Array API strict regressions ## Why existing tests missed this Existing spin consistency tests use only nonnegative real atom types, including their lower-interface ghost atoms. Negative-type tests cover base atomic models and neighbor-list builders, where masking happens before type-dependent work, but not `SpinModel` preprocessing, which runs earlier. Cross-backend spin tests therefore never supplied a nonzero padded spin or asserted that both the real placeholder and its generated spin partner remained negative. ## Validation - `pytest source/tests/common/dpmodel/test_spin_model_virtual_types.py source/tests/common/dpmodel/test_finetune_spin.py source/tests/common/dpmodel/test_spin_model_legacy_routing.py -q` (12 passed) - `ruff format .` (1664 files unchanged on final pass) - `ruff check .` (passed) - `git diff --check` (passed) Coding agent: Codex Codex version: codex-cli 0.144.4 Model: gpt-5.6-sol Reasoning effort: xhigh <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Bug Fixes** - Improved spin-model handling of padded and virtual atom placeholders. - Prevented placeholder atoms from receiving displacement, virial, scaling, or magnetic-output corrections. - Ensured consistent behavior across standard and lower-level spin processing paths. - Improved compatibility across supported array backends and processing devices. - **Tests** - Added regression coverage for virtual placeholders, masking, output handling, and consistency between model backends. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: njzjz-bot <njzjz.bot@gmail.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 1b3c9e9 commit b634aa3

4 files changed

Lines changed: 463 additions & 35 deletions

File tree

deepmd/dpmodel/model/spin_model.py

Lines changed: 65 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,20 @@ def _to_xp(self, arr: Any, xp: Any, ref_arr: Any) -> Any:
8383
"""Convert a numpy array to the same namespace as ref_arr."""
8484
return xp.asarray(arr, device=array_api_compat.device(ref_arr))
8585

86+
def _lookup_type_values(self, values: Any, atype: Array, ref_arr: Array) -> Array:
87+
"""Gather per-type values while mapping virtual atom types to zero.
88+
89+
Negative atom types are padding placeholders, not Python-style indices
90+
from the end of the type table. Their spin scale and mask must remain
91+
zero until the backbone model applies its normal virtual-atom mask.
92+
"""
93+
xp = array_api_compat.array_namespace(ref_arr)
94+
values = self._to_xp(values, xp, ref_arr)
95+
real_atom = atype >= 0
96+
safe_atype = xp.where(real_atom, atype, xp.zeros_like(atype))
97+
gathered = values[safe_atype]
98+
return xp.where(real_atom, gathered, xp.zeros_like(gathered))
99+
86100
def process_spin_input(
87101
self, coord: Array, atype: Array, spin: Array
88102
) -> tuple[Array, Array, Array]:
@@ -99,9 +113,12 @@ def process_spin_input(
99113
"""
100114
xp = array_api_compat.array_namespace(coord)
101115
nframes, nloc = coord.shape[:-1]
102-
atype_spin = xp.concat([atype, atype + self.ntypes_real], axis=-1)
103-
vsm = self._to_xp(self.virtual_scale_mask, xp, coord)
104-
spin_dist = spin * xp.reshape(vsm[atype], (nframes, nloc, 1))
116+
virtual_atype = xp.where(atype >= 0, atype + self.ntypes_real, atype)
117+
atype_spin = xp.concat([atype, virtual_atype], axis=-1)
118+
spin_dist = spin * xp.reshape(
119+
self._lookup_type_values(self.virtual_scale_mask, atype, coord),
120+
(nframes, nloc, 1),
121+
)
105122
virtual_coord = coord + spin_dist
106123
coord_spin = xp.concat([coord, virtual_coord], axis=-2)
107124
# for spin virial correction
@@ -153,12 +170,18 @@ def process_spin_input_lower(
153170
xp = array_api_compat.array_namespace(extended_coord)
154171
nframes, nall = extended_coord.shape[:2]
155172
nloc = nlist.shape[1]
156-
vsm = self._to_xp(self.virtual_scale_mask, xp, extended_coord)
157173
extended_spin_dist = extended_spin * xp.reshape(
158-
vsm[extended_atype], (nframes, nall, 1)
174+
self._lookup_type_values(
175+
self.virtual_scale_mask, extended_atype, extended_coord
176+
),
177+
(nframes, nall, 1),
159178
)
160179
virtual_extended_coord = extended_coord + extended_spin_dist
161-
virtual_extended_atype = extended_atype + self.ntypes_real
180+
virtual_extended_atype = xp.where(
181+
extended_atype >= 0,
182+
extended_atype + self.ntypes_real,
183+
extended_atype,
184+
)
162185
extended_coord_updated = self.concat_switch_virtual(
163186
extended_coord, virtual_extended_coord, nloc
164187
)
@@ -224,9 +247,18 @@ def process_spin_output(
224247
if virtual_scale:
225248
mask = self._to_xp(self.virtual_scale_mask, xp, out_tensor)
226249
else:
227-
mask = self._to_xp(self.spin_mask, xp, out_tensor)
228-
atomic_mask = xp.reshape(mask[atype], (nframes, nloc, 1))
229-
out_real, out_mag = out_tensor[:, :nloc], out_tensor[:, nloc:]
250+
# spin_mask is integral; it multiplies out_mag below, and the array
251+
# API does not promote across kinds.
252+
mask = xp.astype(
253+
self._to_xp(self.spin_mask, xp, out_tensor), out_tensor.dtype
254+
)
255+
atomic_mask = xp.reshape(
256+
self._lookup_type_values(mask, atype, out_tensor),
257+
(nframes, nloc, 1),
258+
)
259+
# Trailing ellipsis: the array API does not specify numpy's implicit
260+
# expansion of a partial multi-axis index.
261+
out_real, out_mag = out_tensor[:, :nloc, ...], out_tensor[:, nloc:, ...]
230262
if add_mag:
231263
out_real = out_real + out_mag
232264
out_mag = xp.reshape(
@@ -250,19 +282,27 @@ def process_spin_output_lower(
250282
if virtual_scale:
251283
mask = self._to_xp(self.virtual_scale_mask, xp, extended_out_tensor)
252284
else:
253-
mask = self._to_xp(self.spin_mask, xp, extended_out_tensor)
254-
atomic_mask = xp.reshape(mask[extended_atype], (nframes, nall, 1))
285+
# spin_mask is integral; it multiplies extended_out_mag below, and
286+
# the array API does not promote across kinds.
287+
mask = xp.astype(
288+
self._to_xp(self.spin_mask, xp, extended_out_tensor),
289+
extended_out_tensor.dtype,
290+
)
291+
atomic_mask = xp.reshape(
292+
self._lookup_type_values(mask, extended_atype, extended_out_tensor),
293+
(nframes, nall, 1),
294+
)
255295
extended_out_real = xp.concat(
256296
[
257-
extended_out_tensor[:, :nloc],
258-
extended_out_tensor[:, nloc + nloc : nloc + nall],
297+
extended_out_tensor[:, :nloc, ...],
298+
extended_out_tensor[:, nloc + nloc : nloc + nall, ...],
259299
],
260300
axis=1,
261301
)
262302
extended_out_mag = xp.concat(
263303
[
264-
extended_out_tensor[:, nloc : nloc + nloc],
265-
extended_out_tensor[:, nloc + nall :],
304+
extended_out_tensor[:, nloc : nloc + nloc, ...],
305+
extended_out_tensor[:, nloc + nall :, ...],
266306
],
267307
axis=1,
268308
)
@@ -700,8 +740,10 @@ def call_common(
700740
if "mask_mag" not in model_ret:
701741
xp = array_api_compat.array_namespace(atype)
702742
nframes_m, nloc_m = atype.shape[:2]
703-
vsm = self._to_xp(self.virtual_scale_mask, xp, atype)
704-
atomic_mask = xp.reshape(vsm[atype], (nframes_m, nloc_m, 1))
743+
atomic_mask = xp.reshape(
744+
self._lookup_type_values(self.virtual_scale_mask, atype, atype),
745+
(nframes_m, nloc_m, 1),
746+
)
705747
model_ret["mask_mag"] = atomic_mask > 0.0
706748
return model_ret
707749

@@ -883,8 +925,12 @@ def call_common_lower(
883925
if "mask_mag" not in model_ret:
884926
xp = array_api_compat.array_namespace(extended_atype)
885927
nall = extended_atype.shape[1]
886-
vsm = self._to_xp(self.virtual_scale_mask, xp, extended_atype)
887-
atomic_mask = xp.reshape(vsm[extended_atype], (nframes, nall, 1))
928+
atomic_mask = xp.reshape(
929+
self._lookup_type_values(
930+
self.virtual_scale_mask, extended_atype, extended_atype
931+
),
932+
(nframes, nall, 1),
933+
)
888934
model_ret["mask_mag"] = atomic_mask > 0.0
889935
return model_ret
890936

deepmd/pt/model/model/spin_model.py

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -91,21 +91,29 @@ def _pack_spin_stat_sample(
9191

9292
def _lookup_type_values(values: torch.Tensor, atype: torch.Tensor) -> torch.Tensor:
9393
"""
94-
Gather one scalar value per atom type.
95-
96-
``values[atype]`` is semantically equivalent, but AOTInductor may lower
97-
that advanced-indexing form to a CUDA ``index.Tensor`` shim even for a CPU
98-
``.pt2`` package. ``index_select`` keeps the exported spin graph device
99-
stable while preserving the same lookup semantics.
100-
101-
Padding ghost slots carry ``atype == -1`` (batched extended regions are
102-
padded to a uniform ``nall``). Unlike advanced indexing, ``index_select``
103-
rejects negative indices, so the padding entries are clamped to row 0; their
104-
looked-up value is irrelevant because padding atoms carry zero spin and are
105-
dropped from the per-local output downstream.
94+
Gather one scalar value per atom type, mapping virtual atom types to zero.
95+
96+
``values[atype]`` is semantically equivalent for real atoms, but
97+
AOTInductor may lower that advanced-indexing form to a CUDA
98+
``index.Tensor`` shim even for a CPU ``.pt2`` package. ``index_select``
99+
keeps the exported spin graph device stable.
100+
101+
Padding slots carry ``atype == -1``: ``deepmd/utils/data.py`` appends it as
102+
the virtual-atom padding for mixed-type systems, and batched extended
103+
regions are padded to a uniform ``nall``. Those are placeholders, not
104+
Python-style indices from the end of the type table, so they get zero
105+
rather than row 0's value — otherwise a padded slot picks up a real spin
106+
scale and mask whenever type 0 is magnetic. This matches
107+
``SpinModel._lookup_type_values`` in ``deepmd/dpmodel/model/spin_model.py``.
106108
"""
107-
flat_atype = torch.clamp_min(atype.reshape(-1).to(dtype=torch.long), 0)
108-
return torch.index_select(values.to(atype.device), 0, flat_atype).view(atype.shape)
109+
long_atype = atype.to(dtype=torch.long)
110+
real_atom = long_atype >= 0
111+
# index_select rejects negative indices, unlike advanced indexing.
112+
flat_atype = torch.clamp_min(long_atype.reshape(-1), 0)
113+
gathered = torch.index_select(values.to(atype.device), 0, flat_atype).view(
114+
atype.shape
115+
)
116+
return torch.where(real_atom, gathered, torch.zeros_like(gathered))
109117

110118

111119
class SpinModel(torch.nn.Module):
@@ -140,7 +148,12 @@ def process_spin_input(
140148
nframes, nloc = atype.shape
141149
coord = coord.reshape(nframes, nloc, 3)
142150
spin = spin.reshape(nframes, nloc, 3)
143-
atype_spin = torch.concat([atype, atype + self.ntypes_real], dim=-1)
151+
# Keep virtual placeholders at -1 instead of offsetting them into a
152+
# real type of the spin half of the type table.
153+
virtual_atype = torch.where(
154+
atype >= 0, atype + self.ntypes_real, torch.full_like(atype, -1)
155+
)
156+
atype_spin = torch.concat([atype, virtual_atype], dim=-1)
144157
# spin_dist = s_i * \mu_i
145158
spin_dist = spin * _lookup_type_values(
146159
self.virtual_scale_mask,
@@ -193,7 +206,11 @@ def process_spin_input_lower(
193206
extended_atype,
194207
).reshape([nframes, nall, 1])
195208
virtual_extended_coord = extended_coord + extended_spin_dist
196-
virtual_extended_atype = extended_atype + self.ntypes_real
209+
virtual_extended_atype = torch.where(
210+
extended_atype >= 0,
211+
extended_atype + self.ntypes_real,
212+
torch.full_like(extended_atype, -1),
213+
)
197214
extended_coord_updated = concat_switch_virtual(
198215
extended_coord, virtual_extended_coord, nloc
199216
)

0 commit comments

Comments
 (0)