Source code for nvalchemi.training.finetune

# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Fine-tuning strategy conveniences built on :class:`TrainingStrategy`."""

from __future__ import annotations

from collections.abc import Mapping, Sequence
from pathlib import Path
from typing import Annotated, Any

import torch
from pydantic import Field, model_validator

from nvalchemi.training import _spec_utils as strategy_spec
from nvalchemi.training import _strategy_validation as strategy_validation
from nvalchemi.training._spec import BaseSpec, create_model_spec_from_json
from nvalchemi.training.hooks.finetune import (
    FreezeMode,
    ModulePatchHook,
    TrainableParameterHook,
)
from nvalchemi.training.strategy import TrainingStrategy

__all__ = ["FineTuningStrategy"]

_DEFAULT_PRETRAINED_CHECKPOINT_LR = 1e-5


def _apply_checkpoint_finetuning_defaults(
    strategy_kwargs: dict[str, Any],
    source_metadata: Mapping[str, Any] | None,
    *,
    use_original_loss: bool,
    use_original_opt_class: bool,
    optimizer_lr: float | None,
) -> None:
    """Fill omitted fine-tuning config from checkpoint strategy metadata.

    This helper intentionally stays outside ``FineTuningStrategy`` because it
    adapts checkpoint metadata into constructor kwargs; it is not a strategy
    behavior users should call directly. Keep source reuse explicit and
    initialization-only: rebuild serializable loss or optimizer config when the
    caller opts in, but never restore optimizer state, scheduler state, hooks,
    counters, or epoch/step limits.

    Future source-reuse options should be added here rather than expanding the
    public strategy surface with helper methods.
    """
    needs_loss = use_original_loss and "loss_fn" not in strategy_kwargs
    needs_optimizer = (
        use_original_opt_class and "optimizer_configs" not in strategy_kwargs
    )
    if not needs_loss and not needs_optimizer:
        return
    if source_metadata is None:
        requested = []
        if needs_loss:
            requested.append("loss_fn")
        if needs_optimizer:
            requested.append("optimizer_configs")
        raise ValueError(
            "Cannot reuse original "
            f"{', '.join(requested)} because checkpoint has no strategy metadata."
        )

    # re-use the original loss target if requested, and it exists
    if needs_loss:
        loss_spec = source_metadata.get("loss_fn_spec")
        if loss_spec is None:
            raise ValueError(
                "Cannot reuse original loss_fn because checkpoint metadata "
                "does not contain loss_fn_spec."
            )
        strategy_kwargs["loss_fn"] = strategy_spec._loss_fn_from_spec(loss_spec)

    # when the user requests to re-use the original optimizer, we
    # reconstruct it but use the fine-tuning LR instead of the base
    if needs_optimizer:
        raw_configs = source_metadata.get("optimizer_configs")
        if raw_configs is None:
            raise ValueError(
                "Cannot reuse original optimizer_configs because checkpoint "
                "metadata does not contain optimizer_configs."
            )
        optimizer_configs = strategy_spec._optimizer_configs_from_spec(raw_configs)
        if optimizer_lr is not None:
            for configs in optimizer_configs.values():
                for config in configs:
                    config.optimizer_kwargs = {
                        **config.optimizer_kwargs,
                        "lr": optimizer_lr,
                    }
        strategy_kwargs["optimizer_configs"] = optimizer_configs


[docs] class FineTuningStrategy(TrainingStrategy): """Training strategy for patching modules and selecting trainable parameters. ``FineTuningStrategy`` is intended for workflows where a pretrained model is loaded first and then adapted in-place before optimizer construction. The strategy keeps the base :class:`TrainingStrategy` loop, but prepends registration-time hooks derived from its convenience fields before any explicit ``hooks=`` supplied by the user: * ``module_patches`` becomes a :class:`ModulePatchHook`. * ``freeze_patterns`` / ``trainable_patterns`` become a :class:`TrainableParameterHook`. Module patch targets are fully-qualified paths of the form ``"<model_key>.<module_path>.<child>"``, for example ``"main.model.readouts.1.linear"``. The parent path must already exist. The final child is replaced when it is an existing ``torch.nn.Module`` or added when missing. Use :func:`nvalchemi.training.create_model_spec` for module patches that must round-trip through :meth:`to_spec_dict`; direct ``torch.nn.Module`` instances are supported at runtime but are rejected by serialization. Parameter patterns are matched against fully-qualified names such as ``"main.model.readouts.1.linear.weight"``. ``trainable_patterns`` alone is an allow-list: only matching parameters remain trainable and enter optimizers. When ``freeze_patterns`` is also supplied, matching parameters are excluded first, then ``trainable_patterns`` are re-included. With the default ``freeze_mode="requires_grad"``, excluded parameters are temporarily marked ``requires_grad=False`` during :meth:`run` and restored afterward. Use ``freeze_mode="optimizer_only"`` when excluded parameters should still receive gradients but must not be updated by optimizers. Examples -------- Replace a readout head, train only that head, and serialize the workflow by declaring the replacement as a :class:`BaseSpec`:: import torch from nvalchemi.training import ( EnergyMSELoss, FineTuningStrategy, ForceMSELoss, OptimizerConfig, create_model_spec, default_training_fn, ) strategy = FineTuningStrategy( models=pretrained_model, module_patches={ "main.model.readouts.1.linear": create_model_spec( torch.nn.Linear, in_features=128, out_features=1, ) }, trainable_patterns=("main.model.readouts.1.linear.*",), freeze_mode="requires_grad", optimizer_configs=OptimizerConfig( optimizer_cls=torch.optim.AdamW, optimizer_kwargs={"lr": 1e-4}, ), training_fn=default_training_fn, loss_fn=EnergyMSELoss() + ForceMSELoss(normalize_by_atom_count=True), num_epochs=10, devices=[torch.device("cuda")], ) strategy.run(train_loader) Use optimizer-only filtering when excluded parameters should still receive gradients but must not be updated:: strategy = FineTuningStrategy( models=pretrained_model, freeze_patterns=("main.model.*",), trainable_patterns=("main.model.readouts.*",), freeze_mode="optimizer_only", optimizer_configs=optimizer_config, training_fn=default_training_fn, loss_fn=loss_fn, num_steps=1000, ) """ module_patches: dict[str, BaseSpec | torch.nn.Module] = Field( default_factory=dict, description="Ordered module patches applied before optimizer construction.", ) freeze_patterns: Annotated[ tuple[str, ...], Field( description=( "Glob patterns excluded from training. Exclusions can be " "re-included by ``trainable_patterns``." ) ), ] = () trainable_patterns: Annotated[ tuple[str, ...], Field( description=( "Glob patterns included in the trainable parameter allow-list. " "When no ``freeze_patterns`` are supplied, this is the complete " "allow-list." ) ), ] = () freeze_mode: Annotated[ FreezeMode, Field( description=( "Whether excluded parameters are temporarily frozen via " "``requires_grad=False`` or only excluded from optimizers. " 'Defaults to ``"requires_grad"``.' ) ), ] = "requires_grad" @model_validator(mode="before") @classmethod def _prepend_finetuning_hooks(cls, data: Any) -> Any: """Convert convenience fields into registration-time hooks.""" if not isinstance(data, dict): return data normalized = dict(data) generated: list[Any] = [] module_patches = normalized.get("module_patches") or {} if module_patches: generated.append(ModulePatchHook(patches=module_patches)) freeze_patterns = tuple(normalized.get("freeze_patterns") or ()) trainable_patterns = tuple(normalized.get("trainable_patterns") or ()) if freeze_patterns or trainable_patterns: generated.append( TrainableParameterHook( freeze_patterns=freeze_patterns, trainable_patterns=trainable_patterns, freeze_mode=normalized.get("freeze_mode", "requires_grad"), ) ) if generated: normalized["hooks"] = [*generated, *list(normalized.get("hooks") or [])] return normalized
[docs] @classmethod def from_pretrained_checkpoint( cls, checkpoint_dir: Path | str, *, checkpoint_index: int = -1, map_location: str | torch.device | None = None, validators: Sequence[Any] | None = None, use_original_loss: bool = False, use_original_opt_class: bool = False, optimizer_lr: float | None = _DEFAULT_PRETRAINED_CHECKPOINT_LR, **strategy_kwargs: Any, ) -> FineTuningStrategy: """Start a new fine-tuning run from checkpointed model weights. This alternate constructor initializes a fresh :class:`FineTuningStrategy` from a model stored in a native nvalchemi checkpoint. It is intentionally different from :meth:`load_checkpoint`, which resumes an interrupted fine-tuning strategy by restoring the saved optimizer, scheduler, counters, hooks, and strategy configuration. ``from_pretrained_checkpoint`` loads the complete checkpoint model set as initialization. Single-model checkpoints are passed to the strategy as a single model; multi-model checkpoints are passed as a named model mapping. Source optimizer state, scheduler state, hooks, epoch/step limits, and runtime counters are not inherited. The new fine-tuning strategy starts with reset counters and applies any ``module_patches`` or trainable-parameter filters before optimizer construction. By default, callers provide a new ``loss_fn`` and ``optimizer_configs``. Set ``use_original_loss=True`` or ``use_original_opt_class=True`` to fill either value from the source checkpoint metadata when the caller omits it. Reused optimizer configs keep the original optimizer and scheduler classes, but their optimizer ``lr`` is overwritten with ``optimizer_lr`` unless ``optimizer_lr=None`` is passed. Parameters ---------- checkpoint_dir : Path | str Root directory containing a checkpoint written by :meth:`TrainingStrategy.save_checkpoint` or :class:`~nvalchemi.training.hooks.CheckpointHook`. checkpoint_index : int, optional Checkpoint index to read. ``-1`` loads the latest index recorded in the checkpoint manifest. map_location : str | torch.device | None, optional Device override forwarded to checkpoint loading. validators : Sequence[Any] | None, optional Optional checkpoint validators forwarded to the lower-level loader. use_original_loss : bool, optional If ``True`` and ``loss_fn`` is not supplied, rebuild the loss from the source strategy checkpoint metadata. use_original_opt_class : bool, optional If ``True`` and ``optimizer_configs`` is not supplied, rebuild the optimizer/scheduler configs from source checkpoint metadata. optimizer_lr : float | None, optional Learning rate written into reused optimizer configs. Defaults to ``1e-5`` for conservative fine-tuning. Pass ``None`` to preserve the checkpoint's serialized optimizer learning rates. **strategy_kwargs : Any Normal :class:`FineTuningStrategy` constructor arguments except ``models``. The loaded checkpoint model is supplied as ``models``. Returns ------- FineTuningStrategy A new fine-tuning strategy initialized from checkpointed model weights. Raises ------ ValueError If ``models`` is supplied, if no checkpoint models are loaded, or if requested source loss/optimizer metadata is unavailable. Notes ----- Use :meth:`load_checkpoint` instead when the goal is to resume the same fine-tuning run with its saved optimizer state, scheduler state, hooks, counters, and training limits. Source loss and optimizer config reuse here is initialization-only and never restores optimizer state. """ if "models" in strategy_kwargs: raise ValueError( "FineTuningStrategy.from_pretrained_checkpoint loads models " "from checkpoint_dir; pass fine-tuning configuration through " "other keyword arguments." ) from nvalchemi.training._checkpoint import CheckpointManifest, load_checkpoint # Read the manifest first so we can request every checkpointed model # without constructing the saved strategy or inheriting its runtime config. manifest = CheckpointManifest.read(Path(checkpoint_dir)) available_models = sorted(manifest.models) if not available_models: raise ValueError(f"Checkpoint {checkpoint_dir!s} does not contain models.") loaded = load_checkpoint( checkpoint_dir, checkpoint_index=checkpoint_index, map_location=map_location, model_names=set(available_models), validators=validators, ) # Native component-only checkpoints return a manifest; strategy # checkpoints return a dict. Normalize both into a plain model mapping. if not isinstance(loaded, dict): loaded_models = { name: pair[0] for name in available_models if (pair := loaded.models.get(name)) is not None } else: loaded_models = { name: entry["model"] for name, entry in loaded.get("models", {}).items() } missing_models = set(available_models) - set(loaded_models) if missing_models: raise ValueError( f"Checkpoint did not load model(s) {sorted(missing_models)!r}. " f"Available models: {available_models!r}." ) source_metadata = ( loaded.get("strategy_metadata") if isinstance(loaded, dict) else None ) _apply_checkpoint_finetuning_defaults( strategy_kwargs, source_metadata, use_original_loss=use_original_loss, use_original_opt_class=use_original_opt_class, optimizer_lr=optimizer_lr, ) # Preserve the familiar single-model constructor UX, but keep named # mappings intact when the checkpoint contains multiple models. models = ( next(iter(loaded_models.values())) if len(loaded_models) == 1 else loaded_models ) return cls(models=models, **strategy_kwargs)
def to_spec_dict(self) -> dict[str, Any]: """Serialize declarative fine-tuning knobs to a JSON-ready dict. Returns ------- dict[str, Any] JSON-ready bundle suitable for :func:`json.dumps`. Raises ------ TypeError If ``module_patches`` contains direct ``torch.nn.Module`` values. Use :func:`nvalchemi.training.create_model_spec` for serializable module patches. """ spec = super().to_spec_dict() if self.module_patches: patch_specs: dict[str, dict[str, Any]] = {} for target, value in self.module_patches.items(): if not isinstance(value, BaseSpec): raise TypeError( "FineTuningStrategy.to_spec_dict only supports " "module_patches declared as BaseSpec values; " f"{target!r} is {type(value).__name__}." ) patch_specs[target] = value.model_dump() spec["module_patches"] = patch_specs spec["freeze_patterns"] = list(self.freeze_patterns) spec["trainable_patterns"] = list(self.trainable_patterns) spec["freeze_mode"] = self.freeze_mode return spec @classmethod def from_spec_dict( cls, spec: dict[str, Any], *, models: strategy_validation.ModelInput | None = None, hooks: list[Any] | None = None, training_fn: Any = None, ) -> FineTuningStrategy: """Rebuild a :class:`FineTuningStrategy` from ``to_spec_dict`` output. Parameters ---------- spec : dict[str, Any] A dict produced by :meth:`to_spec_dict`, optionally after a JSON round-trip. models : BaseModelMixin | dict[str, BaseModelMixin] | None, optional Runtime model override(s). hooks : list[Any] | None, optional Runtime hooks appended after generated fine-tuning hooks. training_fn : Any, optional Runtime callable or dotted-path override. Returns ------- FineTuningStrategy A freshly validated fine-tuning strategy ready to :meth:`run`. """ required = ("optimizer_configs", "devices", "loss_fn_spec") missing = [key for key in required if key not in spec] if missing: raise ValueError( f"from_spec_dict: spec is missing required key(s) {missing}. " f"Expected keys: {list(required)}." ) module_patches = { target: create_model_spec_from_json(raw_spec) for target, raw_spec in spec.get("module_patches", {}).items() } model_input = strategy_spec._models_from_spec_and_overrides( spec.get("model_specs", {}), models, single_model_input=strategy_spec._single_model_input_from_spec( spec.get("single_model_input") ), ) return cls( models=model_input, optimizer_configs=strategy_spec._optimizer_configs_from_spec( spec["optimizer_configs"] ), num_epochs=spec.get("num_epochs"), num_steps=spec.get("num_steps"), epoch_step_modifier=spec.get("epoch_step_modifier", 1.0), hooks=list(hooks) if hooks is not None else [], training_fn=strategy_spec._training_fn_from_spec(spec, training_fn), loss_fn=strategy_spec._loss_fn_from_spec(spec["loss_fn_spec"]), devices=strategy_spec._devices_from_spec(spec["devices"]), module_patches=module_patches, freeze_patterns=tuple(spec.get("freeze_patterns", ())), trainable_patterns=tuple(spec.get("trainable_patterns", ())), freeze_mode=spec.get("freeze_mode", "requires_grad"), )