Skip to content

Commit ed691aa

Browse files
wanghan-iapcmHan Wangpre-commit-ci[bot]
authored
refactor(model): express analytical bridging as an explicit linear_ener composition (deepmodeling#5964)
Close deepmodeling#5948. A bridged model IS a linear composition, but it was spelled as a `bridging_method` flag on a non-composite model type. The type you requested was not the type you got, and every builder that accepted the flag re-implemented the composition (and drifted; see deepmodeling#5947). ## Canonical spelling ```json "model": { "type": "linear_ener", "weights": "sum", "type_map": ["Ni", "O"], "models": [ {"type": "dpa4", "descriptor": {"...": "..."}, "fitting_net": {"...": "..."}}, {"type": "inner_potential", "mode": "zbl", "r_inner": 0.8, "r_outer": 1.2} ] } ``` ## Changes, by issue task 1. **`inner_potential` is a config-level model type.** Registered in argcheck (`mode`, `r_inner`, `r_outer`), so it can be named as a `linear_ener` child. It is buildable only inside a composition. 2. **The composition derives the descriptor coupling.** The linear builder writes the learned sibling descriptor's `inner_clamp_r_inner`/`_outer` from the `inner_potential` child at build time. The radii are written once; one source of truth. 3. **`pair_exclude_types` belongs to the composition.** The canonical builders do no promotion; the composition-level key governs both children by construction. The legacy promotion semantics of the pt `type: "dpa4"` builder survive only inside the sugar expansion, so the two bridged routes of deepmodeling#5947 can no longer diverge (the ~80 eV builder disagreement is gone: both spellings of the flag now expand identically). Full deletion of the non-bridged promotion stays with deepmodeling#5947's deprecation cycle. 4. **`bridging_method` is sugar with ONE owner.** `deepmd.utils.bridging.expand_bridging_method` expands the flag into the canonical form at every backend's `get_model` entry. The non-composite builders (`get_standard_model`, `get_sezm_model`) fail fast on the flag instead of composing — or, as pt's standard route used to do, silently dropping it. 5. **pt and pt_expt land together.** dpmodel gains a real `linear_ener` config builder (it previously had none); its child-parsing core is shared with pt_expt. pt realizes the canonical form through its existing `SeZMModel` implementation, so pt checkpoints and physics are unchanged. Native-scheme spin combines with the canonical form: a top-level `spin` section on a `linear_ener` config wraps the composition as `NativeSpinEnergyModel` (dpmodel/pt_expt) or routes to the SeZM spin builder (pt). ## Both spellings are supported, by design The concise `type: "dpa4"` + `bridging_method` form is the **recommended user interface** (it is shorter, and existing inputs/checkpoints keep working with no migration). The explicit `linear_ener` + `inner_potential` form is the **canonical internal semantics**: all builders construct only it, so the concise form is pure rewriting and cannot drift. `examples/water/dpa4/input-zbl.json` keeps the concise form; `doc/model/dpa4.md` documents the concise form first and shows the explicit equivalent. ## Tests - `source/tests/common/test_bridging.py` (new): 16 normalizer unit tests — key routing, promotion, mismatch error, spin passthrough, rejections, inactive-flag passthrough. - `source/tests/common/dpmodel/test_zbl_bridging.py`: canonical-vs-sugar tests — identical energy (exact equality) and identical serialized wire dict, plus shape rejections and the standard-builder fail-fast. - `source/tests/pt/model/test_get_model_bridging.py` (new): canonical → `SeZMModel` with identical serialization to the sugar form; rejections (`weights != "sum"`, non-DPA4 sibling, two inner children); plain `linear_ener` unaffected. - `source/tests/pt_expt/model/test_get_model_bridging.py`: updated to the new contract (flag on `type: standard` now composes through `get_model`); canonical composition, canonical native-spin, serialize parity, builder fail-fasts. - Suites run locally and green: dpmodel common (1127 passed), pt_expt `test_zbl_bridging` / `test_get_model_bridging` / `test_get_model_dpa4` / `test_linear_model`, pt sezm model + spin + linear suites, consistency `test_linear_ener`, `test_examples`. ## Known limitations - **pt is a mapping, not a composition.** The pt backend implements bridging inside `SeZMModel`, so its linear builder maps the canonical config back onto `SeZMModel` constructor arguments. Physics and checkpoint format are byte-identical to the flag form (pinned by a serialize-equality test), but pt's internal ownership still differs from dpmodel/pt_expt. - **pt is stricter on split exclusion scopes.** A canonical config whose learned child sets `descriptor.exclude_types` different from the composition's `pair_exclude_types` raises in pt (SeZM mismatch check) while dpmodel/pt_expt accept the two scopes independently (deepmodeling#5947 territory). - The non-bridged `descriptor.exclude_types` promotion in `get_sezm_model` (pt, pt_expt) is untouched; removing it is deepmodeling#5947's staged deprecation. - No end-to-end `dp train` run or GPU `.pt2` export with a canonical config in this PR; construction, argcheck, serialization, and energy parity are unit-tested. Two locally failing AOTI freeze tests are the known pre-existing torch 2.11 CPU-SIMD inductor bug (they pass with `cpp.simdlen = 1`; master fails identically). - Multi-task `shared_dict` combined with an `inner_potential` child is rejected, not supported. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added linear-energy compositions combining learned, descriptor-based, pair-tabulated, and analytical inner-potential models. * Added native-spin support and configurable clamping radii for these compositions. * Added automatic ZBL bridging expansion with validation, shared exclusions, and equivalent shorthand/canonical configurations. * Added support for bridged models in inference, checkpoint freezing, serialization, and model updates. * **Documentation** * Updated DPA4 ZBL bridging guidance, recommended configurations, and compatibility details. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Han Wang <wang_han@iapcm.ac.cn> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 90aec0a commit ed691aa

18 files changed

Lines changed: 2357 additions & 241 deletions

File tree

deepmd/dpmodel/model/dp_linear_model.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
from deepmd.dpmodel.model.make_model import (
2323
make_model,
2424
)
25+
from deepmd.utils.data_system import (
26+
DeepmdDataSystem,
27+
)
2528

2629
DPLinearModel_ = make_model(LinearEnergyAtomicModel, T_Bases=(NativeOP, BaseModel))
2730

@@ -45,3 +48,48 @@ def __init__(
4548
) -> None:
4649
DPModelCommon.__init__(self)
4750
DPLinearModel_.__init__(self, *args, **kwargs)
51+
52+
@classmethod
53+
def update_sel(
54+
cls,
55+
train_data: DeepmdDataSystem,
56+
type_map: list[str] | None,
57+
local_jdata: dict,
58+
) -> tuple[dict, float | None]:
59+
"""Update the selection and perform neighbor statistics.
60+
61+
Updates each learned child in place, skipping analytical
62+
(``inner_potential``) and pair-table children, and aggregates the
63+
minimum neighbor distance (twin of the pt_expt implementation).
64+
65+
Parameters
66+
----------
67+
train_data : DeepmdDataSystem
68+
data used to do neighbor statistics
69+
type_map : list[str], optional
70+
The name of each type of atoms
71+
local_jdata : dict
72+
The local data refer to the current class
73+
74+
Returns
75+
-------
76+
dict
77+
The updated local data
78+
float
79+
The minimum distance between two atoms
80+
"""
81+
local_jdata_cpy = local_jdata.copy()
82+
type_map = local_jdata_cpy["type_map"]
83+
min_nbor_dist = None
84+
for idx, sub_model in enumerate(local_jdata_cpy["models"]):
85+
if sub_model.get("type") == "inner_potential":
86+
# analytical child: no descriptor, no selection to update
87+
continue
88+
if "tab_file" not in sub_model:
89+
sub_model, temp_min = DPModelCommon.update_sel(
90+
train_data, type_map, local_jdata_cpy["models"][idx]
91+
)
92+
local_jdata_cpy["models"][idx] = sub_model
93+
if min_nbor_dist is None or temp_min <= min_nbor_dist:
94+
min_nbor_dist = temp_min
95+
return local_jdata_cpy, min_nbor_dist

deepmd/dpmodel/model/model.py

Lines changed: 67 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@
3131
from deepmd.dpmodel.model.spin_model import (
3232
SpinModel,
3333
)
34+
from deepmd.utils.bridging import (
35+
expand_bridging_method,
36+
)
3437
from deepmd.utils.spin import (
3538
Spin,
3639
normalize_spin_use_spin,
@@ -58,50 +61,67 @@ def get_standard_model(data: dict) -> BaseModel:
5861
data : dict
5962
The data to construct the model.
6063
"""
61-
data = copy.deepcopy(data)
62-
# Analytical bridging (e.g. ZBL): the radii feed the DESCRIPTOR's
63-
# InnerClamp/BridgingSwitch (mirrors pt's builder); the method builds the
64-
# atomic model's InnerPotential below.
6564
bridging_method = str(data.get("bridging_method", "none"))
66-
bridging_enabled = bridging_method.lower() not in ("none", "")
67-
if bridging_enabled:
68-
data["descriptor"]["inner_clamp_r_inner"] = data.get("bridging_r_inner", 0.5)
69-
data["descriptor"]["inner_clamp_r_outer"] = data.get("bridging_r_outer", 0.8)
70-
model = _model_factory.get_standard_model(data)
71-
if not bridging_enabled:
72-
return model
73-
74-
descriptor = model.atomic_model.descriptor
75-
atom_exclude_types = data.get("atom_exclude_types", [])
76-
pair_exclude_types = data.get("pair_exclude_types", [])
77-
# Composition, not a flag (first-principles design): the analytical
78-
# bridging term is its own atomic model, summed with the learned one by the
79-
# existing linear composition machinery.
80-
from deepmd.dpmodel.atomic_model.inner_potential import (
81-
InnerPotentialAtomicModel,
82-
)
83-
from deepmd.dpmodel.atomic_model.linear_atomic_model import (
84-
LinearEnergyAtomicModel,
85-
)
65+
if bridging_method.lower() not in ("none", ""):
66+
raise ValueError(
67+
"`bridging_method` is not supported for a standard model: "
68+
"analytical bridging builds a linear composition, not a "
69+
"standard model. Route the config through `get_model` (which "
70+
"expands the flag), or spell the composition explicitly with "
71+
'`type: "linear_ener"` and an `inner_potential` sub-model.'
72+
)
73+
return _model_factory.get_standard_model(data)
74+
75+
76+
def get_linear_model(data: dict) -> BaseModel:
77+
"""Build a linear energy model from a ``linear_ener`` config.
78+
79+
Children with a ``descriptor`` build as standard learned atomic
80+
models; ``pairtab`` children build as pair-tabulation atomic models;
81+
an ``inner_potential`` child builds the analytical bridging term. The
82+
composition is the ONE owner of the bridging coupling: it derives the
83+
learned sibling descriptor's ``inner_clamp_r_inner``/``_outer`` from
84+
the ``inner_potential`` child's ``r_inner``/``r_outer``, so the radii
85+
are written once in the config (issue #5948, task 2).
86+
87+
A top-level ``spin`` section (scheme ``native``) wraps the composed
88+
atomic model as a :class:`NativeSpinEnergyModel`, with ``use_spin``
89+
injected into every learned child's descriptor.
90+
91+
Parameters
92+
----------
93+
data : dict
94+
The model configuration.
95+
"""
8696
from deepmd.dpmodel.model.dp_linear_model import (
8797
LinearEnergyModel,
8898
)
8999

90-
zbl_atomic = InnerPotentialAtomicModel(
91-
type_map=data["type_map"],
92-
mode=bridging_method,
93-
rcut=descriptor.get_rcut(),
94-
sel=descriptor.get_sel(),
95-
)
96-
composed = LinearEnergyAtomicModel(
97-
models=[model.atomic_model, zbl_atomic],
98-
type_map=data["type_map"],
99-
weights="sum",
100-
# Both exclusions belong to the composition: its children share one
101-
# graph, so "excluded" must cover the analytical term too.
102-
atom_exclude_types=atom_exclude_types,
103-
pair_exclude_types=pair_exclude_types,
104-
)
100+
data = copy.deepcopy(data)
101+
spin = None
102+
if "spin" in data:
103+
spin_cfg = data.pop("spin")
104+
if str(spin_cfg.get("scheme", "deepspin")) != "native":
105+
raise NotImplementedError(
106+
"Spin linear_ener models support only spin scheme 'native'."
107+
)
108+
use_spin = normalize_spin_use_spin(spin_cfg["use_spin"], data["type_map"])
109+
spin = Spin(
110+
use_spin=use_spin,
111+
virtual_scale=spin_cfg.get("virtual_scale", 1.0),
112+
allow_missing_label=spin_cfg.get("allow_missing_label", False),
113+
)
114+
for sub in data["models"]:
115+
if "descriptor" in sub:
116+
sub["descriptor"]["use_spin"] = use_spin
117+
composed = _model_factory.get_linear_atomic_model(data)
118+
if spin is not None:
119+
if not composed.supports_native_spin():
120+
raise NotImplementedError(
121+
"spin scheme 'native' requires an atomic model declaring "
122+
"supports_native_spin()"
123+
)
124+
return NativeSpinEnergyModel(atomic_model_=composed, spin=spin)
105125
return LinearEnergyModel(atomic_model_=composed)
106126

107127

@@ -135,14 +155,10 @@ def get_native_spin_model(data: dict) -> NativeSpinEnergyModel:
135155
eligible; the gate is that capability method, not a descriptor-type
136156
list.
137157
138-
The non-spin backbone is built by :func:`get_standard_model`, which OWNS
139-
everything about assembling the atomic model -- descriptor/fitting,
140-
exclusions and the analytical-bridging composition -- so ``spin`` and
141-
``bridging_method`` combine for free: the wrapper re-classes whatever
142-
atomic model came back, be it a single learned model or a
143-
``LinearEnergyAtomicModel`` over ``[learned, InnerPotential]`` (the
144-
analytical child accepts and ignores ``spin``; the learned child consumes
145-
it).
158+
The non-spin backbone is built by :func:`get_standard_model`. A spin
159+
model with analytical bridging is a ``linear_ener`` composition and
160+
routes through :func:`get_linear_model` instead (the ``bridging_method``
161+
sugar expands to that form in :func:`get_model`).
146162
147163
Parameters
148164
----------
@@ -195,9 +211,13 @@ def get_model(data: dict) -> BaseModel:
195211
data : dict
196212
The data to construct the model.
197213
"""
214+
data = expand_bridging_method(data)
198215
return _model_factory.get_model(
199216
data,
200217
standard_model_factory=get_standard_model,
201218
spin_model_factory=get_spin_model,
202219
native_spin_model_factory=get_native_spin_model,
220+
model_factories={
221+
"linear_ener": get_linear_model,
222+
},
203223
)

0 commit comments

Comments
 (0)