Skip to content

Commit 9ad31f7

Browse files
committed
feat(pt): add charge density prediction support
Add a grid-based charge density prediction task for the PyTorch backend: - add DensityFittingNet, DPDensityAtomicModel and GridDensityModel (fitting type "density", model type "grid_density") - add GridDensityLoss ("grid_density") for grid density training - support loading grid.npy/density.npy in the data system - support DeepEval/DeepPot inference with grid= input, returning density - support dp test for density models (DeepDensity and DensityTester) - add QM9 charge density training example under examples/density/
1 parent 8cfd46e commit 9ad31f7

35 files changed

Lines changed: 2439 additions & 1 deletion

deepmd/infer/deep_density.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# SPDX-License-Identifier: LGPL-3.0-or-later
2+
from typing import (
3+
Any,
4+
)
5+
6+
import numpy as np
7+
8+
from deepmd.dpmodel.output_def import (
9+
FittingOutputDef,
10+
ModelOutputDef,
11+
OutputVariableDef,
12+
)
13+
14+
from .deep_eval import (
15+
DeepEval,
16+
)
17+
18+
19+
class DeepDensity(DeepEval):
20+
"""Charge density evaluated on grid points.
21+
22+
Parameters
23+
----------
24+
model_file : Path
25+
The name of the frozen model file.
26+
*args : list
27+
Positional arguments.
28+
auto_batch_size : bool or int or AutoBatchSize, default: True
29+
If True, automatic batch size will be used. If int, it will be used
30+
as the initial batch size.
31+
neighbor_list : ase.neighborlist.NewPrimitiveNeighborList, optional
32+
The ASE neighbor list class to produce the neighbor list. If None, the
33+
neighbor list will be built natively in the model.
34+
**kwargs : dict
35+
Keyword arguments.
36+
"""
37+
38+
@property
39+
def output_def(self) -> ModelOutputDef:
40+
"""Get the output definition of this model.
41+
42+
The density is predicted on grid points rather than on atoms, but it
43+
is declared with the same per-site output definition as the fitting
44+
net of the model.
45+
"""
46+
return ModelOutputDef(
47+
FittingOutputDef(
48+
[
49+
OutputVariableDef(
50+
"density",
51+
[1],
52+
reducible=True,
53+
r_differentiable=True,
54+
c_differentiable=True,
55+
),
56+
]
57+
)
58+
)
59+
60+
def eval(
61+
self,
62+
coords: np.ndarray,
63+
cells: np.ndarray | None,
64+
atom_types: list[int] | np.ndarray,
65+
grid: np.ndarray,
66+
fparam: np.ndarray | None = None,
67+
aparam: np.ndarray | None = None,
68+
mixed_type: bool = False,
69+
**kwargs: dict[str, Any],
70+
) -> np.ndarray:
71+
"""Evaluate the density on grid points.
72+
73+
Parameters
74+
----------
75+
coords : np.ndarray
76+
The coordinates of the atoms, in shape (nframes, natoms, 3).
77+
cells : np.ndarray
78+
The cell vectors of the system, in shape (nframes, 9). If the system
79+
is not periodic, set it to None.
80+
atom_types : list[int] or np.ndarray
81+
The types of the atoms. If mixed_type is False, the shape is (natoms,);
82+
otherwise, the shape is (nframes, natoms).
83+
grid : np.ndarray
84+
The coordinates of the grid points, in shape (nframes, ngrid, 3).
85+
fparam : np.ndarray, optional
86+
The frame parameters, by default None.
87+
aparam : np.ndarray, optional
88+
The atomic parameters, by default None.
89+
mixed_type : bool, optional
90+
Whether the atom_types is mixed type, by default False.
91+
**kwargs : dict[str, Any]
92+
Keyword arguments.
93+
94+
Returns
95+
-------
96+
density
97+
The density on the grid points, in shape (nframes, ngrid).
98+
"""
99+
(
100+
coords,
101+
cells,
102+
atom_types,
103+
fparam,
104+
aparam,
105+
nframes,
106+
natoms,
107+
) = self._standard_input(coords, cells, atom_types, fparam, aparam, mixed_type)
108+
results = self.deep_eval.eval(
109+
coords,
110+
cells,
111+
atom_types,
112+
False,
113+
fparam=fparam,
114+
aparam=aparam,
115+
grid=np.array(grid),
116+
**kwargs,
117+
)
118+
return results["density"].reshape(nframes, -1)
119+
120+
121+
__all__ = ["DeepDensity"]

deepmd/infer/deep_pot.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,11 @@ def eval(
212212
aparam=aparam,
213213
**kwargs,
214214
)
215+
# TODO: if the grid is requested, we can directly return it without reshaping to energy, force and virial. We can also consider to return the grid in a separate key in the results dict, instead of reshaping it to energy, force and virial.
216+
if "grid" in kwargs:
217+
result = results["density"].reshape(nframes, -1)
218+
return result
219+
215220
energy = results["energy_redu"].reshape(nframes, 1)
216221
force = results["energy_derv_r"].reshape(nframes, natoms, 3)
217222
virial = results["energy_derv_c_redu"].reshape(nframes, 9)

deepmd/infer/model_test/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
Any,
1515
)
1616

17+
from deepmd.infer.deep_density import (
18+
DeepDensity,
19+
)
1720
from deepmd.infer.deep_dipole import (
1821
DeepDipole,
1922
)
@@ -36,6 +39,9 @@
3639
save_txt_file,
3740
test_chunk_atoms,
3841
)
42+
from deepmd.infer.model_test.density import (
43+
DensityTester,
44+
)
3945
from deepmd.infer.model_test.dos import (
4046
DosTester,
4147
)
@@ -56,6 +62,7 @@
5662

5763
__all__ = [
5864
"ChunkContext",
65+
"DensityTester",
5966
"DipoleTester",
6067
"DosTester",
6168
"EnerTester",
@@ -96,6 +103,8 @@ def build_tester(dp: Any, *, atomic: bool) -> ModelTester:
96103
return tester(dp, atomic=atomic)
97104
if isinstance(dp, DeepDOS):
98105
return DosTester(dp, atomic=atomic)
106+
if isinstance(dp, DeepDensity):
107+
return DensityTester(dp, atomic=atomic)
99108
if isinstance(dp, DeepProperty):
100109
return PropertyTester(dp, atomic=atomic)
101110
if isinstance(dp, DeepGlobalPolar):

deepmd/infer/model_test/density.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# SPDX-License-Identifier: LGPL-3.0-or-later
2+
"""Testing of models predicting charge density on grid points."""
3+
4+
from deepmd.infer.model_test.base import (
5+
ChunkContext,
6+
ModelTester,
7+
_write_per_frame_details,
8+
)
9+
from deepmd.utils.data import (
10+
DeepmdData,
11+
)
12+
from deepmd.utils.eval_metrics import (
13+
mae,
14+
rmse,
15+
)
16+
17+
__all__ = ["DensityTester"]
18+
19+
20+
class DensityTester(ModelTester):
21+
"""Test a model of charge density on grid points."""
22+
23+
report = (
24+
("mae_density", "DENSITY MAE : {} units"),
25+
("rmse_density", "DENSITY RMSE : {} units"),
26+
)
27+
28+
def add_data_requirements(self, data: DeepmdData) -> None:
29+
"""Declare the labels a density test consumes."""
30+
dp = self.dp
31+
# The grid and the density are defined on grid points rather than on
32+
# atoms, and their extent (ngrid) is not known until the data is
33+
# loaded. They are declared "atomic" so the loader keeps the
34+
# frame-major layout without reshaping to natoms; see the grid/density
35+
# early return in DeepmdData._load_data.
36+
data.add("grid", 3, atomic=True, must=True, high_prec=True)
37+
data.add("density", 1, atomic=True, must=True, high_prec=True)
38+
if dp.get_dim_fparam() > 0:
39+
data.add(
40+
"fparam", dp.get_dim_fparam(), atomic=False, must=True, high_prec=False
41+
)
42+
if dp.get_dim_aparam() > 0:
43+
data.add(
44+
"aparam", dp.get_dim_aparam(), atomic=True, must=True, high_prec=False
45+
)
46+
47+
def evaluate_chunk(
48+
self,
49+
data: DeepmdData,
50+
test_data: dict,
51+
context: ChunkContext,
52+
) -> dict[str, tuple[float, float]]:
53+
"""Evaluate one chunk of a density test."""
54+
dp = self.dp
55+
mixed_type = data.mixed_type
56+
nframes = test_data["box"].shape[0]
57+
58+
coord = test_data["coord"].reshape([nframes, -1])
59+
box = test_data["box"] if data.pbc else None
60+
if mixed_type:
61+
atype = test_data["type"].reshape([nframes, -1])
62+
else:
63+
atype = test_data["type"][0]
64+
fparam = test_data["fparam"] if dp.get_dim_fparam() > 0 else None
65+
aparam = test_data["aparam"] if dp.get_dim_aparam() > 0 else None
66+
grid = test_data["grid"]
67+
68+
prediction = dp.eval(
69+
coord,
70+
box,
71+
atype,
72+
grid,
73+
fparam=fparam,
74+
aparam=aparam,
75+
mixed_type=mixed_type,
76+
).reshape(nframes, -1)
77+
label = test_data["density"].reshape(nframes, -1)
78+
79+
diff = prediction - label
80+
errors: dict[str, tuple[float, float]] = {
81+
"mae_density": (mae(diff), diff.size),
82+
"rmse_density": (rmse(diff), diff.size),
83+
}
84+
85+
if context.detail_path is not None:
86+
_write_per_frame_details(
87+
context,
88+
suffix="density",
89+
reference=label,
90+
prediction=prediction,
91+
)
92+
93+
return errors

deepmd/pt/infer/deep_eval.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020
OutputVariableCategory,
2121
OutputVariableDef,
2222
)
23+
from deepmd.infer.deep_density import (
24+
DeepDensity,
25+
)
2326
from deepmd.infer.deep_dipole import (
2427
DeepDipole,
2528
)
@@ -440,6 +443,8 @@ def model_type(self) -> type["DeepEvalWrapper"]:
440443
return DeepWFC
441444
elif "population" in model_output_type:
442445
return DeepPopulation
446+
elif "density" in model_output_type:
447+
return DeepDensity
443448
elif self.get_var_name() in model_output_type:
444449
return DeepProperty
445450
else:
@@ -552,6 +557,17 @@ def eval(
552557
coords, atom_types, len(atom_types.shape) > 1
553558
)
554559
request_defs = self._get_request_defs(atomic)
560+
if "grid" in kwargs and kwargs["grid"] is not None:
561+
out = self._eval_func(self._eval_model_density, numb_test, natoms)(
562+
coords,
563+
cells,
564+
atom_types,
565+
np.array(kwargs["grid"]),
566+
fparam,
567+
aparam,
568+
request_defs,
569+
)
570+
return {"density": out}
555571
if "spin" not in kwargs or kwargs["spin"] is None:
556572
out = self._eval_func(self._eval_model, numb_test, natoms)(
557573
coords, cells, atom_types, fparam, aparam, request_defs, charge_spin
@@ -916,6 +932,80 @@ def _eval_model_spin(
916932
) # this is kinda hacky
917933
return tuple(results)
918934

935+
def _eval_model_density(
936+
self,
937+
coords: np.ndarray,
938+
cells: np.ndarray | None,
939+
atom_types: np.ndarray,
940+
grid: np.ndarray,
941+
fparam: np.ndarray | None,
942+
aparam: np.ndarray | None,
943+
request_defs: list[OutputVariableDef],
944+
) -> tuple[np.ndarray, ...]:
945+
model = self.dp.to(DEVICE)
946+
947+
nframes = coords.shape[0]
948+
if len(atom_types.shape) == 1:
949+
natoms = len(atom_types)
950+
atom_types = np.tile(atom_types, nframes).reshape(nframes, -1)
951+
else:
952+
natoms = len(atom_types[0])
953+
954+
coord_input = torch.tensor(
955+
coords.reshape([nframes, natoms, 3]),
956+
dtype=GLOBAL_PT_FLOAT_PRECISION,
957+
device=DEVICE,
958+
)
959+
type_input = torch.tensor(atom_types, dtype=torch.long, device=DEVICE)
960+
grid_input = torch.tensor(
961+
grid.reshape([nframes, -1, 3]),
962+
dtype=GLOBAL_PT_FLOAT_PRECISION,
963+
device=DEVICE,
964+
)
965+
ngrid = grid_input.shape[1]
966+
if cells is not None:
967+
box_input = torch.tensor(
968+
cells.reshape([nframes, 3, 3]),
969+
dtype=GLOBAL_PT_FLOAT_PRECISION,
970+
device=DEVICE,
971+
)
972+
else:
973+
box_input = None
974+
if fparam is not None:
975+
fparam_input = to_torch_tensor(
976+
fparam.reshape(nframes, self.get_dim_fparam())
977+
)
978+
else:
979+
fparam_input = None
980+
if aparam is not None:
981+
aparam_input = to_torch_tensor(
982+
aparam.reshape(nframes, natoms, self.get_dim_aparam())
983+
)
984+
else:
985+
aparam_input = None
986+
987+
do_atomic_virial = any(
988+
x.category == OutputVariableCategory.DERV_C_REDU for x in request_defs
989+
)
990+
batch_output = model(
991+
coord_input,
992+
type_input,
993+
grid=grid_input,
994+
box=box_input,
995+
do_atomic_virial=do_atomic_virial,
996+
fparam=fparam_input,
997+
aparam=aparam_input,
998+
)
999+
if isinstance(batch_output, tuple):
1000+
batch_output = batch_output[0]
1001+
1002+
results = []
1003+
pt_name = "density"
1004+
density_shape = [nframes, ngrid]
1005+
out = batch_output[pt_name].reshape(density_shape).detach().cpu().numpy()
1006+
results.append(out)
1007+
return tuple(results)
1008+
9191009
def _get_output_shape(
9201010
self, odef: OutputVariableDef, nframes: int, natoms: int
9211011
) -> list[int]:

deepmd/pt/loss/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
# SPDX-License-Identifier: LGPL-3.0-or-later
2+
from .charge import (
3+
GridDensityLoss,
4+
)
25
from .denoise import (
36
DenoiseLoss,
47
)
@@ -35,6 +38,7 @@
3538
"EnergyHessianStdLoss",
3639
"EnergySpinLoss",
3740
"EnergyStdLoss",
41+
"GridDensityLoss",
3842
"PopulationLoss",
3943
"PropertyLoss",
4044
"TaskLoss",

0 commit comments

Comments
 (0)