Source code for xnn.common.models.hub.checkpoint

"""Reading, writing and building models from checkpoints.

Three kinds of file hold a model's weights, and :func:`load_checkpoint` reads
all of them into the same :class:`Checkpoint`:

* a **portable model directory** (``card.json``, ``config.yaml``,
  ``model.pt``), written by :func:`save_pretrained`. The config is plain YAML
  and the weights load with ``torch.load(weights_only=True)``, so the
  directory carries no pickled Python objects and no absolute paths: copy it
  anywhere and it loads there;
* an **xnn trainer checkpoint** (``best.pt``: ``{"model": state_dict, "cfg":
  Config}``, written by :meth:`~xnn.common.train.Trainer.save`), or a bare
  state dict;
* a file in a **foreign format** registered in
  :mod:`~xnn.common.models.hub.formats` (e.g. a ``mace-torch`` foundation
  model), converted on the fly.

:func:`build_potential` turns a checkpoint into a ready model. It is the one
loading recipe behind :func:`~xnn.common.models.hub.from_pretrained`, the MDI
engine, ``xnn export`` and the benchmark runner.
"""
from __future__ import annotations

import copy
import dataclasses
import hashlib
import itertools
import logging
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional, Union

import torch
from torch import nn

from .card import CARD_FILE, CONFIG_FILE, WEIGHTS_FILE, ModelCard
from .formats import XNN_FORMAT, detect_format, get_format

logger = logging.getLogger(__name__)


[docs] @dataclass class Checkpoint: """A model's weights and the config that rebuilds it. Attributes ---------- config : Config or None The run configuration (``None`` for a bare state dict). state_dict : dict of str to torch.Tensor Weights in the :class:`~xnn.common.models.ForceStressOutput` layout (keys prefixed ``model.``), as the trainer saves them. card : ModelCard or None The card of a portable directory. path : pathlib.Path Where the checkpoint was read from. """ config: Any state_dict: dict card: Optional[ModelCard] path: Path
# config <-> YAML def _plain(value: Any, where: str) -> Any: """Convert a config value to plain YAML types (lists, dicts, scalars).""" if isinstance(value, dict): return {(k if isinstance(k, (str, int, float, bool)) else str(k)): _plain(v, f"{where}.{k}") for k, v in value.items()} if isinstance(value, (list, tuple)): return [_plain(v, f"{where}[{i}]") for i, v in enumerate(value)] if value is None or isinstance(value, (str, bool, int, float)): return value if isinstance(value, Path): return str(value) if isinstance(value, torch.dtype): return str(value).replace("torch.", "") if torch.is_tensor(value): return value.detach().cpu().tolist() if hasattr(value, "tolist"): # numpy arrays and scalars return value.tolist() if dataclasses.is_dataclass(value): return _dataclass_dict(value, where) raise TypeError(f"config value {where} = {value!r} ({type(value).__name__}) " f"cannot be written to YAML") def _dataclass_dict(obj: Any, where: str = "cfg") -> dict[str, Any]: """``dataclasses.asdict`` that tolerates instances pickled by older xnn. A field missing from the instance (added to the class after the checkpoint was written) takes its default. """ out = {} for f in dataclasses.fields(obj): if hasattr(obj, f.name): v = getattr(obj, f.name) elif f.default is not dataclasses.MISSING: v = f.default elif f.default_factory is not dataclasses.MISSING: v = f.default_factory() else: continue out[f.name] = _plain(v, f"{where}.{f.name}") return out
[docs] def config_to_dict(config: Any) -> dict[str, Any]: """Serialize a :class:`Config` (or a bare :class:`ModelConfig`) to a dict. Top-level sections and fields equal to the defaults are left out, so a converted foundation model's file holds just its ``model`` section while a trained model keeps its data and optimizer settings as provenance. Parameters ---------- config : Config or ModelConfig The configuration. Returns ------- dict Mapping accepted by :func:`xnn.common.config.from_dict`. """ from ...config import Config, ModelConfig if isinstance(config, ModelConfig): return {"model": _dataclass_dict(config, "model")} d = _dataclass_dict(config) default = _dataclass_dict(Config()) return {k: v for k, v in d.items() if k == "model" or v != default.get(k)}
[docs] def file_references(config: Any) -> list[str]: """Config values of the model section that name existing files. A config that points at a file (a ReaxFF ``ffield`` path, an OPLS or DREIDING topology JSON) loads only where that file exists, which breaks the portability of a model directory. Parameters ---------- config : Config or ModelConfig The configuration. Returns ------- list of str ``"<key>=<value>"`` for each such value. """ found = [] def walk(v, where): if isinstance(v, dict): for k, x in v.items(): walk(x, f"{where}.{k}") elif isinstance(v, list): for i, x in enumerate(v): walk(x, f"{where}[{i}]") elif isinstance(v, str) and ("/" in v or "\\" in v or "." in v): try: if Path(v).expanduser().is_file(): found.append(f"{where}={v}") except (OSError, ValueError): pass walk(config_to_dict(config)["model"], "model") return found
[docs] def write_config(config: Any, path: Union[str, Path]) -> Path: """Write a config as YAML, atomically (see :func:`config_to_dict`).""" import yaml path = Path(path) tmp = path.with_name(path.name + f".tmp{os.getpid()}") tmp.write_text(yaml.safe_dump(config_to_dict(config), sort_keys=False)) tmp.replace(path) return path
[docs] def read_config(path: Union[str, Path]): """Read a config written by :func:`write_config`.""" import yaml from ...config import from_dict return from_dict(yaml.safe_load(Path(path).read_text()) or {})
# weights
[docs] def float_dtype(source) -> torch.dtype: """Floating-point dtype of a module's parameters or of a state dict.""" # a module's parameters first, then its buffers: a standalone dispersion # model (``name: d4``) has buffers only tensors = (source.values() if isinstance(source, dict) else itertools.chain(source.parameters(), source.buffers())) for t in tensors: if torch.is_tensor(t) and t.is_floating_point(): return t.dtype return torch.get_default_dtype()
[docs] def has_dispersion(model: nn.Module) -> bool: """Whether a model already includes a D3 / D4 wrapper (at any nesting).""" from ..dispersion import DispersionCorrection from ..les import LatentEwald while True: if isinstance(model, DispersionCorrection): return True if isinstance(model, LatentEwald): model = model.model else: return False
[docs] def core_model(model: nn.Module) -> nn.Module: """The innermost potential under the force, dispersion, LES and multi-head wrappers.""" from ..registry import core_model as _core return _core(model)
[docs] def wrapped_state_dict(model: nn.Module) -> dict[str, torch.Tensor]: """State dict in the trainer's :class:`ForceStressOutput` layout, on CPU.""" from ..outputs import ForceStressOutput if not isinstance(model, ForceStressOutput): model = ForceStressOutput(model) return {k: v.detach().cpu() for k, v in model.state_dict().items()}
def _md5(path: Path, chunk: int = 1 << 20) -> str: """MD5 hex digest of a file.""" h = hashlib.md5() with open(path, "rb") as f: for block in iter(lambda: f.read(chunk), b""): h.update(block) return h.hexdigest()
[docs] def load_checkpoint(path: Union[str, Path], *, format: Optional[str] = None, head: Optional[str] = None) -> Checkpoint: """Read any supported checkpoint into a :class:`Checkpoint`. Parameters ---------- path : str or pathlib.Path A portable model directory, an xnn trainer checkpoint (or bare state dict), or a file in a registered foreign format. format : str, optional Force the format instead of detecting it from the suffix (``"xnn"``, ``"mace-torch"``). head : str, optional Head to keep when converting a multi-head foreign checkpoint. For an xnn checkpoint the heads are kept and chosen at build time (:func:`build_potential`). Returns ------- Checkpoint Config, weights and card. Raises ------ FileNotFoundError If ``path`` does not exist, or a directory lacks its config/weights. ValueError If the file is not a recognizable checkpoint. """ from ...config import Config, from_dict path = Path(path).expanduser() if not path.exists(): raise FileNotFoundError(f"no checkpoint at {path}") # a foreign format first: its detect() may claim a directory or a .pt file fmt = format or detect_format(path) if fmt != XNN_FORMAT: model, cfg = get_format(fmt).convert(path, head) if not isinstance(cfg, Config): cfg = Config(model=cfg) return Checkpoint(cfg, wrapped_state_dict(model), None, path) if path.is_dir(): missing = [f for f in (CONFIG_FILE, WEIGHTS_FILE) if not (path / f).is_file()] if missing: raise FileNotFoundError(f"{path} is not a model directory (missing {missing})") state = torch.load(path / WEIGHTS_FILE, map_location="cpu", weights_only=True) sd = state["model"] if isinstance(state.get("model"), dict) else state card = ModelCard.load(path / CARD_FILE) if (path / CARD_FILE).is_file() else None return Checkpoint(read_config(path / CONFIG_FILE), sd, card, path) # trainer checkpoints pickle their Config, hence weights_only=False state = torch.load(path, map_location="cpu", weights_only=False) if isinstance(state, nn.Module): raise ValueError( f"{path} holds a pickled {type(state).__module__}.{type(state).__name__} " f"module, not an xnn checkpoint; for an upstream MACE model pass " f"format='mace-torch'") if isinstance(state, dict) and isinstance(state.get("model"), dict): cfg = state.get("cfg") if isinstance(cfg, dict): cfg = from_dict(cfg) return Checkpoint(cfg, state["model"], None, path) if isinstance(state, dict) and state and all(torch.is_tensor(v) for v in state.values()): return Checkpoint(None, state, None, path) raise ValueError(f"{path} is not a recognizable xnn checkpoint")
[docs] def save_pretrained(model: Union[nn.Module, str, Path], save_directory: Union[str, Path], *, config: Any = None, card: Union[ModelCard, dict, None] = None, verify: bool = True, **card_fields: Any) -> Path: """Write a model as a portable directory. The directory holds ``config.yaml`` (the configuration that rebuilds the architecture), ``model.pt`` (the weights, loadable with ``weights_only=True``) and ``card.json`` (the :class:`ModelCard`, with an MD5 per file). The card is written last, so a directory with a card is complete. Nothing in it refers to the machine it was written on. Parameters ---------- model : torch.nn.Module or str or pathlib.Path A model (bare, or wrapped in :class:`ForceStressOutput`) or the path of any checkpoint :func:`load_checkpoint` reads (e.g. a trainer's ``best.pt``), which is then repacked. save_directory : str or pathlib.Path Output directory; created if missing. config : Config or ModelConfig, optional The configuration the model was built from. Taken from the checkpoint when ``model`` is a path; required for a module. card : ModelCard or dict, optional Card fields (name, description, license, citation, ...). The architecture, cutoff, species, dtype, files and xnn version are filled in from the model. verify : bool, optional Rebuild the architecture from ``config`` and load the weights strictly before writing, so a mismatched config fails here rather than at load time. Defaults to ``True``. **card_fields More card fields, overriding ``card``. Returns ------- pathlib.Path The model directory. Raises ------ ValueError If no config is available, or ``config`` does not rebuild the weights. """ import xnn from ...config import ModelConfig from ..outputs import ForceStressOutput from ..registry import build_model out = Path(save_directory).expanduser() fields: dict[str, Any] = {} module = None if isinstance(model, (str, Path)): ck = load_checkpoint(model) sd, config = ck.state_dict, (config if config is not None else ck.config) if ck.card is not None: fields.update(ck.card.to_dict()) fields.pop("files", None) fields.setdefault("source", Path(model).name) else: sd = wrapped_state_dict(model) module = model.model if isinstance(model, ForceStressOutput) else model if config is None: raise ValueError("save_pretrained needs the model's config (pass config=)") model_cfg = config if isinstance(config, ModelConfig) else config.model if verify or module is None: try: module = build_model(model_cfg) ForceStressOutput(module).load_state_dict(sd) except (RuntimeError, KeyError) as e: raise ValueError( f"the config does not rebuild these weights ({model_cfg.name}): {e}") from e if isinstance(card, ModelCard): fields.update(card.to_dict()) elif card: fields.update(card) fields.update(card_fields) fields.pop("files", None) from ...finetune.heads import find_multihead core = core_model(module) species = getattr(core, "species", None) or (model_cfg.extra or {}).get("species") fields.update( format=XNN_FORMAT, architecture=model_cfg.name, cutoff=float(getattr(module, "cutoff", model_cfg.cutoff)), species=[int(z) for z in species] if species is not None else None, heads=(list(find_multihead(module).heads) if find_multihead(module) is not None else fields.get("heads")), dtype=str(float_dtype(sd)).replace("torch.", ""), xnn_version=getattr(xnn, "__version__", None), ) fields.setdefault("name", out.name) new_card = ModelCard.from_dict(fields) refs = file_references(config) if refs: logger.warning("the config of %s refers to files outside the model directory (%s); " "it loads only where those paths exist. Use packaged library names " "or inline data to make it portable", out, ", ".join(refs)) out.mkdir(parents=True, exist_ok=True) (out / CARD_FILE).unlink(missing_ok=True) # incomplete until rewritten write_config(config, out / CONFIG_FILE) tmp = out / f"{WEIGHTS_FILE}.tmp{os.getpid()}" torch.save({"model": {k: v.detach().cpu() for k, v in sd.items()}}, tmp) tmp.replace(out / WEIGHTS_FILE) new_card.files = {f: _md5(out / f) for f in (CONFIG_FILE, WEIGHTS_FILE)} new_card.save(out / CARD_FILE) return out
def _as_dtype(dtype) -> Optional[torch.dtype]: """Accept ``torch.float64`` or ``"float64"``.""" if dtype is None or isinstance(dtype, torch.dtype): return dtype return getattr(torch, str(dtype).replace("torch.", ""))
[docs] def build_potential(config: Any, state_dict: dict, *, dtype=None, dispersion: Any = None, compute_forces: bool = True, compute_stress: bool = False, eeq_reuse: bool = False, model_options: Optional[dict[str, Any]] = None, head: Optional[str] = None, label: str = "checkpoint"): """Rebuild a model from its config and weights. The model is built in float64, so the constant tables of the physics terms (D3 / D4 reference data, LES kernels) hold their exact values, and cast once afterwards, to ``dtype`` or to the weights' own dtype. Building in float32 and upcasting would keep float32-rounded tables, which costs about 5e-8 hartree in a D4 energy even when serving in float64. A config that records ``subtracted_dispersion`` (what the training labels had removed) gets that term added back unless ``dispersion=False``; see :func:`~xnn.common.models.registry.resolve_dispersion`. A fine-tuned checkpoint is served as a plain potential: of a multi-head model (``extra["heads"]``) the head named by ``head`` is kept (:meth:`~xnn.common.finetune.MultiHead.select`), and LoRA updates (``extra["lora"]``) are folded into the base weights (:func:`~xnn.common.finetune.merge_lora`). Parameters ---------- config : Config The run configuration. state_dict : dict Weights in the :class:`ForceStressOutput` layout. dtype : torch.dtype or str, optional Dtype to serve in. Defaults to the dtype of the weights. dispersion : dict, str, bool or None, optional D3 / D4 correction to add (as in ``extra["dispersion"]``); ``None`` adds the recorded one, if any, and ``False`` adds nothing. compute_forces, compute_stress : bool, optional Heads of the returned :class:`ForceStressOutput`. eeq_reuse : bool, optional Carry the D4 EEQ solve over between steps (molecular dynamics). model_options : dict, optional Overrides of ``config.model.extra`` applied before the model is built: deployment knobs that change no weights, such as AIMNet2's Coulomb method (``{"coulomb": "dsf", "lr_cutoff": 15.0}``); the weights still load strictly. head : str, optional The head of a multi-head checkpoint to serve; required when it has several, ignored (if it matches) for a single-head one. label : str, optional Name used in log messages. Returns ------- ForceStressOutput The model with its weights loaded, in eval mode. Raises ------ ValueError If ``dispersion`` is given for a model that already includes one, or ``head`` is missing or unknown for a multi-head checkpoint. """ from ...finetune.heads import find_multihead from ...finetune.lora import has_lora, merge_lora from ..outputs import ForceStressOutput from ..registry import (add_dispersion, build_model, recorded_dispersion, replace_module, resolve_dispersion) recorded = recorded_dispersion(config) spec = resolve_dispersion(dispersion, recorded) if model_options: config = copy.deepcopy(config) config.model.extra = {**(config.model.extra or {}), **model_options} prev_dtype = torch.get_default_dtype() torch.set_default_dtype(torch.float64) try: base = build_model(config.model) # ForceStressOutput has no parameters of its own; loading through # it matches the trainer's state-dict layout ForceStressOutput(base).load_state_dict(state_dict) multi = find_multihead(base) if multi is not None: if head is None and len(multi.heads) > 1: raise ValueError(f"{label} has the heads {multi.heads}; pass head= with one of them") chosen = multi.heads[0] if head is None else str(head) if chosen not in multi.heads: raise ValueError(f"unknown head {chosen!r} of {label}; it has {multi.heads}") base = replace_module(base, multi, multi.select(chosen)) logger.info("serving head %s of %s", chosen, label) elif head is not None: logger.debug("%s has a single head; head=%r ignored", label, head) if has_lora(base): merge_lora(base) logger.info("LoRA updates of %s folded into the base weights", label) if spec is not None: if has_dispersion(base): raise ValueError( f"{label} already includes a dispersion correction " f"({config.model.name} with extra['dispersion']); adding " f"another one would count dispersion twice") base = add_dispersion(base, spec) if recorded is None: logger.info("added %s dispersion on top of the checkpoint: %s", type(base).__name__, spec) elif dispersion is None or dispersion is True: logger.info("adding back the dispersion recorded as subtracted " "from the training labels: %s", spec) else: logger.info("adding back the dispersion recorded as subtracted " "from the training labels, with overrides %s: %s", dispersion, spec) elif recorded is not None: logger.warning("serving %s WITHOUT the dispersion recorded as subtracted " "from its training labels (%s)", label, recorded) finally: torch.set_default_dtype(prev_dtype) dtype = _as_dtype(dtype) or float_dtype(state_dict) model = ForceStressOutput(base, compute_forces=compute_forces, compute_stress=compute_stress).to(dtype) if eeq_reuse: from ..d4 import enable_eeq_reuse n_terms = enable_eeq_reuse(model) if n_terms: logger.info("EEQ reuse between steps enabled for %d D4 term(s)", n_terms) else: logger.info("--eeq-reuse has no effect: the model has no D4 term") return model.eval()