Skip to content

Commit 73a851f

Browse files
author
Han Wang
committed
fix(dpmodel,pt_expt): normalize use_spin forms and forward allow_missing_label in the native-spin builders
Review: PR deepmodeling#5884 (OutisLi, P1 x2). The DPA4 native-spin builders did a direct boolean conversion of spin.use_spin, breaking the public schema's index and symbol forms (['Ni'] became [True] and failed the length check; [0, 1] became [False, True]), and dropped spin.allow_missing_label (so datasets without spin.npy were rejected despite the documented zero default). - New pure helper deepmd.utils.spin.normalize_spin_use_spin(use_spin, type_map) -> list[bool]: single owner of the three-form contract pt implements in its frozen tree (bool passthrough / index scatter / symbol lookup with ValueError on unknown symbols); direct unit tests cover all forms incl. the empty list and input purity. - Both builder twins (dpmodel model.py get_dpa4_native_spin_model and pt_expt get_model._get_dpa4_native_spin_model) normalize via the helper and forward allow_missing_label into Spin(...). - get_additional_data_requirement drops its getattr has_spin/spin dances for direct calls (has_spin is base-declared; spin models carry .spin). - Tests: config-form parametrization (symbols/indices/booleans/unknown symbol) on both builders; the pt_expt data-requirement regression pins must=False + default=0.0 with allow_missing_label=True and must=True without.
1 parent 3c108c4 commit 73a851f

7 files changed

Lines changed: 200 additions & 9 deletions

File tree

deepmd/dpmodel/model/model.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
)
4949
from deepmd.utils.spin import (
5050
Spin,
51+
normalize_spin_use_spin,
5152
)
5253

5354
_DPA4_SEZM_DESCRIPTOR_TYPES = ("dpa4", "DPA4", "sezm", "SeZM")
@@ -223,10 +224,13 @@ def get_dpa4_native_spin_model(data: dict) -> DPA4NativeSpinModel:
223224
"is a follow-up; use one or the other"
224225
)
225226
spin_cfg = data.pop("spin")
226-
use_spin = [bool(flag) for flag in spin_cfg["use_spin"]]
227+
# Expand index/symbol forms of ``use_spin`` against ``type_map`` into the
228+
# per-type boolean list (pure; validates symbols).
229+
use_spin = normalize_spin_use_spin(spin_cfg["use_spin"], data["type_map"])
227230
spin = Spin(
228231
use_spin=use_spin,
229232
virtual_scale=spin_cfg.get("virtual_scale", 1.0),
233+
allow_missing_label=spin_cfg.get("allow_missing_label", False),
230234
)
231235
data["descriptor"]["use_spin"] = use_spin
232236
backbone_model = get_standard_model(data)

deepmd/pt_expt/model/get_model.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
)
4747
from deepmd.utils.spin import (
4848
Spin,
49+
normalize_spin_use_spin,
4950
)
5051

5152
log = logging.getLogger(__name__)
@@ -242,10 +243,13 @@ def _get_dpa4_native_spin_model(data: dict) -> DPA4NativeSpinModel:
242243
"charge-spin FiLM combined with native spin on the graph route "
243244
"is a follow-up; use one or the other"
244245
)
245-
use_spin = [bool(flag) for flag in spin_cfg["use_spin"]]
246+
# Expand index/symbol forms of ``use_spin`` against ``type_map`` into the
247+
# per-type boolean list (pure; validates symbols).
248+
use_spin = normalize_spin_use_spin(spin_cfg["use_spin"], data["type_map"])
246249
spin = Spin(
247250
use_spin=use_spin,
248251
virtual_scale=spin_cfg.get("virtual_scale", 1.0),
252+
allow_missing_label=spin_cfg.get("allow_missing_label", False),
249253
)
250254
data["descriptor"]["use_spin"] = use_spin
251255
backbone_model = get_sezm_model(data)

deepmd/pt_expt/train/training.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -261,17 +261,13 @@ def get_additional_data_requirement(_model: Any) -> list[DataRequirementItem]:
261261
"aparam", _model.get_dim_aparam(), atomic=True, must=True
262262
)
263263
)
264-
has_spin = getattr(_model, "has_spin", False)
265-
if callable(has_spin):
266-
has_spin = has_spin()
267-
if has_spin:
264+
if _model.has_spin():
268265
# ``model.spin.allow_missing_label`` relaxes the spin label from
269266
# mandatory to optional with a zero default, so a system without a
270267
# ``spin`` file is filled with zeros rather than rejected. Mirrors
271268
# ``deepmd.pt.train.training.get_additional_data_requirement``.
272-
allow_missing_spin = getattr(
273-
getattr(_model, "spin", None), "allow_missing_label", False
274-
)
269+
# Every spin model wrapper carries a ``spin`` attribute.
270+
allow_missing_spin = _model.spin.allow_missing_label
275271
additional_data_requirement.append(
276272
DataRequirementItem(
277273
"spin",

deepmd/utils/spin.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,51 @@
88
)
99

1010

11+
def normalize_spin_use_spin(use_spin: list, type_map: list[str]) -> list[bool]:
12+
"""Normalize ``use_spin`` to a per-type boolean list.
13+
14+
Three equivalent forms are accepted: a per-type boolean list, a list of
15+
magnetic type indices, or a list of magnetic element symbols. The index
16+
and symbol forms are expanded against ``type_map``, so a large type map
17+
only needs its magnetic species named. Pure: the inputs are not
18+
modified.
19+
20+
Parameters
21+
----------
22+
use_spin : list
23+
The ``spin.use_spin`` configuration value, in any of the three
24+
accepted forms.
25+
type_map : list[str]
26+
The model's type map, defining the per-type order and (for the
27+
symbol form) the element names.
28+
29+
Returns
30+
-------
31+
list[bool]
32+
The per-type boolean form, of length ``len(type_map)``.
33+
34+
Raises
35+
------
36+
ValueError
37+
If a symbol in ``use_spin`` is absent from ``type_map``.
38+
"""
39+
if use_spin and isinstance(use_spin[0], str):
40+
type_index = {name: idx for idx, name in enumerate(type_map)}
41+
unknown = [name for name in use_spin if name not in type_index]
42+
if unknown:
43+
raise ValueError(
44+
f"spin.use_spin references element(s) {unknown} absent from type_map."
45+
)
46+
use_spin = [type_index[name] for name in use_spin]
47+
# ``bool`` is a subclass of ``int``; an already-boolean list is passed
48+
# through while an index list is scattered into a per-type mask.
49+
if not use_spin or not isinstance(use_spin[0], bool):
50+
mask = np.full(len(type_map), False, dtype=bool)
51+
mask[use_spin] = True
52+
return mask.tolist()
53+
return [bool(flag) for flag in use_spin]
54+
55+
1156
class Spin:
1257
"""Class for spin, mainly processes the spin type-related information.
1358
Atom types can be split into three kinds:

source/tests/common/dpmodel/test_dpa4_native_spin_model.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,47 @@ def test_translated_output_def_has_spin_keys(self):
126126
assert "force_mag" in out_def
127127
assert "energy" in out_def
128128
assert "force" in out_def
129+
130+
131+
class TestNativeSpinConfigForms:
132+
"""``spin.use_spin`` index/symbol forms and ``allow_missing_label``.
133+
134+
The public schema accepts a per-type boolean list, a list of magnetic
135+
type indices, or a list of element symbols (expanded against
136+
``type_map`` by ``normalize_spin_use_spin``); ``allow_missing_label``
137+
must be forwarded into the constructed :class:`Spin`.
138+
"""
139+
140+
@pytest.mark.parametrize(
141+
("use_spin_form", "expected"),
142+
[
143+
(["Ni"], [True, False]), # element-symbol form
144+
([0], [True, False]), # type-index form
145+
([0, 1], [True, True]), # multiple type indices
146+
([True, False], [True, False]), # canonical boolean passthrough
147+
],
148+
)
149+
def test_use_spin_forms(self, use_spin_form, expected):
150+
config = copy.deepcopy(NATIVE_SPIN_CONFIG)
151+
config["spin"] = {"use_spin": use_spin_form, "scheme": "native"}
152+
model = get_model(config)
153+
assert model.spin.use_spin.tolist() == expected
154+
# The descriptor consumes the SAME normalized boolean list.
155+
descriptor = model.backbone_model.atomic_model.descriptor
156+
assert [bool(flag) for flag in descriptor.use_spin] == expected
157+
158+
def test_use_spin_unknown_symbol_raises(self):
159+
config = copy.deepcopy(NATIVE_SPIN_CONFIG)
160+
config["spin"] = {"use_spin": ["Fe"], "scheme": "native"}
161+
with pytest.raises(ValueError, match="absent from type_map"):
162+
get_model(config)
163+
164+
def test_allow_missing_label_forwarded(self):
165+
config = copy.deepcopy(NATIVE_SPIN_CONFIG)
166+
config["spin"]["allow_missing_label"] = True
167+
model = get_model(config)
168+
assert model.spin.allow_missing_label is True
169+
170+
def test_allow_missing_label_default_false(self):
171+
model = get_model(copy.deepcopy(NATIVE_SPIN_CONFIG))
172+
assert model.spin.allow_missing_label is False

source/tests/common/test_spin.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,52 @@
66

77
from deepmd.utils.spin import (
88
Spin,
9+
normalize_spin_use_spin,
910
)
1011

1112
CUR_DIR = os.path.dirname(__file__)
1213

1314

15+
class NormalizeUseSpinTest(unittest.TestCase):
16+
"""Unit tests for ``normalize_spin_use_spin`` (pure; all three forms)."""
17+
18+
def setUp(self) -> None:
19+
self.type_map = ["Ni", "O", "H"]
20+
21+
def test_boolean_passthrough(self) -> None:
22+
self.assertEqual(
23+
normalize_spin_use_spin([True, False, True], self.type_map),
24+
[True, False, True],
25+
)
26+
27+
def test_index_form(self) -> None:
28+
self.assertEqual(
29+
normalize_spin_use_spin([0, 2], self.type_map),
30+
[True, False, True],
31+
)
32+
33+
def test_symbol_form(self) -> None:
34+
self.assertEqual(
35+
normalize_spin_use_spin(["Ni", "H"], self.type_map),
36+
[True, False, True],
37+
)
38+
39+
def test_empty_list_all_false(self) -> None:
40+
self.assertEqual(
41+
normalize_spin_use_spin([], self.type_map),
42+
[False, False, False],
43+
)
44+
45+
def test_unknown_symbol_raises(self) -> None:
46+
with self.assertRaisesRegex(ValueError, "absent from type_map"):
47+
normalize_spin_use_spin(["Fe"], self.type_map)
48+
49+
def test_pure_no_input_mutation(self) -> None:
50+
use_spin = ["Ni"]
51+
normalize_spin_use_spin(use_spin, self.type_map)
52+
self.assertEqual(use_spin, ["Ni"])
53+
54+
1455
class SpinTest(unittest.TestCase):
1556
def setUp(self) -> None:
1657
type_map_1 = ["H", "O"]

source/tests/pt_expt/model/test_dpa4_native_spin.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -902,3 +902,60 @@ def test_training_smoke(self, tmp_path) -> None:
902902
)
903903
finally:
904904
os.chdir(old_cwd)
905+
906+
907+
class TestNativeSpinConfigFormsPtExpt:
908+
"""pt_expt twin of ``test_dpa4_native_spin_model.py::TestNativeSpinConfigForms``.
909+
910+
``spin.use_spin`` index/symbol forms are expanded against ``type_map``
911+
(``normalize_spin_use_spin``), and ``allow_missing_label`` is forwarded
912+
into the constructed :class:`Spin` -- observable through the trainer's
913+
spin data requirement.
914+
"""
915+
916+
@pytest.mark.parametrize(
917+
("use_spin_form", "expected"),
918+
[
919+
(["Ni"], [True, False]), # element-symbol form
920+
([0], [True, False]), # type-index form
921+
([0, 1], [True, True]), # multiple type indices
922+
([True, False], [True, False]), # canonical boolean passthrough
923+
],
924+
)
925+
def test_use_spin_forms(self, use_spin_form, expected) -> None:
926+
config = copy.deepcopy(NATIVE_SPIN_CONFIG)
927+
config["spin"] = {"use_spin": use_spin_form, "scheme": "native"}
928+
model = get_model(config)
929+
assert model.spin.use_spin.tolist() == expected
930+
descriptor = model.backbone_model.atomic_model.descriptor
931+
assert [bool(flag) for flag in descriptor.use_spin] == expected
932+
933+
def test_use_spin_unknown_symbol_raises(self) -> None:
934+
config = copy.deepcopy(NATIVE_SPIN_CONFIG)
935+
config["spin"] = {"use_spin": ["Fe"], "scheme": "native"}
936+
with pytest.raises(ValueError, match="absent from type_map"):
937+
get_model(config)
938+
939+
@pytest.mark.parametrize(
940+
("allow_missing", "expected_must"),
941+
[
942+
(True, False), # relaxed: spin file optional, zero default
943+
(False, True), # default: spin file mandatory
944+
],
945+
)
946+
def test_allow_missing_label_data_requirement(
947+
self, allow_missing, expected_must
948+
) -> None:
949+
from deepmd.pt_expt.train.training import (
950+
get_additional_data_requirement,
951+
)
952+
953+
config = copy.deepcopy(NATIVE_SPIN_CONFIG)
954+
if allow_missing:
955+
config["spin"]["allow_missing_label"] = True
956+
model = get_model(config)
957+
assert model.spin.allow_missing_label is allow_missing
958+
reqs = get_additional_data_requirement(model)
959+
spin_req = next(rr for rr in reqs if rr.key == "spin")
960+
assert spin_req.must is expected_must
961+
assert spin_req.default == 0.0

0 commit comments

Comments
 (0)