Skip to content

Commit 7cd0d19

Browse files
feat(property): Support element type-wise bias in property fitting (#5322)
### Summary Introduced the `distinguish_types` option for property fitting to support more flexible atom contribution calculations. - `distinguish_types=True`(Default) has consistent behavior with energy fitting(if the property is extensive), calculating individual atom contributions based on each specific atom type. - `distinguish_types=False` treats atom contributions as element-agnostic. This is particularly useful for element extrapolation scenarios where the model needs to generalize across different chemical species. ### Key changes - `deepmd/pt/model/task/property.py`: - Add `distinguish_types` parameter. - Change the serialization version from 5 to 6. - `deepmd/pt/utils/stat.py`: - Refactored `output_std`: Replaced from the constant "ones" padding with actual standard deviation values(required by function `apply_out_stat` in `deepmd/pt/model/atomic_model/property_atomic_model.py`) - Optimized the RMSE logging/printing logic for better clarity. - `deepmd/utils/out_stat.py`: - Updated `compute_stats_from_redu` to include the `intensive` parameter. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a distinguish_types option for property fitting and an intensive mode for statistics computation. * **Bug Fixes** * Output statistics can be applied per atom type when enabled; global standard deviations are now correctly broadcast to per-type shapes. * Normalization and RMSE reporting adjusted to handle intensive vs per-atom modes. * **Tests** * Added/updated tests covering intensive statistics and updated expected statistic values. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 0828604 commit 7cd0d19

File tree

17 files changed

+143
-57
lines changed

17 files changed

+143
-57
lines changed

deepmd/dpmodel/atomic_model/property_atomic_model.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def __init__(
2727

2828
def get_compute_stats_distinguish_types(self) -> bool:
2929
"""Get whether the fitting net computes stats which are not distinguished between different types of atoms."""
30-
return False
30+
return self.fitting_net.get_distinguish_types()
3131

3232
def get_intensive(self) -> bool:
3333
"""Whether the fitting property is intensive."""
@@ -51,6 +51,10 @@ def apply_out_stat(
5151
5252
"""
5353
out_bias, out_std = self._fetch_out_stat(self.bias_keys)
54-
for kk in self.bias_keys:
55-
ret[kk] = ret[kk] * out_std[kk][0] + out_bias[kk][0]
54+
if self.get_compute_stats_distinguish_types():
55+
for kk in self.bias_keys:
56+
ret[kk] = ret[kk] * out_std[kk][atype] + out_bias[kk][atype]
57+
else:
58+
for kk in self.bias_keys:
59+
ret[kk] = ret[kk] * out_std[kk][0] + out_bias[kk][0]
5660
return ret

deepmd/dpmodel/fitting/property_fitting.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ class PropertyFittingNet(InvarFitting):
6565
default_fparam: list[float], optional
6666
The default frame parameter. If set, when `fparam.npy` files are not included in the data system,
6767
this value will be used as the default value for the frame parameter in the fitting net.
68+
distinguish_types : bool
69+
Whether to distinguish atom types when computing output statistics.
6870
"""
6971

7072
def __init__(
@@ -88,11 +90,13 @@ def __init__(
8890
exclude_types: list[int] = [],
8991
type_map: list[str] | None = None,
9092
default_fparam: list | None = None,
93+
distinguish_types: bool = True,
9194
# not used
9295
seed: int | None = None,
9396
) -> None:
9497
self.task_dim = task_dim
9598
self.intensive = intensive
99+
self.distinguish_types = distinguish_types
96100
super().__init__(
97101
var_name=property_name,
98102
ntypes=ntypes,
@@ -131,7 +135,8 @@ def output_def(self) -> FittingOutputDef:
131135
@classmethod
132136
def deserialize(cls, data: dict) -> "PropertyFittingNet":
133137
data = data.copy()
134-
check_version_compatibility(data.pop("@version"), 5, 1)
138+
check_version_compatibility(data.pop("@version"), 6, 1)
139+
data.setdefault("distinguish_types", False)
135140
data.pop("dim_out")
136141
data["property_name"] = data.pop("var_name")
137142
data.pop("tot_ener_zero")
@@ -150,7 +155,12 @@ def serialize(self) -> dict:
150155
"type": "property",
151156
"task_dim": self.task_dim,
152157
"intensive": self.intensive,
158+
"distinguish_types": self.distinguish_types,
153159
}
154-
dd["@version"] = 5
160+
dd["@version"] = 6
155161

156162
return dd
163+
164+
def get_distinguish_types(self) -> bool:
165+
"""Get whether the fitting net computes stats which are distinguished between different types of atoms."""
166+
return self.distinguish_types

deepmd/dpmodel/utils/stat.py

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ def _post_process_stat(
135135
"""Post process the statistics.
136136
137137
For global statistics, we do not have the std for each type of atoms,
138-
thus fake the output std by ones for all the types.
138+
thus broadcast the global std to all the types.
139139
If the shape of out_std is already the same as out_bias,
140140
we do not need to do anything.
141141
"""
@@ -144,7 +144,9 @@ def _post_process_stat(
144144
if vv.shape == out_std[kk].shape:
145145
new_std[kk] = out_std[kk]
146146
else:
147-
new_std[kk] = np.ones_like(vv)
147+
ntypes = vv.shape[0]
148+
reps = [ntypes] + [1] * (vv.ndim - 1)
149+
new_std[kk] = np.tile(out_std[kk], reps)
148150
return out_bias, new_std
149151

150152

@@ -481,6 +483,7 @@ def _compute_output_stats_global(
481483
merged_natoms[kk],
482484
assigned_bias=assigned_atom_ener[kk],
483485
rcond=rcond,
486+
intensive=intensive,
484487
)
485488
else:
486489
# this key does not have global labels, skip it.
@@ -491,26 +494,25 @@ def _compute_output_stats_global(
491494
def rmse(x: np.ndarray) -> float:
492495
return np.sqrt(np.mean(np.square(x)))
493496

494-
if model_pred is None:
495-
unbias_e = {
496-
kk: merged_natoms[kk] @ bias_atom_e[kk].reshape(ntypes, -1)
497-
for kk in bias_atom_e.keys()
498-
}
499-
else:
500-
unbias_e = {
501-
kk: model_pred[kk].reshape(nf[kk], -1)
502-
+ merged_natoms[kk] @ bias_atom_e[kk].reshape(ntypes, -1)
503-
for kk in bias_atom_e.keys()
504-
}
505-
atom_numbs = {kk: merged_natoms[kk].sum(-1) for kk in bias_atom_e.keys()}
497+
unbias_e = {}
498+
for kk in bias_atom_e.keys():
499+
coeffs = merged_natoms[kk]
500+
if intensive:
501+
total_atoms = coeffs.sum(axis=1, keepdims=True)
502+
coeffs = coeffs / total_atoms
503+
recon = coeffs @ bias_atom_e[kk].reshape(ntypes, -1)
504+
if model_pred is not None:
505+
recon += model_pred[kk].reshape(nf[kk], -1)
506+
unbias_e[kk] = recon
506507

507508
for kk in bias_atom_e.keys():
508-
rmse_ae = rmse(
509-
(unbias_e[kk].reshape(nf[kk], -1) - merged_output[kk].reshape(nf[kk], -1))
510-
/ atom_numbs[kk][:, None]
511-
)
509+
diff = unbias_e[kk].reshape(nf[kk], -1) - merged_output[kk].reshape(nf[kk], -1)
510+
if not intensive:
511+
diff /= merged_natoms[kk].sum(axis=-1, keepdims=True)
512+
rmse_ae = rmse(diff)
513+
stat_type = "per atom " if not intensive else ""
512514
log.info(
513-
f"RMSE of {kk} per atom after linear regression is: {rmse_ae} in the unit of {kk}."
515+
f"RMSE of {kk} {stat_type}after linear regression is: {rmse_ae} in the unit of {kk}."
514516
)
515517
return bias_atom_e, std_atom_e
516518

deepmd/pd/utils/stat.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ def _post_process_stat(
144144
"""Post process the statistics.
145145
146146
For global statistics, we do not have the std for each type of atoms,
147-
thus fake the output std by ones for all the types.
147+
thus broadcast the global std to all the types.
148148
If the shape of out_std is already the same as out_bias,
149149
we do not need to do anything.
150150
@@ -154,7 +154,9 @@ def _post_process_stat(
154154
if vv.shape == out_std[kk].shape:
155155
new_std[kk] = out_std[kk]
156156
else:
157-
new_std[kk] = np.ones_like(vv)
157+
ntypes = vv.shape[0]
158+
reps = [ntypes] + [1] * (vv.ndim - 1)
159+
new_std[kk] = np.tile(out_std[kk], reps)
158160
return out_bias, new_std
159161

160162

deepmd/pt/model/atomic_model/property_atomic_model.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def __init__(
2626

2727
def get_compute_stats_distinguish_types(self) -> bool:
2828
"""Get whether the fitting net computes stats which are not distinguished between different types of atoms."""
29-
return False
29+
return self.fitting_net.get_distinguish_types()
3030

3131
def get_intensive(self) -> bool:
3232
"""Whether the fitting property is intensive."""
@@ -49,6 +49,10 @@ def apply_out_stat(
4949
5050
"""
5151
out_bias, out_std = self._fetch_out_stat(self.bias_keys)
52-
for kk in self.bias_keys:
53-
ret[kk] = ret[kk] * out_std[kk][0] + out_bias[kk][0]
52+
if self.get_compute_stats_distinguish_types():
53+
for kk in self.bias_keys:
54+
ret[kk] = ret[kk] * out_std[kk][atype] + out_bias[kk][atype]
55+
else:
56+
for kk in self.bias_keys:
57+
ret[kk] = ret[kk] * out_std[kk][0] + out_bias[kk][0]
5458
return ret

deepmd/pt/model/task/property.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ class PropertyFittingNet(InvarFitting):
7070
different fitting nets for different atom types.
7171
seed : int, optional
7272
Random seed.
73+
distinguish_types : bool
74+
Whether to distinguish atom types when computing output statistics.
7375
"""
7476

7577
def __init__(
@@ -91,10 +93,12 @@ def __init__(
9193
trainable: bool | list[bool] = True,
9294
seed: int | None = None,
9395
default_fparam: list | None = None,
96+
distinguish_types: bool = True,
9497
**kwargs: Any,
9598
) -> None:
9699
self.task_dim = task_dim
97100
self.intensive = intensive
101+
self.distinguish_types = distinguish_types
98102
super().__init__(
99103
var_name=property_name,
100104
ntypes=ntypes,
@@ -133,10 +137,15 @@ def get_intensive(self) -> bool:
133137
"""Whether the fitting property is intensive."""
134138
return self.intensive
135139

140+
def get_distinguish_types(self) -> bool:
141+
"""Get whether to distinguish atom types when computing output statistics."""
142+
return self.distinguish_types
143+
136144
@classmethod
137145
def deserialize(cls, data: dict) -> "PropertyFittingNet":
138146
data = data.copy()
139-
check_version_compatibility(data.pop("@version", 1), 5, 1)
147+
check_version_compatibility(data.pop("@version", 1), 6, 1)
148+
data.setdefault("distinguish_types", False)
140149
data.pop("dim_out")
141150
data["property_name"] = data.pop("var_name")
142151
obj = super().deserialize(data)
@@ -150,8 +159,9 @@ def serialize(self) -> dict:
150159
"type": "property",
151160
"task_dim": self.task_dim,
152161
"intensive": self.intensive,
162+
"distinguish_types": self.distinguish_types,
153163
}
154-
dd["@version"] = 5
164+
dd["@version"] = 6
155165

156166
return dd
157167

deepmd/pt/utils/stat.py

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ def _post_process_stat(
157157
"""Post process the statistics.
158158
159159
For global statistics, we do not have the std for each type of atoms,
160-
thus fake the output std by ones for all the types.
160+
thus broadcast the global std to all the types.
161161
If the shape of out_std is already the same as out_bias,
162162
we do not need to do anything.
163163
@@ -167,7 +167,9 @@ def _post_process_stat(
167167
if vv.shape == out_std[kk].shape:
168168
new_std[kk] = out_std[kk]
169169
else:
170-
new_std[kk] = np.ones_like(vv)
170+
ntypes = vv.shape[0]
171+
reps = [ntypes] + [1] * (vv.ndim - 1)
172+
new_std[kk] = np.tile(out_std[kk], reps)
171173
return out_bias, new_std
172174

173175

@@ -517,6 +519,7 @@ def _compute_output_stats_global(
517519
merged_natoms[kk],
518520
assigned_bias=assigned_atom_ener[kk],
519521
rcond=rcond,
522+
intensive=intensive,
520523
)
521524
else:
522525
# this key does not have global labels, skip it.
@@ -525,29 +528,28 @@ def _compute_output_stats_global(
525528

526529
# unbias_e is only used for print rmse
527530

528-
if model_pred is None:
529-
unbias_e = {
530-
kk: merged_natoms[kk] @ bias_atom_e[kk].reshape(ntypes, -1)
531-
for kk in bias_atom_e.keys()
532-
}
533-
else:
534-
unbias_e = {
535-
kk: model_pred[kk].reshape(nf[kk], -1)
536-
+ merged_natoms[kk] @ bias_atom_e[kk].reshape(ntypes, -1)
537-
for kk in bias_atom_e.keys()
538-
}
539-
atom_numbs = {kk: merged_natoms[kk].sum(-1) for kk in bias_atom_e.keys()}
531+
unbias_e = {}
532+
for kk in bias_atom_e.keys():
533+
coeffs = merged_natoms[kk]
534+
if intensive:
535+
total_atoms = coeffs.sum(axis=1, keepdims=True)
536+
coeffs = coeffs / total_atoms
537+
recon = coeffs @ bias_atom_e[kk].reshape(ntypes, -1)
538+
if model_pred is not None:
539+
recon += model_pred[kk].reshape(nf[kk], -1)
540+
unbias_e[kk] = recon
540541

541542
def rmse(x: np.ndarray) -> float:
542543
return np.sqrt(np.mean(np.square(x)))
543544

544545
for kk in bias_atom_e.keys():
545-
rmse_ae = rmse(
546-
(unbias_e[kk].reshape(nf[kk], -1) - merged_output[kk].reshape(nf[kk], -1))
547-
/ atom_numbs[kk][:, None]
548-
)
546+
diff = unbias_e[kk].reshape(nf[kk], -1) - merged_output[kk].reshape(nf[kk], -1)
547+
if not intensive:
548+
diff /= merged_natoms[kk].sum(axis=-1, keepdims=True)
549+
rmse_ae = rmse(diff)
550+
stat_type = "per atom " if not intensive else ""
549551
log.info(
550-
f"RMSE of {kk} per atom after linear regression is: {rmse_ae} in the unit of {kk}."
552+
f"RMSE of {kk} {stat_type}after linear regression is: {rmse_ae} in the unit of {kk}."
551553
)
552554
return bias_atom_e, std_atom_e
553555

deepmd/utils/argcheck.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1925,6 +1925,9 @@ def fitting_property() -> list[Argument]:
19251925
doc_seed = "Random seed for parameter initialization of the fitting net"
19261926
doc_task_dim = "The dimension of outputs of fitting net"
19271927
doc_intensive = "Whether the fitting property is intensive"
1928+
doc_distinguish_types = (
1929+
"Whether to distinguish atom types when computing output statistics."
1930+
)
19281931
doc_property_name = "The names of fitting property, which should be consistent with the property name in the dataset."
19291932
doc_trainable = "Whether the parameters in the fitting net are trainable. This option can be\n\n\
19301933
- bool: True if all parameters of the fitting net are trainable, False otherwise.\n\n\
@@ -1966,6 +1969,13 @@ def fitting_property() -> list[Argument]:
19661969
Argument("seed", [int, None], optional=True, doc=doc_seed),
19671970
Argument("task_dim", int, optional=True, default=1, doc=doc_task_dim),
19681971
Argument("intensive", bool, optional=True, default=False, doc=doc_intensive),
1972+
Argument(
1973+
"distinguish_types",
1974+
bool,
1975+
optional=True,
1976+
default=True,
1977+
doc=doc_distinguish_types,
1978+
),
19691979
Argument(
19701980
"property_name",
19711981
str,

deepmd/utils/out_stat.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ def compute_stats_from_redu(
1313
natoms: np.ndarray,
1414
assigned_bias: np.ndarray | None = None,
1515
rcond: float | None = None,
16+
intensive: bool = False,
1617
) -> tuple[np.ndarray, np.ndarray]:
1718
"""Compute the output statistics.
1819
@@ -31,6 +32,8 @@ def compute_stats_from_redu(
3132
of the type is not assigned.
3233
rcond
3334
Cut-off ratio for small singular values of a.
35+
intensive
36+
Whether the output is intensive or extensive.
3437
3538
Returns
3639
-------
@@ -44,6 +47,8 @@ def compute_stats_from_redu(
4447
output_redu = np.array(output_redu)
4548
var_shape = list(output_redu.shape[1:])
4649
output_redu = output_redu.reshape(nf, -1)
50+
if intensive:
51+
natoms = natoms / np.sum(natoms, axis=1, keepdims=True)
4752
# check shape
4853
assert output_redu.ndim == 2
4954
assert natoms.ndim == 2

source/tests/common/dpmodel/test_atomic_model_atomic_stat.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ def test_output_stat(self) -> None:
208208
expected_std[0, :, :1] = np.array([0.0, 0.816496]).reshape(
209209
2, 1
210210
) # updating std for foo based on [5.0, 5.0, 5.0], [5.0, 6.0, 7.0]]
211+
expected_std[1, :, :] = np.zeros([2, 2])
211212
np.testing.assert_almost_equal(md0.out_std, expected_std, decimal=4)
212213
# nt x odim
213214
foo_bias = np.array([5.0, 6.0]).reshape(2, 1)

0 commit comments

Comments
 (0)