88from 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