Skip to content

Commit 6026dbb

Browse files
committed
feat(dpa_adapt): support MFT downstream_task_type='group_property'
Wires MFTFineTuner/MFTConfigManager to build a group_property downstream head, jointly trained with an aux ener branch on a shared descriptor (preventing representation collapse per arXiv:2601.08486), so grouped/ assembly targets (e.g. an OER O*/OH*/OOH* overpotential, or the polymer cloud-point task) can use MFT instead of plain single-task finetuning. - MFTFineTuner.__init__ accepts downstream_task_type='group_property' alongside 'property'/'ener', plus a group_reduce param; property_name/ task_dim validation now applies to both property-like modes. - MFTConfigManager gets its own _build_group_property_fitting_net/_loss, independent of the property builders: GroupPropertyFittingNet is a small standalone MLP, not built on GeneralFitting, so several property-schema fields (resnet_dt, intensive, distinguish_types, numb_aparam) don't exist on it and dargs strict mode rejects them outright rather than ignoring them -- reusing/trimming the property builder would have silently carried one over. - dim_case_embd on the downstream head is read from the aux branch's own (checkpoint-derived) fitting_net_params via a shared _aux_dim_case_embd helper, not hardcoded: it must equal the branch count of whatever multi-task checkpoint the aux branch was itself pretrained as part of (31 for DPA-3.1-3M, but e.g. 23 for the OMol25/Organic_Reactions/ODAC23 checkpoint used for the cloud-point OOD run) -- hardcoding 31 silently mismatches every checkpoint that isn't DPA-3.1-3M. - evaluate()/predict() gain _check_no_multi_frame_groups: dp --pt test never threads group_id/weight/pool_mask through its evaluation path, so a frozen group_property head silently scores every test frame as its own one-frame group. Harmless for single-frame groups, silently wrong for genuine multi-frame assemblies -- refuse instead of returning numbers that look plausible but average over the wrong rows.
1 parent 1830aac commit 6026dbb

4 files changed

Lines changed: 681 additions & 60 deletions

File tree

dpa_adapt/config/manager.py

Lines changed: 122 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,42 @@
88
from dpa_adapt._backend import (
99
resolve_dp_command,
1010
)
11+
from dpa_adapt.mft import (
12+
_PROPERTY_LIKE_DOWNSTREAM_TYPES,
13+
)
14+
15+
16+
def _aux_dim_case_embd(t: Any) -> int:
17+
"""dim_case_embd required on the DOWNSTREAM head to share a descriptor
18+
with the aux branch.
19+
20+
Read from the aux branch's own (ckpt-derived) ``fitting_net_params``
21+
rather than hardcoded: it must equal the branch count of whatever
22+
multi-task checkpoint the aux branch was itself pretrained as part of
23+
(deepmd-kit's multi-task trainer requires every model_dict branch to
24+
declare the same dim_case_embd -- see
25+
deepmd.pt.train.training.get_case_embd_config). That count is 31 for
26+
DPA-3.1-3M but differs for other checkpoints (e.g. 23 for the
27+
OMol25/Organic_Reactions/ODAC23 checkpoint); hardcoding 31 silently
28+
mismatches every checkpoint that isn't DPA-3.1-3M. Falls back to 0 (no
29+
case embedding, matching the aux branch) when the aux fitting_net has
30+
none, e.g. a single-task-pretrained checkpoint.
31+
"""
32+
return (getattr(t, "fitting_net_params", None) or {}).get("dim_case_embd", 0)
33+
1134

1235
# Default property-head architecture for MFT DOWNSTREAM when
1336
# downstream_task_type="property". Mirrors DPATrainer.DEFAULT_FITTING_NET
14-
# (trainer.py L64-70) plus dim_case_embd=31, which the DPA-3.1-3M ckpt
15-
# requires for the case-embedding layer in multi-task mode. (DPATrainer is
16-
# single-task and doesn't need this field; in MFT the descriptor is shared
17-
# across branches so the property head must declare it.)
37+
# (trainer.py L64-70). dim_case_embd is added dynamically by
38+
# _build_property_fitting_net via _aux_dim_case_embd, not baked in here:
39+
# DPATrainer is single-task and doesn't need this field at all, while MFT's
40+
# correct value depends on which checkpoint is being finetuned.
1841
_PROPERTY_FITTING_NET_BASE = {
1942
"type": "property",
2043
"neuron": [240, 240, 240],
2144
"activation_function": "tanh",
2245
"resnet_dt": True,
2346
"precision": "float32",
24-
"dim_case_embd": 31,
2547
}
2648

2749

@@ -40,6 +62,9 @@ def _build_property_fitting_net(t: Any) -> dict:
4062
"seed": t.seed,
4163
}
4264
)
65+
dim_case_embd = _aux_dim_case_embd(t)
66+
if dim_case_embd:
67+
fn["dim_case_embd"] = dim_case_embd
4368
if getattr(t, "fparam_dim", 0) > 0:
4469
fn["numb_fparam"] = t.fparam_dim
4570
return fn
@@ -59,6 +84,57 @@ def _build_property_loss() -> dict:
5984
}
6085

6186

87+
# Default group_property-head architecture for MFT DOWNSTREAM when
88+
# downstream_task_type="group_property". Independent of
89+
# _PROPERTY_FITTING_NET_BASE (not a trimmed copy of it): GroupPropertyFittingNet
90+
# is a small standalone MLP, not built on GeneralFitting, so several
91+
# property-schema fields (resnet_dt, intensive, ...) don't exist on it and
92+
# dargs strict-mode rejects them outright rather than ignoring them -- see
93+
# deepmd.utils.argcheck.fitting_group_property. dim_case_embd is added
94+
# dynamically by _build_group_property_fitting_net via _aux_dim_case_embd,
95+
# not baked in here, for the same reason as the property head.
96+
_GROUP_PROPERTY_FITTING_NET_BASE = {
97+
"type": "group_property",
98+
"neuron": [240, 240, 240],
99+
"activation_function": "gelu",
100+
"precision": "float32",
101+
}
102+
103+
104+
def _build_group_property_fitting_net(t: Any) -> dict:
105+
"""Construct a group_property fitting_net dict from a tuner's params."""
106+
fn = dict(_GROUP_PROPERTY_FITTING_NET_BASE)
107+
fn.update(
108+
{
109+
"property_name": t.property_name,
110+
"task_dim": t.task_dim,
111+
"group_reduce": getattr(t, "group_reduce", "mean"),
112+
"seed": t.seed,
113+
}
114+
)
115+
dim_case_embd = _aux_dim_case_embd(t)
116+
if dim_case_embd:
117+
fn["dim_case_embd"] = dim_case_embd
118+
if getattr(t, "fparam_dim", 0) > 0:
119+
fn["numb_fparam"] = t.fparam_dim
120+
return fn
121+
122+
123+
def _build_group_property_loss() -> dict:
124+
"""group_property-task loss for DOWNSTREAM.
125+
126+
deepmd.utils.argcheck.loss_group_property() reuses loss_property()'s
127+
schema verbatim, so this only differs from _build_property_loss() by
128+
``type``.
129+
"""
130+
return {
131+
"type": "group_property",
132+
"loss_func": "mse",
133+
"metric": ["mae", "rmse"],
134+
"beta": 1.0,
135+
}
136+
137+
62138
_ENER_LOSS = {
63139
"type": "ener",
64140
"start_pref_e": 0.2,
@@ -81,26 +157,36 @@ def build(self) -> dict:
81157
if getattr(t, "fitting_net_params", None)
82158
else {"type": "ener"}
83159
)
84-
# DOWNSTREAM branch: ener (legacy, sensitivity-analysis callers) or
85-
# property (paper-faithful BOOM eval). Default 'ener' for back-compat
86-
# with FakeTuners and existing callers that don't set the attr.
160+
# DOWNSTREAM branch: ener (legacy, sensitivity-analysis callers),
161+
# property (paper-faithful BOOM eval), or group_property (grouped/
162+
# assembly targets, e.g. OER overpotential). Default 'ener' for
163+
# back-compat with FakeTuners and existing callers that don't set
164+
# the attr.
87165
downstream_task_type = getattr(t, "downstream_task_type", "ener")
88-
is_property = downstream_task_type == "property"
89-
# Branch key for the downstream head. Paper qm9_gap/mft uses "property";
90-
# legacy ener mode keeps "DOWNSTREAM" so mp_data sensitivity-analysis
91-
# configs stay byte-for-byte unchanged (renaming would break the branch
92-
# name in their already-trained ckpts).
93-
downstream_key = "property" if is_property else "DOWNSTREAM"
94-
if is_property:
166+
# Both property and group_property get a fresh, RANDOM-initialized
167+
# downstream head sized by property_name/task_dim and follow the
168+
# qm9_gap paper-alignment defaults below; only legacy ener mode
169+
# reuses the aux branch's own fitting_net/finetune_head.
170+
is_random_downstream = downstream_task_type in _PROPERTY_LIKE_DOWNSTREAM_TYPES
171+
# Branch key for the downstream head. Paper qm9_gap/mft uses the task
172+
# type itself ("property" / "group_property"); legacy ener mode keeps
173+
# "DOWNSTREAM" so mp_data sensitivity-analysis configs stay
174+
# byte-for-byte unchanged (renaming would break the branch name in
175+
# their already-trained ckpts).
176+
downstream_key = downstream_task_type if is_random_downstream else "DOWNSTREAM"
177+
if downstream_task_type == "property":
95178
downstream_fitting_net = _build_property_fitting_net(t)
96179
downstream_loss = _build_property_loss()
180+
elif downstream_task_type == "group_property":
181+
downstream_fitting_net = _build_group_property_fitting_net(t)
182+
downstream_loss = _build_group_property_loss()
97183
else:
98184
downstream_fitting_net = aux_fitting_net
99185
downstream_loss = dict(_ENER_LOSS)
100186

101-
# Paper qm9_gap/mft alignment is applied ONLY in property mode. The
102-
# legacy ener path (mp_data sensitivity analysis) stays byte-for-byte
103-
# unchanged.
187+
# Paper qm9_gap/mft alignment is applied to both property and
188+
# group_property downstream modes. The legacy ener path (mp_data
189+
# sensitivity analysis) stays byte-for-byte unchanged.
104190
descriptor = {
105191
"type": "dpa3",
106192
"repflow": {
@@ -130,7 +216,9 @@ def build(self) -> dict:
130216
"optim_update": True,
131217
"use_exp_switch": True,
132218
},
133-
"activation_function": "silut:3.0" if is_property else "custom_silu:3.0",
219+
"activation_function": "silut:3.0"
220+
if is_random_downstream
221+
else "custom_silu:3.0",
134222
"precision": "float32",
135223
"use_tebd_bias": False,
136224
"concat_output_tebd": False,
@@ -139,15 +227,16 @@ def build(self) -> dict:
139227
"trainable": True,
140228
"use_econf_tebd": False,
141229
}
142-
if is_property:
230+
if is_random_downstream:
143231
descriptor["repflow"]["fix_stat_std"] = 0.3
144232

145-
# MFT branch heads. In property mode the paper pins finetune_head:
146-
# the aux head loads from its named branch, the downstream property
147-
# head is RANDOM-initialized (paper Eq 12). Legacy ener mode keeps the
148-
# original layout (no finetune_head on aux; downstream = aux branch),
149-
# including key order, so the emitted JSON is byte-for-byte unchanged.
150-
if is_property:
233+
# MFT branch heads. In property/group_property mode the paper pins
234+
# finetune_head: the aux head loads from its named branch, the
235+
# downstream head is RANDOM-initialized (paper Eq 12). Legacy ener
236+
# mode keeps the original layout (no finetune_head on aux; downstream
237+
# = aux branch), including key order, so the emitted JSON is
238+
# byte-for-byte unchanged.
239+
if is_random_downstream:
151240
aux_head = {
152241
"type_map": "type_map",
153242
"descriptor": "dpa3_descriptor",
@@ -176,22 +265,23 @@ def build(self) -> dict:
176265
decay_steps = (
177266
t.decay_steps
178267
if getattr(t, "decay_steps", None) is not None
179-
else (1000 if is_property else 5000)
268+
else (1000 if is_random_downstream else 5000)
180269
)
181270
# Per-branch batch sizes: explicit override wins, then paper defaults
182-
# for property mode, then the single batch_size for legacy ener mode.
271+
# for property/group_property mode, then the single batch_size for
272+
# legacy ener mode.
183273
aux_batch = getattr(t, "aux_batch_size", None) or (
184-
"auto:128" if is_property else t.batch_size
274+
"auto:128" if is_random_downstream else t.batch_size
185275
)
186276
downstream_batch = getattr(t, "downstream_batch_size", None) or (
187-
"auto:512" if is_property else t.batch_size
277+
"auto:512" if is_random_downstream else t.batch_size
188278
)
189279
# Paper default 0.5/0.5; aux_prob (default 0.5) controls the split, the
190280
# downstream share is the complement. Legacy keeps downstream at 1.0.
191281
aux_prob = float(t.aux_prob)
192282
if not 0.0 <= aux_prob <= 1.0:
193283
raise ValueError(f"aux_prob must be in [0, 1]; got {t.aux_prob!r}.")
194-
downstream_prob = (1.0 - aux_prob) if is_property else 1.0
284+
downstream_prob = (1.0 - aux_prob) if is_random_downstream else 1.0
195285

196286
aux_systems = t.aux_data if isinstance(t.aux_data, list) else [t.aux_data]
197287
train_systems = (
@@ -233,7 +323,7 @@ def build(self) -> dict:
233323
"batch_size": downstream_batch,
234324
}
235325

236-
if is_property:
326+
if is_random_downstream:
237327
# Paper qm9_gap: gradient clipping at 5.0.
238328
training["gradient_max_norm"] = 5.0
239329

0 commit comments

Comments
 (0)