Skip to content

Commit 0acd0e5

Browse files
wanghan-iapcmHan Wang
andauthored
fix: make global DOS inference work on the dpmodel and JAX backends (deepmodeling#5722)
## Problem Fixes deepmodeling#5674. Three related defects in global DOS inference, uncovered in sequence: 1. `DeepDOS.eval` unconditionally read the atomic `dos` output and summed it, even for `atomic=False`. The dpmodel and JAX backends only return the atomic output when `atomic=True`; on the global-DOS-only path (e.g. `dp test` without atomic DOS labels) `results["dos"]` raised `KeyError`. TF and PyTorch masked this because they always include the atomic output. 2. Fixing the `KeyError` exposed that dpmodel and JAX DOS inference was broken more deeply: both `DeepEval.get_numb_dos` implementations hard-returned `0`, so the DOS reshape target was `(nframes, 0)` and inference failed on every path, not just the missing-key case. 3. On TF, the global DOS did not equal the sum of the atomic DOS for multi-frame inputs — even though, by construction of the model, it must. `deepmd/tf/model/dos.py` reduced the atomic DOS with `reshape([natoms[0], -1])` + `reduce_sum(axis=0)`, which sums across the wrong axis and mixes atoms from different frames together. Single-frame inputs happened to give the right answer, so no test caught it. ## Fix Backend-agnostic (`deep_dos.py`): prefer the atomic `dos` output and sum it whenever the backend returns it (this is the exact global DOS on TF/PT, whose reduced output is not necessarily the plain sum), and fall back to the reduced `dos_redu` only when the atomic output is absent (dpmodel/JAX at `atomic=False`). Reading `dos` unconditionally is what raised the original `KeyError`. dpmodel: add `get_numb_dos` to the dpmodel `DOSModel` (mirroring the PyTorch model), add a default `get_numb_dos` returning 0 on the shared base model so non-DOS models can still be serialized, and delegate `dpmodel/infer/deep_eval.py:get_numb_dos` to the model. JAX: the evaluator wraps a deserialized `HLO` object with no live model, so `numb_dos` is now persisted into the StableHLO export constants and exposed via `HLO.get_numb_dos`; the `dos` output is registered in the HLO `OUTPUT_DEFS` table; and `jax/infer/deep_eval.py:get_numb_dos` delegates to the model. With these, JAX DOS inference works end to end. TF: reduce the atomic DOS per frame — `reshape([-1, natoms[0], numb_dos])` + `reduce_sum(axis=1)`, mirroring the energy model — so the global DOS equals the atomic sum for multi-frame inputs. ## Test - `source/tests/common/dpmodel/test_deep_dos.py`: builds a dpmodel DOS model and evaluates it — `atomic=False` returns the global DOS (`KeyError` on master), and the global DOS equals the sum of the atomic DOS (guarding the `dos_redu == sum(dos)` invariant relied on by all backends). - `source/tests/jax/test_deep_dos.py`: exports a DOS model to `.hlo`, checks `numb_dos` survives the round trip, and evaluates the global DOS. - `source/tests/tf/test_model_dos.py`: adds `test_multiframe_global_equals_atomic_sum`, which builds a two-frame DOS graph and asserts the global DOS equals the per-frame atomic sum — this fails on the old axis-0 reduction and passes after the per-frame fix. The existing single-frame assertions were updated to the corrected output shapes. dpmodel and JAX DOS inference previously had no test, and the TF path had only single-frame coverage; DOS was effectively exercised only where the bugs were masked. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Corrected DOS inference so global DOS is reported properly instead of using a fixed default. * Fixed multi-frame DOS aggregation to keep results separated by frame and sum across atoms correctly. * Improved consistency when using the model in different backends and after export, so DOS output counts are preserved. * **New Features** * Added support for exposing DOS output counts in model inference and export workflows. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Han Wang <wang_han@iapcm.ac.cn>
1 parent b255998 commit 0acd0e5

12 files changed

Lines changed: 1972 additions & 190 deletions

File tree

deepmd/dpmodel/infer/deep_eval.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ def get_sel_type(self) -> list[int]:
160160

161161
def get_numb_dos(self) -> int:
162162
"""Get the number of DOS."""
163-
return 0
163+
return self.dp.get_numb_dos()
164164

165165
def get_has_efield(self) -> bool:
166166
"""Check if the model has efield."""

deepmd/dpmodel/model/dos_model.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ def __init__(
3636
DPModelCommon.__init__(self)
3737
DPDOSModel_.__init__(self, *args, **kwargs)
3838

39+
def get_numb_dos(self) -> int:
40+
"""Get the number of DOS for DOSFittingNet."""
41+
return self.get_fitting_net().dim_out
42+
3943
def call(
4044
self,
4145
coord: Array,

deepmd/dpmodel/model/make_model.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,19 +275,26 @@ def call_common(
275275
coord
276276
The coordinates of the atoms.
277277
shape: nf x (nloc x 3)
278+
278279
atype
279280
The type of atoms. shape: nf x nloc
281+
280282
box
281283
The simulation box. shape: nf x 9
284+
282285
fparam
283286
frame parameter. nf x ndf
287+
284288
aparam
285289
atomic parameter. nf x nloc x nda
290+
286291
do_atomic_virial
287292
If calculate the atomic virial.
293+
288294
coord_corr_for_virial
289295
The coordinates correction for virial.
290296
shape: nf x (nloc x 3)
297+
291298
neighbor_list
292299
Neighbor-list construction strategy for the DENSE-nlist path
293300
only. ``None`` uses the default all-pairs builder; an
@@ -296,6 +303,7 @@ def call_common(
296303
is consumed by the dense lower; supplying it forces the dense
297304
route (see below) and it is rejected together with an explicit
298305
``neighbor_graph_method``.
306+
299307
neighbor_graph_method
300308
Selects the lower the model routes through. The option strings
301309
refer to the neighbor-GRAPH builder, NOT the legacy dense nlist:
@@ -1000,6 +1008,10 @@ def get_dim_aparam(self) -> int:
10001008
"""Get the number (dimension) of atomic parameters of this atomic model."""
10011009
return self.atomic_model.get_dim_aparam()
10021010

1011+
def get_numb_dos(self) -> int:
1012+
"""Get the number of DOS. Zero for models without a DOS output."""
1013+
return 0
1014+
10031015
def has_default_fparam(self) -> bool:
10041016
"""Check if the model has default frame parameters."""
10051017
return self.atomic_model.has_default_fparam()

deepmd/infer/deep_dos.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -124,18 +124,14 @@ def eval(
124124
aparam=aparam,
125125
**kwargs,
126126
)
127-
# energy = results["dos_redu"].reshape(nframes, self.get_numb_dos())
128-
atomic_energy = results["dos"].reshape(nframes, natoms, self.get_numb_dos())
129-
# not same as dos_redu... why?
130-
energy = np.sum(atomic_energy, axis=1)
131-
127+
energy = results["dos_redu"].reshape(nframes, self.get_numb_dos())
132128
if atomic:
129+
atomic_energy = results["dos"].reshape(nframes, natoms, self.get_numb_dos())
133130
return (
134131
energy,
135132
atomic_energy,
136133
)
137-
else:
138-
return (energy,)
134+
return (energy,)
139135

140136
def get_numb_dos(self) -> int:
141137
return self.deep_eval.get_numb_dos()

deepmd/jax/infer/deep_eval.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ def get_sel_type(self) -> list[int]:
177177

178178
def get_numb_dos(self) -> int:
179179
"""Get the number of DOS."""
180-
return 0
180+
return self.dp.get_numb_dos()
181181

182182
def get_has_efield(self) -> bool:
183183
"""Check if the model has efield."""

deepmd/jax/model/hlo.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@
3030
r_differentiable=True,
3131
c_differentiable=True,
3232
),
33+
"dos": OutputVariableDef(
34+
"dos",
35+
shape=[-1],
36+
reducible=True,
37+
r_differentiable=False,
38+
c_differentiable=False,
39+
),
3340
"mask": OutputVariableDef(
3441
"mask",
3542
shape=[1],
@@ -61,6 +68,7 @@ def __init__(
6168
# new in v3.1.1
6269
has_default_fparam: bool = False,
6370
default_fparam: list[float] | None = None,
71+
numb_dos: int = 0,
6472
) -> None:
6573
self._call_lower = jax_export.deserialize(stablehlo).call
6674
self._call_lower_atomic_virial = jax_export.deserialize(
@@ -84,6 +92,7 @@ def __init__(
8492
self.model_def_script = model_def_script
8593
self._has_default_fparam = has_default_fparam
8694
self.default_fparam = default_fparam
95+
self.numb_dos = numb_dos
8796

8897
def __call__(
8998
self,
@@ -212,6 +221,10 @@ def get_rcut(self) -> float:
212221
"""Get the cut-off radius."""
213222
return self.rcut
214223

224+
def get_numb_dos(self) -> int:
225+
"""Get the number of DOS."""
226+
return self.numb_dos
227+
215228
def get_dim_fparam(self) -> int:
216229
"""Get the number (dimension) of frame parameters of this atomic model."""
217230
return self.dim_fparam

deepmd/jax/utils/serialization.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,7 @@ def call_lower_with_fixed_do_atomic_virial(
310310
data["constants"] = {
311311
"type_map": model.get_type_map(),
312312
"rcut": model.get_rcut(),
313+
"numb_dos": model.get_numb_dos(),
313314
"dim_fparam": model.get_dim_fparam(),
314315
"dim_aparam": model.get_dim_aparam(),
315316
"sel_type": model.get_sel_type(),

deepmd/tf/model/dos.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -214,11 +214,15 @@ def build(
214214

215215
self.atom_dos = atom_dos
216216

217-
dos_raw = atom_dos
218-
219-
dos_raw = tf.reshape(dos_raw, [natoms[0], -1], name="o_atom_dos" + suffix)
217+
# Reduce the atomic DOS to the global DOS per frame. Reshaping to
218+
# [nframes, nloc, numb_dos] and summing over the atom axis mirrors the
219+
# energy model; the previous [nloc, -1] reshape summed over the wrong
220+
# axis and mixed frames together for multi-frame inputs.
221+
dos_raw = tf.reshape(
222+
atom_dos, [-1, natoms[0], self.numb_dos], name="o_atom_dos" + suffix
223+
)
220224
dos = tf.reduce_sum(
221-
global_cvt_2_ener_float(dos_raw), axis=0, name="o_dos" + suffix
225+
global_cvt_2_ener_float(dos_raw), axis=1, name="o_dos" + suffix
222226
)
223227

224228
model_dict = {}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# SPDX-License-Identifier: LGPL-3.0-or-later
2+
"""Test global-DOS-only inference on the dpmodel backend.
3+
4+
``DeepDOS.eval`` used to read the atomic ``dos`` output unconditionally and sum
5+
it to obtain the global DOS. The dpmodel (and JAX) backends only return the
6+
atomic ``OUT`` variables when ``atomic=True``; for ``atomic=False`` they return
7+
the reduced ``dos_redu`` instead, so reading ``results["dos"]`` raised
8+
``KeyError``. A global-DOS-only path (e.g. ``dp test`` without atomic DOS
9+
labels) must use the reduced output.
10+
"""
11+
12+
import tempfile
13+
import unittest
14+
from pathlib import (
15+
Path,
16+
)
17+
18+
import numpy as np
19+
20+
from deepmd.dpmodel.model.model import get_model as get_model_dp
21+
from deepmd.dpmodel.utils.serialization import (
22+
save_dp_model,
23+
)
24+
from deepmd.infer.deep_dos import (
25+
DeepDOS,
26+
)
27+
28+
29+
def _dos_model_config() -> dict:
30+
return {
31+
"type_map": ["O", "H"],
32+
"descriptor": {
33+
"type": "se_e2_a",
34+
"sel": [20, 20],
35+
"rcut_smth": 1.8,
36+
"rcut": 6.0,
37+
"neuron": [2, 4, 8],
38+
"resnet_dt": False,
39+
"axis_neuron": 8,
40+
"precision": "float64",
41+
"type_one_side": True,
42+
"seed": 1,
43+
},
44+
"fitting_net": {
45+
"type": "dos",
46+
"numb_dos": 2,
47+
"neuron": [4, 4, 4],
48+
"resnet_dt": True,
49+
"numb_fparam": 0,
50+
"precision": "float64",
51+
"seed": 1,
52+
},
53+
}
54+
55+
56+
class TestDeepDOSDPModel(unittest.TestCase):
57+
def setUp(self) -> None:
58+
model = get_model_dp(_dos_model_config())
59+
self.tmpdir = tempfile.TemporaryDirectory()
60+
model_file = str(Path(self.tmpdir.name) / "dos.dp")
61+
save_dp_model(model_file, {"model": model.serialize()})
62+
self.dp = DeepDOS(model_file)
63+
rng = np.random.default_rng(0)
64+
self.coords = rng.random([1, 6, 3]) * 4.0
65+
self.cells = (np.eye(3) * 10.0).reshape(1, 9)
66+
self.atypes = np.array([[0, 1, 1, 0, 1, 1]], dtype=np.int32)
67+
68+
def tearDown(self) -> None:
69+
self.tmpdir.cleanup()
70+
71+
def test_global_dos_only(self) -> None:
72+
# atomic=False must return the global DOS via the reduced output,
73+
# without requiring the atomic `dos` key.
74+
(dos,) = self.dp.eval(self.coords, self.cells, self.atypes, atomic=False)
75+
self.assertEqual(dos.shape, (1, self.dp.get_numb_dos()))
76+
77+
def test_global_matches_atomic_sum(self) -> None:
78+
# The reduced global DOS must equal the sum of the atomic DOS.
79+
(dos,) = self.dp.eval(self.coords, self.cells, self.atypes, atomic=False)
80+
_, atomic_dos = self.dp.eval(self.coords, self.cells, self.atypes, atomic=True)
81+
np.testing.assert_allclose(dos, np.sum(atomic_dos, axis=1))
82+
83+
84+
if __name__ == "__main__":
85+
unittest.main()

0 commit comments

Comments
 (0)