From 911ddbbcd67f5b4823969c17fefbc96f78b9b71c Mon Sep 17 00:00:00 2001 From: OutisLi Date: Mon, 5 Oct 2026 23:01:42 +0800 Subject: [PATCH 1/4] feat(finetune): initialize native spin from spin-free models Allow --finetune to initialize native-spin DPA4/SeZM models on the PyTorch and pt-expt backends, and native-spin DPA4C on pt-expt, directly from spin-free checkpoints without a manually converted checkpoint. Construct the target from its fine-tuning input and transfer learned weights after branch selection and type-map alignment. Preserve the target spin scheme, magnetic element mask, and analytical bridging. A same-name multi-task branch undergoing spin-free to native-spin conversion must update its output statistics instead of resuming unchanged. Keep the conversion rules in a shared tensor-transfer module: - Dispatch descriptor-specific expansion and fitting input projection selection by their concrete network classes. - Copy DPA4 geometric weights while retaining fresh zero-output spin routes and the target's configuration-derived magnetic masks. - Insert DPA4C spin features between the geometric readout and trailing neighborhood masses/type embeddings in descriptor statistics and fitting input weights, including trailing frame and atomic parameters. - Extend the pair-FiLM output columns while retaining initialized spin heads and anchors. Keep the spin gate closed and the added fitting rows initialized so the geometric function is preserved with a trainable gradient into the spin branch. - Support MLP and GLU fittings, including linear fittings without hidden layers. Expand each input projection once and reuse the result for all state-dict aliases. - Reject incompatible tensor shapes, fitting families, and first-layer identity or duplicated residuals whose semantics change on expansion. Check residuals independently of resnet_dt before loading target state. - Detach copied values so temporary transfer tensors retain no autograd graph. All conversion work runs during initialization. Leave out_bias, out_std, and bias_atom_e to the target preset_out_bias and fine-tuning statistics. Start optimizer and EMA state afresh. Retain native-to-native transfer validation, existing type-map restrictions, and explicit RANDOM fitting initialization. Validation: - 127 CPU tests and 2 subtests passed across fine-tuning and bridging. - Check initial energy/force/virial parity, zero initial magnetic force, trainable magnetic gradients, and checkpoint/serialization round trips. - Cover all v20260911 DPA4C presets, MLP/GLU input layouts, residual rejection without target mutation, and frame/atomic fitting inputs. - Exercise single-task and multi-task CLI fine-tuning into plain and ZBL models, including target bias presets/statistics and fresh optimizer/EMA. - Repository pre-commit hooks passed. GPU and distributed validation were not performed for this change. --- deepmd/pt/train/training.py | 21 + deepmd/pt/utils/finetune.py | 27 + deepmd/pt/utils/spin_finetune.py | 284 +++++++++++ deepmd/pt_expt/train/training.py | 107 ++-- deepmd/utils/finetune.py | 26 +- source/tests/common/test_finetune_utils.py | 38 +- .../pt_expt/model/test_get_model_bridging.py | 2 +- .../pt_expt/test_finetune_native_spin.py | 460 ++++++++++++++++++ 8 files changed, 888 insertions(+), 77 deletions(-) create mode 100644 deepmd/pt/utils/spin_finetune.py create mode 100644 source/tests/pt_expt/test_finetune_native_spin.py diff --git a/deepmd/pt/train/training.py b/deepmd/pt/train/training.py index 7efc76e4d9..cb24eeaecc 100644 --- a/deepmd/pt/train/training.py +++ b/deepmd/pt/train/training.py @@ -101,6 +101,9 @@ LmdbBatchDataLoader, LmdbDataset, ) +from deepmd.pt.utils.spin_finetune import ( + get_native_spin_finetune_state, +) from deepmd.pt.utils.stat import ( make_stat_input, pair_margin_frame_mask, @@ -874,6 +877,24 @@ def collect_single_finetune_params( _pretrained_model_params["descriptor"], _model_key_from, ) + if _finetune_rule_single.convert_to_native_spin: + log.info( + "Initialize native-spin branch '%s' from spin-free branch '%s'.", + _model_key, + _model_key_from, + ) + converted = get_native_spin_finetune_state( + self.wrapper.model[_model_key], + pretrained_model_wrapper.model[_model_key_from], + random_fitting=_new_fitting, + ) + _new_state_dict.update( + { + f"model.{_model_key}.{key}": value + for key, value in converted.items() + } + ) + return target_keys = [ i for i in _random_state_dict.keys() diff --git a/deepmd/pt/utils/finetune.py b/deepmd/pt/utils/finetune.py index bf1c328408..ad918701da 100644 --- a/deepmd/pt/utils/finetune.py +++ b/deepmd/pt/utils/finetune.py @@ -21,6 +21,33 @@ __all__ = ["get_finetune_rule_single", "get_finetune_rules"] +def _learned_part_path(model: torch.nn.Module) -> str | None: + """Return the state-dict path of the learned descriptor-fitting part of a model. + + A plain model holds that part as its atomic model and a composition with + one learned sub-model, such as a zone-bridging one, as that sub-model, so + fine-tuning between the two transfers the part across the paths. A model + without a single learned part returns ``None``. + """ + atomic_model = model.atomic_model + if getattr(atomic_model, "descriptor", None) is not None: + return "atomic_model." + learned = [ + index + for index, child in enumerate(getattr(atomic_model, "models", ())) + if getattr(child, "descriptor", None) is not None + ] + return f"atomic_model.models.{learned[0]}." if len(learned) == 1 else None + + +def _move_learned_key(key: str, path: str, source_path: str) -> str: + """Move a descriptor or fitting-net key between learned atomic-model paths.""" + for part in ("descriptor.", "fitting_net."): + if f".{path}{part}" in key: + return key.replace(f".{path}{part}", f".{source_path}{part}", 1) + return key + + def get_finetune_rules( finetune_model: str, model_config: dict[str, Any], diff --git a/deepmd/pt/utils/spin_finetune.py b/deepmd/pt/utils/spin_finetune.py new file mode 100644 index 0000000000..6107040eee --- /dev/null +++ b/deepmd/pt/utils/spin_finetune.py @@ -0,0 +1,284 @@ +# SPDX-License-Identifier: LGPL-3.0-or-later +"""Spin-free checkpoint transfer for the PyTorch and pt_expt native-spin models.""" + +from functools import ( + singledispatch, +) + +import torch + +from deepmd.dpmodel.descriptor.dpa4 import ( + DescrptDPA4, +) +from deepmd.dpmodel.descriptor.dpa4c import ( + DescrptDPA4C, +) +from deepmd.dpmodel.fitting.dpa4_ener import ( + GLUFittingNet, +) +from deepmd.dpmodel.utils.network import ( + FittingNet, + NativeLayer, +) +from deepmd.pt.model.descriptor.sezm import ( + DescrptSeZM, +) +from deepmd.pt.utils.finetune import ( + _learned_part_path, +) + + +def get_native_spin_finetune_state( + model: torch.nn.Module, + pretrained_model: torch.nn.Module, + *, + random_fitting: bool = False, +) -> dict[str, torch.Tensor]: + """Build native-spin state from a spin-free learned atomic model. + + Type maps must already agree. The learned parts are matched independently + of analytical bridging. Output biases and scales belong to the target's + subsequent statistics pass; spin-only state keeps its constructor values. + Descriptor-specific transfer rules preserve the geometric function while + leaving a nonzero gradient into the initially closed spin routes. + + Parameters + ---------- + model + Fresh native-spin target, with its input configuration and spin mask. + pretrained_model + Loaded spin-free source after type-map alignment. + random_fitting + Whether to retain the target fitting network instead of transferring it. + + Returns + ------- + dict[str, torch.Tensor] + Complete state dict for a strict load into the target model. + + Raises + ------ + ValueError + If the learned parts, descriptor families, type maps or tensor shapes + are incompatible with spin-free transfer, or widening the fitting input + changes an implicit residual connection. + """ + path = _learned_part_path(model) + source_path = _learned_part_path(pretrained_model) + if path is None or source_path is None: + raise ValueError( + "Spin-free transfer requires one learned atomic model in each model." + ) + if model.get_type_map() != pretrained_model.get_type_map(): + raise ValueError("Spin-free transfer requires aligned type maps.") + learned = model.get_submodule(path.removesuffix(".")) + source = pretrained_model.get_submodule(source_path.removesuffix(".")) + descriptor = learned.descriptor + if type(descriptor) is not type(source.descriptor): + raise ValueError( + "Spin-free transfer requires the same descriptor family: " + f"source={type(source.descriptor).__name__}, " + f"target={type(descriptor).__name__}." + ) + if descriptor.use_spin is None or source.descriptor.use_spin is not None: + raise ValueError( + "Spin-free transfer requires a spin-free source descriptor and a " + "native-spin target descriptor." + ) + + expanded = _expanded_spin_state( + descriptor, source, learned, random_fitting=random_fitting + ) + state = model.state_dict() + for key, value in source.state_dict().items(): + target_key = path + key + if target_key not in state or key.rsplit(".", 1)[-1] in { + "out_bias", + "out_std", + "bias_atom_e", + }: + continue + if random_fitting and not key.startswith("descriptor."): + continue + if key in expanded: + state[target_key] = expanded[key] + else: + _check_shape(key, value.shape, state[target_key].shape) + state[target_key] = value.detach().clone() + return state + + +@singledispatch +def _expanded_spin_state( + descriptor: object, + source: torch.nn.Module, + target: torch.nn.Module, + *, + random_fitting: bool, +) -> dict[str, torch.Tensor]: + """Expand state under the descriptor's native-spin layout contract.""" + raise ValueError( + f"Spin-free transfer is not registered for {type(descriptor).__name__}." + ) + + +@_expanded_spin_state.register(DescrptSeZM) +@_expanded_spin_state.register(DescrptDPA4) +def _expanded_dpa4_state( + descriptor: object, + source: torch.nn.Module, + target: torch.nn.Module, + *, + random_fitting: bool, +) -> dict[str, torch.Tensor]: + # DPA4 injects zero-initialized additive spin routes without widening the + # geometric descriptor or its fitting input. + return {} + + +@_expanded_spin_state.register(DescrptDPA4C) +def _expanded_dpa4c_state( + descriptor: DescrptDPA4C, + source: torch.nn.Module, + target: torch.nn.Module, + *, + random_fitting: bool, +) -> dict[str, torch.Tensor]: + # The descriptor is [readout, spin, masses, type embedding]. Any frame or + # atomic fitting parameters follow this descriptor and shift with its tail. + start = descriptor.readout.get_dim_out() + width = descriptor.spin.get_dim_out() + expanded = ( + {} + if random_fitting + else _expanded_fitting_input_state( + source.fitting_net, target.fitting_net, start=start, width=width + ) + ) + source_state = source.state_dict() + target_state = target.state_dict() + for key in ("descriptor.mean", "descriptor.stddev"): + expanded[key] = _insert_spin_block( + key, + source_state[key], + target_state[key], + axis=0, + start=start, + width=width, + ) + # Ordered spin scale/shift columns follow every geometric FiLM head. + key = "descriptor.pair_film.network.layers.1.w" + expanded[key] = _insert_spin_block( + key, + source_state[key], + target_state[key], + axis=1, + start=source_state[key].shape[1], + width=2 * descriptor.spin_channels, + ) + return expanded + + +@singledispatch +def _fitting_input_projection(network: object) -> NativeLayer: + """Return the affine layer that reads the fitting input.""" + raise ValueError( + "Spin-free input expansion is not registered for fitting network " + f"{type(network).__name__}." + ) + + +@_fitting_input_projection.register(FittingNet) +def _mlp_input_projection(network: FittingNet) -> NativeLayer: + return network.layers[0] + + +@_fitting_input_projection.register(GLUFittingNet) +def _glu_input_projection(network: GLUFittingNet) -> NativeLayer: + return network.hidden_layers[0] if network.hidden_layers else network.output_layer + + +def _expanded_fitting_input_state( + source: torch.nn.Module, + target: torch.nn.Module, + *, + start: int, + width: int, +) -> dict[str, torch.Tensor]: + """Expand fitting input projections without changing their residual semantics.""" + if source.nets.ndim != target.nets.ndim: + raise ValueError("Spin-free transfer requires the same fitting type layout.") + expanded: dict[int, torch.Tensor] = {} + for index in range(target.nets.ntypes**target.nets.ndim): + source_net, target_net = source.nets[index], target.nets[index] + if type(source_net) is not type(target_net): + raise ValueError( + "Spin-free transfer requires matching fitting network types: " + f"source={type(source_net).__name__}, " + f"target={type(target_net).__name__}." + ) + source_layer = _fitting_input_projection(source_net) + target_layer = _fitting_input_projection(target_net) + # NativeLayer derives its residual and timestep from the input/output + # widths. An inserted input block changes an identity or duplicated + # residual, which cannot in general be absorbed into the nonlinear + # branch's weights. Reject that structural change before loading state. + for layer in (source_layer, target_layer): + if layer.resnet and layer.dim_out() in ( + layer.dim_in(), + 2 * layer.dim_in(), + ): + raise ValueError( + "Spin-free transfer cannot preserve the first fitting layer's " + "residual connection when its input expands from " + f"{source_layer.dim_in()} to {target_layer.dim_in()} " + f"with output width {target_layer.dim_out()}. " + "Use a compatible pretrained fitting architecture or select " + "finetune_head='RANDOM' to initialize a fresh fitting network." + ) + # A closed descriptor spin gate preserves the geometric function while + # fresh fitting rows give that gate a nonzero gradient. + expanded[id(target_layer)] = _insert_spin_block( + f"fitting_net.nets[{index}].input_projection.w", + source_layer.w, + target_layer.w, + axis=0, + start=start, + width=width, + ) + # Network collections expose multiple state-dict paths for the same layer. + # Every alias refers to the same expanded weight, computed once per network. + return { + f"fitting_net.{name}.w": expanded[id(module)] + for name, module in target.named_modules(remove_duplicate=False) + if id(module) in expanded + } + + +def _check_shape(key: str, expected: tuple[int, ...], actual: tuple[int, ...]) -> None: + if actual != expected: + raise ValueError( + f"Incompatible spin-free transfer for {key}: " + f"expected target shape {tuple(expected)}, got {tuple(actual)}." + ) + + +def _insert_spin_block( + key: str, + source: torch.Tensor, + target: torch.Tensor, + *, + axis: int, + start: int, + width: int, +) -> torch.Tensor: + """Copy the source around an inserted block, retaining its target values.""" + expected = list(source.shape) + expected[axis] += width + _check_shape(key, tuple(expected), target.shape) + source = source.detach() + result = target.detach().clone() + result.narrow(axis, 0, start).copy_(source.narrow(axis, 0, start)) + tail = source.shape[axis] - start + result.narrow(axis, start + width, tail).copy_(source.narrow(axis, start, tail)) + return result diff --git a/deepmd/pt_expt/train/training.py b/deepmd/pt_expt/train/training.py index f1e7f27c58..36538b8aea 100644 --- a/deepmd/pt_expt/train/training.py +++ b/deepmd/pt_expt/train/training.py @@ -101,6 +101,13 @@ strip_saved_tensor_detach as _strip_saved_tensor_detach, ) from deepmd.pt.utils.compile_compat import trace_pad_dim as _trace_pad_dim +from deepmd.pt.utils.finetune import ( + _learned_part_path, + _move_learned_key, +) +from deepmd.pt.utils.spin_finetune import ( + get_native_spin_finetune_state, +) from deepmd.pt_expt.loss import ( DOSLoss, EnergyLoss, @@ -2243,34 +2250,42 @@ def initialize_statistics( # Selective weight copy (per-branch key remapping) pretrained_state = pretrained_wrapper.state_dict() target_state = self._unwrapped.state_dict() - # A plain model and its zone-bridging composition hold the same - # learned descriptor and fitting net at different paths. - learned_paths = { - model_key: ( - _learned_part_path(self._unwrapped.model[model_key]), - _learned_part_path( - pretrained_wrapper.model[ - finetune_links[model_key].get_model_branch() - ] - ), - ) - for model_key in self.model_keys - } - new_state = {} - for key in target_state: - if key == "_extra_state": - new_state[key] = target_state[key] + new_state = target_state.copy() + for model_key in self.model_keys: + finetune_rule = finetune_links[model_key] + source_key = finetune_rule.get_model_branch() + model = self._unwrapped.model[model_key] + source_model = pretrained_wrapper.model[source_key] + prefix = f"model.{model_key}." + if finetune_rule.convert_to_native_spin: + log.info( + "Initialize native-spin branch '%s' from spin-free branch '%s'.", + model_key, + source_key, + ) + converted = get_native_spin_finetune_state( + model, + source_model, + random_fitting=finetune_rule.get_random_fitting(), + ) + new_state.update( + {prefix + key: value for key, value in converted.items()} + ) continue - # Find which model_key this key belongs to - matched = False - for model_key in self.model_keys: - if f".{model_key}." not in key: + + # Plain and bridged models store their learned part at + # different paths; the analytical part keeps target state. + path = _learned_part_path(model) + source_path = _learned_part_path(source_model) + for key in target_state: + if not key.startswith(prefix): + continue + if ( + finetune_rule.get_random_fitting() + and ".descriptor." not in key + ): continue - matched = True - finetune_rule = finetune_links[model_key] - _key_from = finetune_rule.get_model_branch() - pretrained_key = key.replace(f".{model_key}.", f".{_key_from}.") - path, source_path = learned_paths[model_key] + pretrained_key = f"model.{source_key}." + key[len(prefix) :] if ( pretrained_key not in pretrained_state and path is not None @@ -2279,19 +2294,8 @@ def initialize_statistics( pretrained_key = _move_learned_key( pretrained_key, path, source_path ) - use_random = ( - finetune_rule.get_random_fitting() - and ".descriptor." not in key - ) - if use_random: - new_state[key] = target_state[key] - elif pretrained_key in pretrained_state: + if pretrained_key in pretrained_state: new_state[key] = pretrained_state[pretrained_key] - else: - new_state[key] = target_state[key] - break - if not matched: - new_state[key] = target_state[key] self._unwrapped.load_state_dict(new_state) # Per-branch bias adjustment (rank 0 only, then broadcast) @@ -3540,33 +3544,6 @@ def _more_loss_to_float(cls, more_loss: dict[str, Any]) -> dict[str, float]: } -def _learned_part_path(model: torch.nn.Module) -> str | None: - """Return the state-dict path of the learned descriptor-fitting part of a model. - - A plain model holds that part as its atomic model and a composition with - one learned sub-model, such as a zone-bridging one, as that sub-model, so - fine-tuning between the two transfers the part across the paths. A model - without a single learned part returns ``None``. - """ - atomic_model = model.atomic_model - if getattr(atomic_model, "descriptor", None) is not None: - return "atomic_model." - learned = [ - index - for index, child in enumerate(getattr(atomic_model, "models", ())) - if getattr(child, "descriptor", None) is not None - ] - return f"atomic_model.models.{learned[0]}." if len(learned) == 1 else None - - -def _move_learned_key(key: str, path: str, source_path: str) -> str: - """Rewrite a descriptor or fitting-net key from one learned-part path to another.""" - for part in ("descriptor.", "fitting_net."): - if f".{path}{part}" in key: - return key.replace(f".{path}{part}", f".{source_path}{part}", 1) - return key - - def model_change_out_bias( _model: Any, _sample_func: Any, diff --git a/deepmd/utils/finetune.py b/deepmd/utils/finetune.py index 5dd3623cba..c6ff561dc0 100644 --- a/deepmd/utils/finetune.py +++ b/deepmd/utils/finetune.py @@ -221,17 +221,19 @@ def _native_spin_types( def _validate_native_spin_finetune( target_config: Mapping[str, Any], pretrained_config: Mapping[str, Any], -) -> None: - """Validate the native-spin transfer contract before model construction.""" +) -> bool: + """Validate spin transfer and report whether a spin-free source needs conversion.""" target_types = _native_spin_types(target_config) if target_types is None: - return + return False pretrained_types = _native_spin_types(pretrained_config) if pretrained_types is None: + if pretrained_config.get("spin") is None: + return True raise ValueError( - "Native-spin fine-tuning requires a native-spin pretrained model; " - "automatic conversion from a spin-free model is not supported." + "Native-spin fine-tuning requires a spin-free or native-spin " + "pretrained model." ) if pretrained_types and pretrained_types != target_types: raise ValueError( @@ -239,6 +241,7 @@ def _validate_native_spin_finetune( f"fine-tuning is not supported: pretrained={sorted(pretrained_types)}, " f"target={sorted(target_types)}." ) + return False class FinetuneRuleItem: @@ -249,6 +252,7 @@ def __init__( model_branch: str = "Default", random_fitting: bool = False, resuming: bool = False, + convert_to_native_spin: bool = False, ) -> None: """ The rules for fine-tuning the model from pretrained model. @@ -265,12 +269,15 @@ def __init__( If true, the fitting net will be randomly initialized instead of inherit from the pretrained model. resuming If true, the model will just resume from model_branch without fine-tuning. + convert_to_native_spin + If true, initialize the native-spin model from a spin-free branch. """ self.p_type_map = p_type_map self.type_map = type_map self.model_branch = model_branch self.random_fitting = random_fitting self.resuming = resuming + self.convert_to_native_spin = convert_to_native_spin self.update_type = self.p_type_map != self.type_map def get_index_mapping(self) -> list[int]: @@ -398,7 +405,9 @@ def build(self) -> tuple[dict[str, Any], dict[str, FinetuneRuleItem]]: model_branch_from=model_branch_from, ) ) - finetune_rule.resuming = resuming + finetune_rule.resuming = ( + resuming and not finetune_rule.convert_to_native_spin + ) finetune_links[model_key] = finetune_rule return model_config, finetune_links @@ -444,7 +453,9 @@ def build_single_rule( model_branch_chosen = model_alias_dict[model_branch_chosen] single_config_chosen = deepcopy(model_dict_params[model_branch_chosen]) - _validate_native_spin_finetune(single_config, single_config_chosen) + convert_to_native_spin = _validate_native_spin_finetune( + single_config, single_config_chosen + ) old_type_map = single_config_chosen["type_map"] new_type_map = single_config["type_map"] @@ -453,6 +464,7 @@ def build_single_rule( type_map=new_type_map, model_branch=model_branch_chosen, random_fitting=new_fitting, + convert_to_native_spin=convert_to_native_spin, ) if self.change_model_params: self._apply_pretrained_model_params( diff --git a/source/tests/common/test_finetune_utils.py b/source/tests/common/test_finetune_utils.py index 33547b34d0..85774c4338 100644 --- a/source/tests/common/test_finetune_utils.py +++ b/source/tests/common/test_finetune_utils.py @@ -298,22 +298,52 @@ def test_finetune_rule_builder_accepts_compatible_native_spin_transfers(): ), ) for pretrained, target in compatible_pairs: - finetune.FinetuneRuleBuilder( + _, rules = finetune.FinetuneRuleBuilder( pretrained, target, change_model_params=False, ).build() + assert not rules["Default"].convert_to_native_spin + + +def test_finetune_rule_builder_accepts_spin_free_pretraining() -> None: + pretrained = _model_config(["Fe", "C"]) + target = _native_spin_model_config(["Fe", "C"], ["Fe"]) + target["preset_out_bias"] = {"energy": {"Fe": -1.0, "C": -2.0}} + target["bridging_method"] = "zbl" + original = deepcopy(target) + for change_model_params in (False, True): + config, rules = finetune.FinetuneRuleBuilder( + pretrained, target, change_model_params=change_model_params + ).build() + assert rules["Default"].convert_to_native_spin + assert config["spin"] == original["spin"] + assert config["preset_out_bias"] == original["preset_out_bias"] + assert config["bridging_method"] == "zbl" + assert target == original -def test_finetune_rule_builder_rejects_non_native_spin_pretraining(): +def test_spin_free_multitask_branch_requires_finetune_statistics() -> None: + pretrained = {"model_dict": {"magnetic": _model_config(["Fe", "C"])}} + target = { + "model_dict": {"magnetic": _native_spin_model_config(["Fe", "C"], ["Fe"])} + } + _, rules = finetune.FinetuneRuleBuilder(pretrained, target).build() + assert rules["magnetic"].convert_to_native_spin + assert not rules["magnetic"].get_resuming() + + +def test_finetune_rule_builder_rejects_virtual_spin_pretraining(): + pretrained = _model_config(["Fe", "C"]) + pretrained["spin"] = {"use_spin": [True, False], "scheme": "deepspin"} try: finetune.FinetuneRuleBuilder( - _model_config(["Fe", "C"]), + pretrained, _native_spin_model_config(["Fe", "C"], [True, False]), change_model_params=False, ).build() except ValueError as exc: - assert "requires a native-spin pretrained model" in str(exc) + assert "requires a spin-free or native-spin pretrained model" in str(exc) else: raise AssertionError("expected ValueError") diff --git a/source/tests/pt_expt/model/test_get_model_bridging.py b/source/tests/pt_expt/model/test_get_model_bridging.py index ecead9a672..a73674e48a 100644 --- a/source/tests/pt_expt/model/test_get_model_bridging.py +++ b/source/tests/pt_expt/model/test_get_model_bridging.py @@ -185,7 +185,7 @@ def test_fine_tune_locates_the_learned_part_by_structure() -> None: A descriptor exclusion gives the child its own pair exclusion, which keeps the composition off the fused route, but not its learned part off its path. """ - from deepmd.pt_expt.train.training import ( + from deepmd.pt.utils.finetune import ( _learned_part_path, ) diff --git a/source/tests/pt_expt/test_finetune_native_spin.py b/source/tests/pt_expt/test_finetune_native_spin.py new file mode 100644 index 0000000000..db1ae78302 --- /dev/null +++ b/source/tests/pt_expt/test_finetune_native_spin.py @@ -0,0 +1,460 @@ +# SPDX-License-Identifier: LGPL-3.0-or-later +"""Spin-free checkpoint transfer through both native-spin training backends.""" + +import json +from copy import ( + deepcopy, +) +from pathlib import ( + Path, +) + +import numpy as np +import pytest +import torch + +from deepmd.pt.entrypoints.main import main as pt_main +from deepmd.pt.model.model import get_model as get_pt_model +from deepmd.pt.train.wrapper import ModelWrapper as PTModelWrapper +from deepmd.pt.utils.finetune import ( + _learned_part_path, +) +from deepmd.pt.utils.spin_finetune import ( + get_native_spin_finetune_state, +) +from deepmd.pt_expt.entrypoints.main import ( + main, +) +from deepmd.pt_expt.model import ( + get_model, +) +from deepmd.pt_expt.train.wrapper import ( + ModelWrapper, +) +from deepmd.pt_expt.utils.env import ( + DEVICE, +) +from deepmd.utils.model_preset import ( + get_model_preset, +) + +from .test_finetune import ( + _make_config, + _native_spin_config, +) + +_BACKENDS = [("pt", "dpa4"), ("pt_expt", "dpa4"), ("pt_expt", "dpa4c")] + + +def _config(descriptor: str, native: bool, bridged: bool = False) -> dict: + config = _native_spin_config(descriptor, [True, False]) + if not native: + config.pop("spin") + if bridged: + config["bridging_method"] = "zbl" + config["data_stat_nbatch"] = 1 + return config + + +def _build(backend: str, config: dict) -> torch.nn.Module: + builder = get_pt_model if backend == "pt" else get_model + return builder(deepcopy(config)).to(DEVICE) + + +def _learned(model: torch.nn.Module) -> torch.nn.Module: + return model.get_submodule(_learned_part_path(model).removesuffix(".")) + + +def _pretrained(backend: str, config: dict) -> torch.nn.Module: + """Build a geometric model whose nonzero forces make transfer observable.""" + model = _build(backend, config) + generator = torch.Generator(device=DEVICE).manual_seed(31) + with torch.no_grad(): + for parameter in model.parameters(): + parameter.add_( + 0.02 + * torch.randn( + parameter.shape, + dtype=parameter.dtype, + device=parameter.device, + generator=generator, + ) + ) + descriptor = _learned(model).descriptor + for name, start, stop in (("mean", -0.2, 0.2), ("stddev", 0.8, 1.2)): + array = getattr(descriptor, name) + array.copy_( + torch.linspace( + start, stop, array.numel(), dtype=array.dtype, device=array.device + ).reshape(array.shape) + ) + return model + + +def _inputs() -> dict[str, torch.Tensor]: + return { + "coord": torch.tensor( + [[[0.0, 0.0, 0.0], [1.3, 0.2, 0.1], [0.3, 1.5, 0.4]]], + dtype=torch.float64, + device=DEVICE, + ), + "atype": torch.tensor([[0, 1, 0]], device=DEVICE), + "box": (6.0 * torch.eye(3, dtype=torch.float64, device=DEVICE)).reshape(1, 9), + "spin": torch.tensor( + [[[0.6, 0.2, -0.7], [0.1, 0.5, 0.1], [-0.4, 0.8, 0.3]]], + dtype=torch.float64, + device=DEVICE, + ), + } + + +@pytest.mark.parametrize("backend, descriptor", _BACKENDS) +@pytest.mark.parametrize("bridged", [False, True]) +def test_spin_free_transfer_preserves_function_and_trains_spin( + backend: str, descriptor: str, bridged: bool +) -> None: + source = _pretrained(backend, _config(descriptor, False, bridged)) + target = _build(backend, _config(descriptor, True, bridged)) + target.load_state_dict(get_native_spin_finetune_state(target, source)) + inputs = _inputs() + spin = inputs.pop("spin") + expected = source(**inputs, do_atomic_virial=True) + assert expected["force"].abs().max() > 1e-8 + for scale in (0.0, 1.0, 2.0): + actual = target(**inputs, spin=scale * spin, do_atomic_virial=True) + for key in ("energy", "force", "virial", "atom_virial"): + torch.testing.assert_close( + actual[key], expected[key], rtol=1e-11, atol=1e-11 + ) + torch.testing.assert_close( + actual["force_mag"], torch.zeros_like(actual["force_mag"]), rtol=0, atol=0 + ) + + spin_params = [param for name, param in target.named_parameters() if "spin" in name] + optimizer = torch.optim.SGD(spin_params, lr=1e-3) + (actual["force_mag"] - 0.2).square().sum().backward() + assert any( + param.grad is not None and param.grad.abs().max() > 1e-8 + for param in spin_params + ) + optimizer.step() + trained = target(**inputs, spin=spin) + assert trained["force_mag"].abs().max() > 1e-10 + assert torch.count_nonzero(trained["force_mag"][:, 1]) == 0 + + +@pytest.mark.parametrize("backend, descriptor", _BACKENDS) +@pytest.mark.parametrize("random_fitting", [False, True]) +def test_spin_free_transfer_preserves_target_bias_and_bridging( + backend: str, descriptor: str, random_fitting: bool +) -> None: + source = _pretrained(backend, _config(descriptor, False)) + target = _build(backend, _config(descriptor, True, bridged=True)) + initial = {key: value.clone() for key, value in target.state_dict().items()} + with torch.no_grad(): + for key, value in source.state_dict().items(): + if key.rsplit(".", 1)[-1] in {"out_bias", "out_std", "bias_atom_e"}: + value.fill_(1234.0) + state = get_native_spin_finetune_state( + target, source, random_fitting=random_fitting + ) + target.load_state_dict(state) + for key, value in state.items(): + if key.rsplit(".", 1)[-1] in {"out_bias", "out_std", "bias_atom_e"} or ( + random_fitting and ".fitting_net." in key + ): + torch.testing.assert_close(value, initial[key], rtol=0, atol=0) + source_descriptor = _learned(source).descriptor.state_dict() + target_descriptor = _learned(target).descriptor.state_dict() + for key, value in source_descriptor.items(): + if value.shape == target_descriptor[key].shape: + torch.testing.assert_close(value, target_descriptor[key], rtol=0, atol=0) + np.testing.assert_array_equal(target.spin.use_spin, [True, False]) + + +@pytest.mark.parametrize("backend, descriptor", _BACKENDS) +@pytest.mark.parametrize("width", [4, 16]) +def test_spin_free_transfer_rejects_unrelated_shape_change( + backend: str, descriptor: str, width: int +) -> None: + source = _build(backend, _config(descriptor, False)) + config = _config(descriptor, True) + config["fitting_net"]["neuron"] = [width] + target = _build(backend, config) + with pytest.raises(ValueError, match="Incompatible spin-free transfer"): + get_native_spin_finetune_state(target, source) + + +@pytest.mark.parametrize("resnet_dt", [False, True]) +@pytest.mark.parametrize("residual_side", ["source", "target"]) +@pytest.mark.parametrize("width_multiple", [1, 2]) +def test_dpa4c_transfer_rejects_changed_input_residual( + resnet_dt: bool, residual_side: str, width_multiple: int +) -> None: + source_config = _config("dpa4c", False) + target_config = _config("dpa4c", True) + width_config = source_config if residual_side == "source" else target_config + width = _learned(_build("pt_expt", width_config)).descriptor.get_dim_out() + for config in (source_config, target_config): + config["fitting_net"].update( + neuron=[width_multiple * width], resnet_dt=resnet_dt + ) + source = _pretrained("pt_expt", source_config) + target = _build("pt_expt", target_config) + initial = {key: value.clone() for key, value in target.state_dict().items()} + with pytest.raises(ValueError, match=r"residual.*input"): + get_native_spin_finetune_state(target, source) + for key, value in target.state_dict().items(): + torch.testing.assert_close(value, initial[key], rtol=0, atol=0) + + # A random fitting net does not inherit the source function or its residual. + state = get_native_spin_finetune_state(target, source, random_fitting=True) + target.load_state_dict(state) + for key, value in state.items(): + if ".fitting_net." in key: + torch.testing.assert_close(value, initial[key], rtol=0, atol=0) + + +@pytest.mark.parametrize("fitting_type", ["ener", "dpa4_ener", "sezm_ener"]) +@pytest.mark.parametrize("neuron", [[], [8], [8, 8]]) +def test_dpa4c_transfer_fitting_input_projection( + fitting_type: str, neuron: list[int] +) -> None: + source_config = _config("dpa4c", False) + source_config["fitting_net"].update(type=fitting_type, neuron=neuron) + target_config = deepcopy(source_config) + target_config["spin"] = {"scheme": "native", "use_spin": [True, False]} + source = _pretrained("pt_expt", source_config) + target = _build("pt_expt", target_config) + # DPA4C's descriptor selects a shared fitting network at model construction. + assert _learned(source).fitting_net.nets.ndim == 0 + assert _learned(target).fitting_net.nets.ndim == 0 + state = get_native_spin_finetune_state(target, source) + assert all(not value.requires_grad for value in state.values()) + target.load_state_dict(state) + inputs = _inputs() + spin = inputs.pop("spin") + expected = source(**inputs, do_atomic_virial=True) + assert expected["force"].abs().max() > 1e-8 + for scale in (0.0, 1.0, 2.0): + actual = target(**inputs, spin=scale * spin, do_atomic_virial=True) + for key in ("energy", "force", "virial", "atom_virial"): + torch.testing.assert_close( + actual[key], expected[key], rtol=1e-11, atol=1e-11 + ) + assert torch.count_nonzero(actual["force_mag"]) == 0 + + (actual["force_mag"] - 0.2).square().sum().backward() + gate = _learned(target).descriptor.spin.spin_gate + assert gate.grad is not None and gate.grad.abs().max() > 1e-8 + with torch.no_grad(): + gate.add_(-1e-3 * gate.grad) + trained = target(**inputs, spin=spin) + assert trained["force_mag"].abs().max() > 1e-10 + assert torch.count_nonzero(trained["force_mag"][:, 1]) == 0 + + restored = type(target).deserialize(target.serialize()).to(DEVICE) + reloaded = restored(**inputs, spin=spin) + for key in ("energy", "force", "virial", "force_mag"): + torch.testing.assert_close(reloaded[key], trained[key], rtol=1e-11, atol=1e-11) + + +@pytest.mark.parametrize( + "source_type, target_type", [("ener", "dpa4_ener"), ("dpa4_ener", "ener")] +) +def test_dpa4c_transfer_rejects_changed_fitting_family( + source_type: str, target_type: str +) -> None: + source_config = _config("dpa4c", False) + target_config = _config("dpa4c", True) + for config, fitting_type in ( + (source_config, source_type), + (target_config, target_type), + ): + config["fitting_net"].update( + type=fitting_type, neuron=[16 if fitting_type == "ener" else 8] + ) + source = _build("pt_expt", source_config) + target = _build("pt_expt", target_config) + with pytest.raises(ValueError, match="matching fitting network types"): + get_native_spin_finetune_state(target, source) + + +@pytest.mark.parametrize("fitting_type", ["ener", "dpa4_ener"]) +def test_dpa4c_spin_transfer_preserves_fitting_parameter_tail( + fitting_type: str, +) -> None: + source_config = _config("dpa4c", False) + source_config["fitting_net"].update(type=fitting_type, numb_fparam=2, numb_aparam=1) + target_config = deepcopy(source_config) + target_config["spin"] = {"scheme": "native", "use_spin": [True, False]} + source = _pretrained("pt_expt", source_config) + target = _build("pt_expt", target_config) + target.load_state_dict(get_native_spin_finetune_state(target, source)) + inputs = _inputs() + spin = inputs.pop("spin") + inputs["fparam"] = torch.tensor([[0.2, -0.1]], dtype=torch.float64, device=DEVICE) + inputs["aparam"] = torch.tensor( + [[[0.3], [0.5], [0.7]]], dtype=torch.float64, device=DEVICE + ) + expected = source(**inputs) + actual = target(**inputs, spin=spin) + for key in ("energy", "force", "virial"): + torch.testing.assert_close(actual[key], expected[key], rtol=1e-11, atol=1e-11) + + +@pytest.mark.parametrize("grade", ["nano", "mini", "neo", "air", "plus"]) +def test_dpa4c_preset_spin_free_transfer(grade: str) -> None: + source_config = get_model_preset(f"dpa4c-{grade}-v20260911") + source_config["descriptor"]["seed"] = 17 + source_config["fitting_net"]["seed"] = 19 + target_config = deepcopy(source_config) + target_config["spin"] = {"scheme": "native", "use_spin": ["Fe"]} + source = _pretrained("pt_expt", source_config) + target = _build("pt_expt", target_config) + target.load_state_dict(get_native_spin_finetune_state(target, source)) + inputs = _inputs() + spin = inputs.pop("spin") + inputs["atype"] = torch.tensor( + [[source.get_type_map().index(name) for name in ("Fe", "C", "Fe")]], + device=DEVICE, + ) + expected = source(**inputs) + actual = target(**inputs, spin=spin) + for key in ("energy", "force", "virial"): + torch.testing.assert_close(actual[key], expected[key], rtol=2e-5, atol=2e-5) + assert torch.count_nonzero(actual["force_mag"]) == 0 + + +@pytest.mark.parametrize( + "backend, descriptor, fitting_type", + [ + ("pt", "dpa4", "dpa4_ener"), + ("pt_expt", "dpa4", "dpa4_ener"), + ("pt_expt", "dpa4c", "ener"), + ("pt_expt", "dpa4c", "dpa4_ener"), + ], +) +@pytest.mark.parametrize("bridged", [False, True]) +@pytest.mark.parametrize("multitask", [False, True]) +def test_spin_free_native_finetune_cli( + backend: str, + descriptor: str, + fitting_type: str, + bridged: bool, + multitask: bool, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + source_config = _config(descriptor, False) + source_config["fitting_net"]["type"] = fitting_type + source = _pretrained(backend, source_config) + wrapper_cls = PTModelWrapper if backend == "pt" else ModelWrapper + wrapper = wrapper_cls( + {"magnetic": source} if multitask else source, + model_params={"model_dict": {"magnetic": source_config}} + if multitask + else source_config, + ) + wrapper.train_infos["step"] = 100 + checkpoint = tmp_path / "pretrained.pt" + torch.save( + { + "model": wrapper.state_dict(), + "optimizer": {"source_only": True}, + "ema": {"source_only": True}, + }, + checkpoint, + ) + + inputs = _inputs() + spin = inputs.pop("spin") + expected = source(**inputs) + system = tmp_path / "data" / "data_0" + arrays = system / "set.000" + arrays.mkdir(parents=True) + (system / "type.raw").write_text("0\n1\n0\n") + (system / "type_map.raw").write_text("Fe\nC\n") + labels = { + "coord": inputs["coord"], + "box": inputs["box"], + "spin": spin, + "energy": expected["energy"] + 1.75, + "force": expected["force"], + "force_mag": torch.full_like(spin, 0.2), + } + for key, value in labels.items(): + data = value.detach().cpu().numpy().reshape(1, -1) + np.save(arrays / f"{key}.npy", np.repeat(data, 2, axis=0)) + + target_config = _config(descriptor, True, bridged) + target_config["fitting_net"]["type"] = fitting_type + if bridged: + target_config["preset_out_bias"] = {"energy": {"Fe": 1.25, "C": -0.75}} + config = _make_config(str(system.parent), target_config, numb_steps=2) + config["loss"] = { + "type": "ener_spin", + "start_pref_e": 1.0, + "limit_pref_e": 1.0, + "start_pref_fr": 1.0, + "limit_pref_fr": 1.0, + "start_pref_fm": 1.0, + "limit_pref_fm": 1.0, + } + config["training"].update(enable_ema=True, ema_decay=0.9) + if multitask: + config["model"] = {"model_dict": {"magnetic": target_config}} + config["loss_dict"] = {"magnetic": config.pop("loss")} + config["training"]["data_dict"] = { + "magnetic": { + key: config["training"].pop(key) + for key in ("training_data", "validation_data") + } + } + input_path = tmp_path / "input.json" + input_path.write_text(json.dumps(config)) + monkeypatch.chdir(tmp_path) + entrypoint = pt_main if backend == "pt" else main + entrypoint( + [ + "train", + str(input_path), + "--finetune", + str(checkpoint), + "--skip-neighbor-stat", + ] + ) + saved = torch.load( + tmp_path / "model.ckpt.pt", map_location=DEVICE, weights_only=True + ) + params = saved["model"]["_extra_state"]["model_params"] + model_params = params["model_dict"]["magnetic"] if multitask else params + assert model_params["spin"]["scheme"] == "native" + assert model_params["spin"]["use_spin"] == [True, False] + # PT checkpoints record the zero-based step; pt_expt records the step count. + assert saved["model"]["_extra_state"]["train_infos"]["step"] == ( + 1 if backend == "pt" else 2 + ) + assert "source_only" not in saved["optimizer"] + assert "source_only" not in saved["ema"] + trained = _build(backend, model_params) + loaded = wrapper_cls( + {"magnetic": trained} if multitask else trained, model_params=params + ) + loaded.load_state_dict(saved["model"]) + bias = trained.get_out_bias().reshape(-1) + if bridged: + torch.testing.assert_close( + bias, + torch.tensor([1.25, -0.75], dtype=torch.float64, device=DEVICE), + rtol=0, + atol=0, + ) + else: + torch.testing.assert_close( + 2.0 * bias[0] + bias[1], + torch.tensor(1.75, dtype=torch.float64, device=DEVICE), + rtol=1e-11, + atol=1e-11, + ) + assert trained(**inputs, spin=spin)["force_mag"].abs().max() > 1e-10 From b982f4610e5cda749aee4d08216c573c1e7a4b90 Mon Sep 17 00:00:00 2001 From: OutisLi Date: Sat, 24 Oct 2026 19:33:31 +0800 Subject: [PATCH 2/4] fix(finetune): calibrate native-spin input statistics --- deepmd/pt/train/training.py | 12 +- deepmd/pt/utils/spin_finetune.py | 29 +++- deepmd/pt_expt/train/training.py | 6 + .../pt_expt/test_finetune_native_spin.py | 160 +++++++++++++++++- 4 files changed, 201 insertions(+), 6 deletions(-) diff --git a/deepmd/pt/train/training.py b/deepmd/pt/train/training.py index cb24eeaecc..78735a8b91 100644 --- a/deepmd/pt/train/training.py +++ b/deepmd/pt/train/training.py @@ -102,6 +102,7 @@ LmdbDataset, ) from deepmd.pt.utils.spin_finetune import ( + compute_native_spin_input_stats, get_native_spin_finetune_state, ) from deepmd.pt.utils.stat import ( @@ -971,11 +972,16 @@ def update_finetune_bias( model = ( self.model[_model_key] if self.multi_task else self.model ) - model = model_change_out_bias( - model, + sample_func = ( self.get_sample_func[_model_key] if self.multi_task - else self.get_sample_func, + else self.get_sample_func + ) + if _finetune_rule.convert_to_native_spin: + compute_native_spin_input_stats(model, sample_func) + model = model_change_out_bias( + model, + sample_func, _bias_adjust_mode="change-by-statistic" if not _finetune_rule.get_random_fitting() else "set-by-statistic", diff --git a/deepmd/pt/utils/spin_finetune.py b/deepmd/pt/utils/spin_finetune.py index 6107040eee..caffcdeb3c 100644 --- a/deepmd/pt/utils/spin_finetune.py +++ b/deepmd/pt/utils/spin_finetune.py @@ -1,6 +1,9 @@ # SPDX-License-Identifier: LGPL-3.0-or-later """Spin-free checkpoint transfer for the PyTorch and pt_expt native-spin models.""" +from collections.abc import ( + Callable, +) from functools import ( singledispatch, ) @@ -38,7 +41,8 @@ def get_native_spin_finetune_state( Type maps must already agree. The learned parts are matched independently of analytical bridging. Output biases and scales belong to the target's - subsequent statistics pass; spin-only state keeps its constructor values. + subsequent statistics pass; spin-only parameters keep their constructor + values, while target data calibrates spin-only input statistics afterward. Descriptor-specific transfer rules preserve the geometric function while leaving a nonzero gradient into the initially closed spin routes. @@ -108,6 +112,29 @@ def get_native_spin_finetune_state( return state +def compute_native_spin_input_stats( + model: torch.nn.Module, sampled_func: Callable[[], list[dict]] +) -> None: + """Calibrate newly introduced spin state after spin-free weight transfer. + + Geometric input statistics remain those of the pretrained descriptor. + Only DPA4C requires a data-dependent reference for its spin invariants. + + Parameters + ---------- + model + Native-spin target after a successful spin-free state transfer. + sampled_func + Sampled training systems containing atom types and magnetic moments. + """ + path = _learned_part_path(model) + descriptor = model.get_submodule(path.removesuffix(".")).descriptor + if isinstance(descriptor, DescrptDPA4C): + descriptor.spin.set_spin_reference( + descriptor._measure_spin_reference(sampled_func()) + ) + + @singledispatch def _expanded_spin_state( descriptor: object, diff --git a/deepmd/pt_expt/train/training.py b/deepmd/pt_expt/train/training.py index 36538b8aea..1317afce57 100644 --- a/deepmd/pt_expt/train/training.py +++ b/deepmd/pt_expt/train/training.py @@ -106,6 +106,7 @@ _move_learned_key, ) from deepmd.pt.utils.spin_finetune import ( + compute_native_spin_input_stats, get_native_spin_finetune_state, ) from deepmd.pt_expt.loss import ( @@ -2315,7 +2316,12 @@ def initialize_statistics( def update_finetune_bias( _model_key: str = model_key, _bias_mode: str = bias_mode, + _convert_to_native_spin: bool = finetune_rule.convert_to_native_spin, ) -> None: + if _convert_to_native_spin: + compute_native_spin_input_stats( + self.models[_model_key], self._sample_funcs[_model_key] + ) self.models[_model_key] = model_change_out_bias( self.models[_model_key], self._sample_funcs[_model_key], diff --git a/source/tests/pt_expt/test_finetune_native_spin.py b/source/tests/pt_expt/test_finetune_native_spin.py index db1ae78302..ce8b0a08b8 100644 --- a/source/tests/pt_expt/test_finetune_native_spin.py +++ b/source/tests/pt_expt/test_finetune_native_spin.py @@ -5,9 +5,15 @@ from copy import ( deepcopy, ) +from importlib import ( + import_module, +) from pathlib import ( Path, ) +from typing import ( + Any, +) import numpy as np import pytest @@ -20,6 +26,7 @@ _learned_part_path, ) from deepmd.pt.utils.spin_finetune import ( + compute_native_spin_input_stats, get_native_spin_finetune_state, ) from deepmd.pt_expt.entrypoints.main import ( @@ -185,6 +192,75 @@ def test_spin_free_transfer_rejects_unrelated_shape_change( get_native_spin_finetune_state(target, source) +@pytest.mark.parametrize( + "invalid, message", + [ + ("source_without_learned_part", "one learned atomic model"), + ("target_without_learned_part", "one learned atomic model"), + ("type_map", "aligned type maps"), + ("descriptor_family", "same descriptor family"), + ("native_source", "spin-free source descriptor"), + ("spin_free_target", "native-spin target descriptor"), + ], +) +def test_spin_free_transfer_rejects_incompatible_models( + invalid: str, message: str +) -> None: + source = _build("pt_expt", _config("dpa4", invalid == "native_source")) + target_config = _config( + "dpa4c" if invalid == "descriptor_family" else "dpa4", + invalid != "spin_free_target", + ) + if invalid == "type_map": + target_config["type_map"] = ["C", "Fe"] + target = _build("pt_expt", target_config) + if invalid.endswith("without_learned_part"): + analytical = torch.nn.Module() + analytical.add_module("atomic_model", torch.nn.Module()) + if invalid.startswith("source"): + source = analytical + else: + target = analytical + with pytest.raises(ValueError, match=message): + get_native_spin_finetune_state(target, source) + + +@pytest.mark.parametrize("magnitude", [0.0, 2.0]) +def test_native_spin_statistics_preserve_pretrained_geometry(magnitude: float) -> None: + source = _pretrained("pt_expt", _config("dpa4c", False)) + target = _build("pt_expt", _config("dpa4c", True)) + target.load_state_dict(get_native_spin_finetune_state(target, source)) + inputs = { + "coord": torch.tensor( + [[[0.0, 0.0, 0.0], [1.3, 0.0, 0.0]]], dtype=torch.float64, device=DEVICE + ), + "atype": torch.zeros((1, 2), dtype=torch.int64, device=DEVICE), + "spin": torch.tensor( + [[[0.0, 0.0, magnitude], [0.0, 0.0, magnitude]]], + dtype=torch.float64, + device=DEVICE, + ), + } + descriptor = _learned(target).descriptor + initial = {key: value.clone() for key, value in descriptor.state_dict().items()} + # Equivalent environments need no recalibration of pretrained geometry. + compute_native_spin_input_stats(target, lambda: [inputs]) + torch.testing.assert_close( + descriptor.spin.spin_reference, + torch.tensor([magnitude or 1.0, 1.0, 1.0], dtype=torch.float64, device=DEVICE), + rtol=0, + atol=0, + ) + for key, value in descriptor.state_dict().items(): + if key != "spin.spin_reference": + torch.testing.assert_close(value, initial[key], rtol=0, atol=0) + actual = target(**inputs) + expected = source(coord=inputs["coord"], atype=inputs["atype"]) + for key in ("energy", "force", "virial"): + torch.testing.assert_close(actual[key], expected[key], rtol=1e-11, atol=1e-11) + assert torch.count_nonzero(actual["force_mag"]) == 0 + + @pytest.mark.parametrize("resnet_dt", [False, True]) @pytest.mark.parametrize("residual_side", ["source", "target"]) @pytest.mark.parametrize("width_multiple", [1, 2]) @@ -345,6 +421,44 @@ def test_spin_free_native_finetune_cli( multitask: bool, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + _run_spin_free_native_finetune_cli( + backend, + descriptor, + fitting_type, + bridged, + multitask, + "transfer", + tmp_path, + monkeypatch, + ) + + +@pytest.mark.parametrize("backend, descriptor", [("pt", "dpa4"), ("pt_expt", "dpa4c")]) +@pytest.mark.parametrize("mode", ["random", "pretrain_script"]) +@pytest.mark.parametrize("multitask", [False, True]) +def test_spin_free_native_finetune_cli_options( + backend: str, + descriptor: str, + mode: str, + multitask: bool, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _run_spin_free_native_finetune_cli( + backend, descriptor, "dpa4_ener", False, multitask, mode, tmp_path, monkeypatch + ) + + +def _run_spin_free_native_finetune_cli( + backend: str, + descriptor: str, + fitting_type: str, + bridged: bool, + multitask: bool, + mode: str, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: source_config = _config(descriptor, False) source_config["fitting_net"]["type"] = fitting_type @@ -389,9 +503,23 @@ def test_spin_free_native_finetune_cli( target_config = _config(descriptor, True, bridged) target_config["fitting_net"]["type"] = fitting_type + options = [] + if mode == "random": + initial_fitting = dict( + _learned(_build(backend, target_config)).fitting_net.named_parameters() + ) + if multitask: + target_config["finetune_head"] = "RANDOM" + else: + options = ["--model-branch", "RANDOM"] + elif mode == "pretrain_script": + target_config["descriptor"]["channels"] *= 2 + options = ["--use-pretrain-script"] if bridged: target_config["preset_out_bias"] = {"energy": {"Fe": 1.25, "C": -0.75}} - config = _make_config(str(system.parent), target_config, numb_steps=2) + # A zero-step run exposes RANDOM initialization before optimization. + steps = 0 if mode == "random" else 2 + config = _make_config(str(system.parent), target_config, numb_steps=steps) config["loss"] = { "type": "ener_spin", "start_pref_e": 1.0, @@ -415,6 +543,17 @@ def test_spin_free_native_finetune_cli( input_path.write_text(json.dumps(config)) monkeypatch.chdir(tmp_path) entrypoint = pt_main if backend == "pt" else main + trainers = [] + if mode == "random": + entrypoints = import_module(f"deepmd.{backend}.entrypoints.main") + get_trainer = entrypoints.get_trainer + + def capture_trainer(*args: Any, **kwargs: Any) -> Any: + trainer = get_trainer(*args, **kwargs) + trainers.append(trainer) + return trainer + + monkeypatch.setattr(entrypoints, "get_trainer", capture_trainer) entrypoint( [ "train", @@ -422,8 +561,17 @@ def test_spin_free_native_finetune_cli( "--finetune", str(checkpoint), "--skip-neighbor-stat", + *options, ] ) + if mode == "random": + assert len(trainers) == 1 + model = trainers[0].model + trained = model["magnetic"] if multitask else model + for key, value in _learned(trained).fitting_net.named_parameters(): + torch.testing.assert_close(value, initial_fitting[key], rtol=0, atol=0) + assert torch.count_nonzero(trained(**inputs, spin=spin)["force_mag"]) == 0 + return saved = torch.load( tmp_path / "model.ckpt.pt", map_location=DEVICE, weights_only=True ) @@ -431,9 +579,13 @@ def test_spin_free_native_finetune_cli( model_params = params["model_dict"]["magnetic"] if multitask else params assert model_params["spin"]["scheme"] == "native" assert model_params["spin"]["use_spin"] == [True, False] + assert ( + model_params["descriptor"]["channels"] + == source_config["descriptor"]["channels"] + ) # PT checkpoints record the zero-based step; pt_expt records the step count. assert saved["model"]["_extra_state"]["train_infos"]["step"] == ( - 1 if backend == "pt" else 2 + max(steps - 1, 0) if backend == "pt" else steps ) assert "source_only" not in saved["optimizer"] assert "source_only" not in saved["ema"] @@ -442,6 +594,10 @@ def test_spin_free_native_finetune_cli( {"magnetic": trained} if multitask else trained, model_params=params ) loaded.load_state_dict(saved["model"]) + if descriptor == "dpa4c": + reference = _learned(trained).descriptor.spin.spin_reference + expected_reference = spin[:, [0, 2]].square().sum(dim=-1).mean().sqrt() + torch.testing.assert_close(reference[0], expected_reference, rtol=1e-12, atol=0) bias = trained.get_out_bias().reshape(-1) if bridged: torch.testing.assert_close( From 09ddcb0bd566c9864198d2e9587e6aee6d3d0e50 Mon Sep 17 00:00:00 2001 From: OutisLi Date: Mon, 26 Oct 2026 23:14:56 +0800 Subject: [PATCH 3/4] fix(finetune): inherit widths from composed learned models --- deepmd/utils/finetune.py | 30 +++++--- .../pt_expt/test_finetune_native_spin.py | 71 +++++++++++++++++-- 2 files changed, 86 insertions(+), 15 deletions(-) diff --git a/deepmd/utils/finetune.py b/deepmd/utils/finetune.py index d868b82a82..945805c592 100644 --- a/deepmd/utils/finetune.py +++ b/deepmd/utils/finetune.py @@ -250,6 +250,15 @@ def _validate_native_spin_finetune( return False +def _learned_model_config( + model: Mapping[str, Any], +) -> Mapping[str, Any] | None: + """Find the unique descriptor-bearing part of a plain or composed model.""" + candidates = model["models"] if model.get("type") == "linear_ener" else (model,) + learned = [candidate for candidate in candidates if "descriptor" in candidate] + return learned[0] if len(learned) == 1 else None + + def _inherit_native_spin_fitting_widths( target: dict[str, Any], pretrained: Mapping[str, Any] ) -> None: @@ -259,15 +268,19 @@ def _inherit_native_spin_fitting_widths( fitting_args_plugin, ) + target_learned = _learned_model_config(target) + source_learned = _learned_model_config(pretrained) + if target_learned is None or source_learned is None: + return descriptor_schema = descrpt_args_plugin.get_argument("dpa4c") fitting_schema = fitting_args_plugin.get_argument("dpa4_ener") descriptor_types = {descriptor_schema.name, *descriptor_schema.alias} glu_types = {fitting_schema.name, *fitting_schema.alias} - fitting = target.get("fitting_net", {}) - source_fitting = pretrained.get("fitting_net", {}) + fitting = target_learned.get("fitting_net", {}) + source_fitting = source_learned.get("fitting_net", {}) if ( - target.get("descriptor", {}).get("type") not in descriptor_types - or pretrained.get("descriptor", {}).get("type") not in descriptor_types + target_learned["descriptor"].get("type") not in descriptor_types + or source_learned["descriptor"].get("type") not in descriptor_types or fitting.get("type") not in glu_types or source_fitting.get("type") not in glu_types ): @@ -292,11 +305,12 @@ def _inherit_native_spin_fitting_widths( _resolve_auto_neuron, ) - descriptor_params = descriptor_schema.normalize_value(pretrained["descriptor"]) - descriptor_params.pop("type") - descriptor_params.update( - ntypes=len(pretrained["type_map"]), type_map=list(pretrained["type_map"]) + descriptor_params = descriptor_schema.normalize_value( + source_learned["descriptor"] ) + descriptor_params.pop("type") + type_map = source_learned.get("type_map", pretrained["type_map"]) + descriptor_params.update(ntypes=len(type_map), type_map=list(type_map)) descriptor = DescrptDPA4C(**descriptor_params) source_widths = _resolve_auto_neuron( source_widths, diff --git a/source/tests/pt_expt/test_finetune_native_spin.py b/source/tests/pt_expt/test_finetune_native_spin.py index 5382fab634..20d145c700 100644 --- a/source/tests/pt_expt/test_finetune_native_spin.py +++ b/source/tests/pt_expt/test_finetune_native_spin.py @@ -44,6 +44,9 @@ from deepmd.utils.argcheck import ( normalize, ) +from deepmd.utils.bridging import ( + expand_bridging_method, +) from deepmd.utils.compat import ( update_deepmd_input, ) @@ -72,6 +75,14 @@ def _config(descriptor: str, native: bool, bridged: bool = False) -> dict: return config +def _canonical_config(config: dict) -> dict: + config = expand_bridging_method(config) + config["models"].reverse() + for child in config["models"]: + child.pop("type_map", None) + return config + + def _build(backend: str, config: dict) -> torch.nn.Module: builder = get_pt_model if backend == "pt" else get_model return builder(deepcopy(config)).to(DEVICE) @@ -290,12 +301,27 @@ def test_fresh_dpa4c_spin_statistics_keep_closed_gate_scale() -> None: ) -@pytest.mark.parametrize("fitting_type", ["dpa4_ener", "sezm_ener"]) -@pytest.mark.parametrize("neuron", ["omitted", None, [0], [0, 8], [8, 0]]) +@pytest.mark.parametrize( + "fitting_type, neuron, source_form, target_form", + [ + (fitting_type, neuron, "plain", "plain") + for fitting_type in ("dpa4_ener", "sezm_ener") + for neuron in ("omitted", None, [0], [0, 8], [8, 0]) + ] + + [ + ("dpa4_ener", "omitted", "concise", "canonical"), + ("sezm_ener", None, "concise", "canonical"), + ("sezm_ener", [0], "canonical", "concise"), + ("dpa4_ener", [0, 8], "canonical", "canonical"), + ], +) def test_dpa4c_auto_width_inherits_source_architecture( - fitting_type: str, neuron: str | list[int] | None + fitting_type: str, + neuron: str | list[int] | None, + source_form: str, + target_form: str, ) -> None: - source_config = _config("dpa4c", False) + source_config = _config("dpa4c", False, bridged=source_form != "plain") source_config["fitting_net"]["type"] = fitting_type if neuron == "omitted": source_config["fitting_net"].pop("neuron") @@ -303,13 +329,22 @@ def test_dpa4c_auto_width_inherits_source_architecture( source_config["fitting_net"]["neuron"] = neuron target_config = deepcopy(source_config) target_config["spin"] = {"scheme": "native", "use_spin": [True, False]} + if source_form == "canonical": + source_config = _canonical_config(source_config) + if target_form == "canonical": + target_config = _canonical_config(target_config) + original_source = deepcopy(source_config) original = deepcopy(target_config) source = _pretrained("pt_expt", source_config) config, _ = FinetuneRuleBuilder( source_config, target_config, change_model_params=False ).build() - assert config["fitting_net"]["neuron"] == _learned(source).fitting_net.neuron + learned_config = config["models"][-1] if target_form == "canonical" else config + assert ( + learned_config["fitting_net"]["neuron"] == _learned(source).fitting_net.neuron + ) assert target_config == original + assert source_config == original_source target = _build("pt_expt", config) target.load_state_dict(get_native_spin_finetune_state(target, source)) inputs = _inputs() @@ -563,6 +598,23 @@ def test_dpa4c_auto_width_finetune_cli( ) +def test_dpa4c_canonical_auto_width_finetune_cli( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _run_spin_free_native_finetune_cli( + "pt_expt", + "dpa4c", + "dpa4_ener", + True, + False, + "transfer", + tmp_path, + monkeypatch, + auto_width=True, + canonical=True, + ) + + def _run_spin_free_native_finetune_cli( backend: str, descriptor: str, @@ -574,6 +626,7 @@ def _run_spin_free_native_finetune_cli( monkeypatch: pytest.MonkeyPatch, *, auto_width: bool = False, + canonical: bool = False, ) -> None: source_config = _config(descriptor, False) source_config["fitting_net"]["type"] = fitting_type @@ -636,6 +689,8 @@ def _run_spin_free_native_finetune_cli( options = ["--use-pretrain-script"] if bridged: target_config["preset_out_bias"] = {"energy": {"Fe": 1.25, "C": -0.75}} + if canonical: + target_config = _canonical_config(target_config) # A zero-step run exposes RANDOM initialization before optimization. steps = 0 if mode == "random" else 2 config = _make_config(str(system.parent), target_config, numb_steps=steps) @@ -696,14 +751,16 @@ def capture_trainer(*args: Any, **kwargs: Any) -> Any: ) params = saved["model"]["_extra_state"]["model_params"] model_params = params["model_dict"]["magnetic"] if multitask else params + learned_params = model_params["models"][-1] if canonical else model_params if auto_width: assert ( - model_params["fitting_net"]["neuron"] == _learned(source).fitting_net.neuron + learned_params["fitting_net"]["neuron"] + == _learned(source).fitting_net.neuron ) assert model_params["spin"]["scheme"] == "native" assert model_params["spin"]["use_spin"] == [True, False] assert ( - model_params["descriptor"]["channels"] + learned_params["descriptor"]["channels"] == source_config["descriptor"]["channels"] ) # PT checkpoints record the zero-based step; pt_expt records the step count. From 8136bdbe287e480c17c091406982639ced03579d Mon Sep 17 00:00:00 2001 From: OutisLi Date: Tue, 27 Oct 2026 15:31:49 +0800 Subject: [PATCH 4/4] fix(finetune): require complete fitting network transfer --- deepmd/pt/utils/spin_finetune.py | 37 +++++++- .../pt_expt/test_finetune_native_spin.py | 89 +++++++++++++++++-- 2 files changed, 116 insertions(+), 10 deletions(-) diff --git a/deepmd/pt/utils/spin_finetune.py b/deepmd/pt/utils/spin_finetune.py index caffcdeb3c..064eed85df 100644 --- a/deepmd/pt/utils/spin_finetune.py +++ b/deepmd/pt/utils/spin_finetune.py @@ -63,9 +63,9 @@ def get_native_spin_finetune_state( Raises ------ ValueError - If the learned parts, descriptor families, type maps or tensor shapes - are incompatible with spin-free transfer, or widening the fitting input - changes an implicit residual connection. + If the learned parts, descriptor or fitting families, type maps, + network layouts or tensor shapes are incompatible with spin-free transfer, + or widening the fitting input changes an implicit residual connection. """ path = _learned_part_path(model) source_path = _learned_part_path(pretrained_model) @@ -93,6 +93,8 @@ def get_native_spin_finetune_state( expanded = _expanded_spin_state( descriptor, source, learned, random_fitting=random_fitting ) + if not random_fitting: + _validate_fitting_networks(source.fitting_net, learned.fitting_net) state = model.state_dict() for key, value in source.state_dict().items(): target_key = path + key @@ -282,6 +284,35 @@ def _expanded_fitting_input_state( } +def _validate_fitting_networks( + source: torch.nn.Module, target: torch.nn.Module +) -> None: + """Require complete fitting-network transfer, including frozen weights.""" + if type(source) is not type(target): + raise ValueError( + "Spin-free transfer requires matching fitting network types: " + f"source={type(source).__name__}, target={type(target).__name__}." + ) + # PT and pt_expt register their networks under these respective paths. + # Root buffers hold output biases, statistics and runtime defaults, not + # the learned network layout. Frozen network weights remain in state_dict. + source_keys, target_keys = ( + { + key + for key in fitting.state_dict() + if key.startswith(("filter_layers.", "nets.")) + } + for fitting in (source, target) + ) + if source_keys != target_keys: + raise ValueError( + "Spin-free transfer requires matching fitting network state keys: " + f"source-only={sorted(source_keys - target_keys)}, " + f"target-only={sorted(target_keys - source_keys)}. " + "Use a compatible fitting architecture or select finetune_head='RANDOM'." + ) + + def _check_shape(key: str, expected: tuple[int, ...], actual: tuple[int, ...]) -> None: if actual != expected: raise ValueError( diff --git a/source/tests/pt_expt/test_finetune_native_spin.py b/source/tests/pt_expt/test_finetune_native_spin.py index 20d145c700..88b453f36a 100644 --- a/source/tests/pt_expt/test_finetune_native_spin.py +++ b/source/tests/pt_expt/test_finetune_native_spin.py @@ -466,13 +466,21 @@ def test_dpa4c_transfer_fitting_input_projection( @pytest.mark.parametrize( - "source_type, target_type", [("ener", "dpa4_ener"), ("dpa4_ener", "ener")] + "backend, descriptor, source_type, target_type", + [ + ("pt", "dpa4", "property", "dpa4_ener"), + ("pt_expt", "dpa4", "ener", "dpa4_ener"), + ("pt_expt", "dpa4c", "ener", "dpa4_ener"), + ("pt_expt", "dpa4c", "dpa4_ener", "ener"), + ], ) -def test_dpa4c_transfer_rejects_changed_fitting_family( - source_type: str, target_type: str +def test_spin_free_transfer_rejects_changed_fitting_family( + backend: str, descriptor: str, source_type: str, target_type: str ) -> None: - source_config = _config("dpa4c", False) - target_config = _config("dpa4c", True) + source_config = _config(descriptor, False) + target_config = _config(descriptor, True) + if backend == "pt_expt": + source_config["type"] = target_config["type"] = "standard" for config, fitting_type in ( (source_config, source_type), (target_config, target_type), @@ -480,10 +488,77 @@ def test_dpa4c_transfer_rejects_changed_fitting_family( config["fitting_net"].update( type=fitting_type, neuron=[16 if fitting_type == "ener" else 8] ) - source = _build("pt_expt", source_config) - target = _build("pt_expt", target_config) + if fitting_type == "property": + config["fitting_net"].update(task_dim=1, property_name="energy") + source = _build(backend, source_config) + target = _build(backend, target_config) + initial = {key: value.clone() for key, value in target.state_dict().items()} with pytest.raises(ValueError, match="matching fitting network types"): get_native_spin_finetune_state(target, source) + for key, value in target.state_dict().items(): + torch.testing.assert_close(value, initial[key], rtol=0, atol=0) + target.load_state_dict( + get_native_spin_finetune_state(target, source, random_fitting=True) + ) + for key, value in target.state_dict().items(): + if ".fitting_net." in key: + torch.testing.assert_close(value, initial[key], rtol=0, atol=0) + + +@pytest.mark.parametrize( + "backend, descriptor, source_neuron, target_neuron, trainable", + [ + ("pt", "dpa4", [8], [8, 8], True), + ("pt", "dpa4", [8, 8], [8], True), + ("pt_expt", "dpa4", [8], [8, 8], True), + ("pt_expt", "dpa4c", [8], [8, 8], True), + ("pt_expt", "dpa4c", [8, 8], [8], True), + ("pt_expt", "dpa4c", [8], [8, 8], False), + ], +) +def test_spin_free_transfer_rejects_changed_fitting_layers( + backend: str, + descriptor: str, + source_neuron: list[int], + target_neuron: list[int], + trainable: bool, +) -> None: + source_config = _config(descriptor, False) + target_config = _config(descriptor, True) + for config, neuron in ( + (source_config, source_neuron), + (target_config, target_neuron), + ): + config["fitting_net"].update( + type="dpa4_ener", neuron=neuron, trainable=trainable + ) + source = _build(backend, source_config) + target = _build(backend, target_config) + initial = {key: value.clone() for key, value in target.state_dict().items()} + with pytest.raises(ValueError, match="matching fitting network state keys"): + get_native_spin_finetune_state(target, source) + for key, value in target.state_dict().items(): + torch.testing.assert_close(value, initial[key], rtol=0, atol=0) + target.load_state_dict( + get_native_spin_finetune_state(target, source, random_fitting=True) + ) + for key, value in target.state_dict().items(): + if ".fitting_net." in key: + torch.testing.assert_close(value, initial[key], rtol=0, atol=0) + + +def test_spin_free_transfer_preserves_target_default_fparam() -> None: + source_config = _config("dpa4", False) + source_config["fitting_net"]["numb_fparam"] = 1 + target_config = deepcopy(source_config) + target_config["spin"] = {"scheme": "native", "use_spin": [True, False]} + target_config["fitting_net"]["default_fparam"] = [0.25] + source = _build("pt", source_config) + target = _build("pt", target_config) + fitting = _learned(target).fitting_net + expected = fitting.default_fparam_tensor.clone() + target.load_state_dict(get_native_spin_finetune_state(target, source)) + torch.testing.assert_close(fitting.default_fparam_tensor, expected, rtol=0, atol=0) @pytest.mark.parametrize("fitting_type", ["ener", "dpa4_ener"])