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

"""``from_pretrained``: one call to load any pre-trained model.

:func:`fetch_model` turns a source (registry name, local path, URL or Zenodo
DOI) into a local model directory, downloading, verifying, unpacking and
converting as needed; :func:`load_pretrained` builds the model from it. Both
honor ``cache_dir``, ``force_download`` and ``local_files_only``.
"""
from __future__ import annotations

import logging
import os
import shutil
import tempfile
import urllib.parse
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional, Union

from torch import nn

from ...data.hub._download import download_file, extract_archive
from ..fast import set_use_fast
from ..registry import deployment_config
from . import zenodo
from .cache import (head_dir, is_cache_key, is_ready, offline, resolve_cache_dir,
                    sanitize, url_slot)
from .card import CARD_FILE, CONFIG_FILE, WEIGHTS_FILE, ModelCard
from .checkpoint import build_potential, load_checkpoint, save_pretrained
from .formats import XNN_FORMAT, detect_format, get_format, is_archive, suffixes
from .registry import get_card

logger = logging.getLogger(__name__)

# card fields a cached directory inherits from the card it was fetched for
_INHERITED = ("name", "description", "heads", "units", "license", "citation", "doi",
              "url", "filename", "tags", "notes")
_ASL = ("{name} is distributed under the Academic Software License "
        "(https://github.com/gabor1/ASL); by using it you accept its terms "
        "(no commercial use).")
_noticed: set[str] = set()


[docs] @dataclass class PretrainedModel: """A loaded model with everything known about it. Attributes ---------- model : ForceStressOutput The ready model (forces, optionally stress), in eval mode. config : Config The configuration it was built from. card : ModelCard or None Its card (``None`` for a bare checkpoint file). cutoff : float Neighbor-list cutoff of the built model, in angstrom (a dispersion wrapper widens it beyond the core model's radius). path : pathlib.Path The local directory or file it was loaded from. """ model: nn.Module config: Any card: Optional[ModelCard] cutoff: float path: Path
def _pick_head(card: ModelCard, head: Optional[str]) -> Optional[str]: """Validate ``head`` against the card; ``None`` for single-head models.""" heads = card.heads if not heads: return head if len(heads) == 1: if head is not None and head != heads[0]: raise ValueError(f"{card.name} has the single head {heads[0]!r}, not {head!r}") return None if head is None: raise ValueError(f"{card.name} has {len(heads)} heads; pass head= with one of {heads}") if head not in heads: raise ValueError(f"unknown head {head!r} of {card.name}; it has {heads}") return head def _card_fields(card: ModelCard, head: Optional[str]) -> dict[str, Any]: """Fields a cached directory takes over from the card it was fetched for.""" d = card.to_dict() out = {k: d[k] for k in _INHERITED if k in d} out["source"] = (f"https://doi.org/{card.doi}" if card.doi else card.url) or card.source if head is not None: out["head"] = head return out def _model_dir(root: Path) -> Optional[Path]: """The directory under ``root`` that holds a portable model, if any.""" for config in sorted(root.rglob(CONFIG_FILE)): if (config.parent / WEIGHTS_FILE).is_file(): return config.parent return None def _install(raw: Path, target: Path, card: ModelCard, head: Optional[str], fmt: str) -> None: """Turn a downloaded file into the portable directory ``target``. An archive is unpacked (a portable directory inside is taken as is, a single checkpoint inside is installed in turn), an xnn checkpoint is repacked, and a foreign format is converted. """ fields = _card_fields(card, head) if raw.is_dir() or is_archive(raw): work = Path(tempfile.mkdtemp(prefix=".unpack-", dir=target.parent)) try: if raw.is_dir(): shutil.copytree(raw, work, dirs_exist_ok=True) else: extract_archive(raw, work) found = _model_dir(work) if found is not None: if (found / CARD_FILE).is_file(): # the uploader's card wins; the registry fills in the # rest, and name and source say where this copy is from own = ModelCard.load(found / CARD_FILE).to_dict() fields = {k: v for k, v in fields.items() if k not in own or k in ("name", "source")} save_pretrained(found, target, card=fields) return inner = [p for p in work.rglob("*") if p.is_file() and p.name.lower().endswith(suffixes()) and not is_archive(p)] if len(inner) != 1: raise FileNotFoundError( f"{raw.name} holds no model directory and " f"{'several' if inner else 'no'} checkpoint files " f"{[p.name for p in inner]}") _install(inner[0], target, card, head, detect_format(inner[0])) finally: shutil.rmtree(work, ignore_errors=True) return if fmt == XNN_FORMAT: save_pretrained(raw, target, card=fields) else: model, cfg = get_format(fmt).convert(raw, head) # the converter builds the model from this very config, so the # strict rebuild check of save_pretrained would only cost time save_pretrained(model, target, config=cfg, card=fields, verify=False) def _existing_raw(card: ModelCard, slot: Path, fname: str) -> Optional[Path]: """A copy of the card's file that is already on disk.""" raw = slot / "raw" / sanitize(fname) if raw.is_file(): return raw if card.format != XNN_FORMAT: find = get_format(card.format).find_cached if find is not None: return find(card) return None def _fetch_card(card: ModelCard, slot: Path, head: Optional[str], cache: Path, *, format: Optional[str], force: bool, no_net: bool, quiet: bool) -> Path: """Make sure the model of ``card`` is in ``slot``; return its directory.""" if card.unsupported: raise NotImplementedError(f"{card.name} cannot be loaded: {card.unsupported}") head = _pick_head(card, head) target = head_dir(slot, head) if is_ready(target) and not force: return target fmt = format or card.format # where to get it from portable_files = None if card.doi: ref = zenodo.parse(card.doi) if ref is None: raise ValueError(f"{card.name}: only Zenodo DOIs can be resolved, got {card.doi}") recid = ref[0] record = zenodo.fetch_record(recid, cache / f"zenodo.{recid}", refresh=force, offline=no_net) entries = zenodo.files(record) if card.filename is None and any(e["key"] == CARD_FILE for e in entries): portable_files = [e for e in entries if e["key"] in (CARD_FILE, CONFIG_FILE, WEIGHTS_FILE)] fname = card.name else: entry = zenodo.choose(entries, card.filename, suffixes(), recid) url, md5, fname = entry["url"], entry["md5"], entry["key"] elif card.url: url, md5 = card.url, card.md5 fname = os.path.basename(urllib.parse.urlparse(card.url).path) or "model" else: raise FileNotFoundError( f"{card.name} is registered but has no download source yet (its card " f"names no DOI or URL) and is not cached in {cache}") target.parent.mkdir(parents=True, exist_ok=True) slot.mkdir(parents=True, exist_ok=True) download = slot / ".download" try: if portable_files is not None: if no_net: raise FileNotFoundError(f"{card.name} is not cached in {cache} and " f"network access is disabled") for e in portable_files: dest = download / e["key"] if force: dest.unlink(missing_ok=True) download_file(e["url"], dest, md5=e["md5"], quiet=quiet) _install(download, target, card, head, XNN_FORMAT) return target raw = None if force else _existing_raw(card, slot, fname) if raw is None: if no_net: raise FileNotFoundError(f"{card.name} is not cached in {cache} and " f"network access is disabled " f"(local_files_only=True or XNN_OFFLINE)") # a file to convert goes to raw/, where it outlives a failed # conversion (a forgotten head=) and serves the other heads dest = (download if fmt == XNN_FORMAT else slot / "raw") / sanitize(fname) if force: dest.unlink(missing_ok=True) raw = download_file(url, dest, md5=md5, quiet=quiet) _install(raw, target, card, head, fmt) # converted without a head means single-head: nothing else needs it if head is None and raw.parent == slot / "raw": raw.unlink(missing_ok=True) if not any(raw.parent.iterdir()): raw.parent.rmdir() finally: shutil.rmtree(download, ignore_errors=True) return target
[docs] def fetch_model(source: Union[str, Path], *, cache_dir: Optional[Union[str, Path]] = None, filename: Optional[str] = None, head: Optional[str] = None, format: Optional[str] = None, force_download: bool = False, local_files_only: bool = False, quiet: bool = False) -> Path: """Return a local copy of a model, downloading and converting it if needed. Sources are tried in this order: 1. an existing local path (a model directory or a checkpoint file), used in place; 2. a registered name (:func:`list_models`), fetched from the DOI or URL in its card into ``<cache_dir>/<name>/``; 3. a Zenodo DOI or record link, into ``<cache_dir>/zenodo.<id>/<file>/``; 4. a plain ``http(s)`` URL of a checkpoint or archive, into ``<cache_dir>/url/<file>-<hash>/``; 5. the key of a directory already in the cache (as :func:`list_models` shows it), e.g. one copied over from another machine. Whatever is fetched is stored as a portable model directory (see :func:`~xnn.common.models.hub.save_pretrained`), so the next call needs no network and no converter package. Parameters ---------- source : str or pathlib.Path What to load. cache_dir : str or pathlib.Path, optional Cache directory. Defaults to :func:`~xnn.common.models.hub.cache.default_model_cache_dir`. filename : str, optional File to take from a Zenodo record that holds several models. head : str, optional Head to keep from a multi-head model. format : str, optional Format of the published file when its suffix does not tell (``"xnn"``, ``"mace-torch"``). force_download : bool, optional Download and convert again even when cached. local_files_only : bool, optional Never use the network (also set by ``XNN_OFFLINE=1``); fail when the model is not cached. quiet : bool, optional Hide download progress bars. Returns ------- pathlib.Path A model directory, or the local file that was passed in. Raises ------ FileNotFoundError If the source is not found, or not cached while offline. ValueError For a DOI that is not on Zenodo, or an ambiguous record or head. """ s = str(source) local = Path(s).expanduser() if "://" not in s and local.exists(): return local cache = resolve_cache_dir(cache_dir) opts = dict(format=format, force=force_download, no_net=local_files_only or offline(), quiet=quiet) card = get_card(s) if card is not None: return _fetch_card(card, cache / card.name, head, cache, **opts) ref = zenodo.parse(s) if ref is not None: recid, fname = ref fname = fname or filename root = cache / f"zenodo.{recid}" if fname is not None and not force_download: target = head_dir(root / sanitize(fname), head) if is_ready(target): return target record = zenodo.fetch_record(recid, root, refresh=force_download, offline=opts["no_net"]) entries = zenodo.files(record) doi = record.get("doi") or f"10.5281/zenodo.{recid}" common = dict(doi=doi, description=str(record.get("metadata", {}).get("title", "")), license=zenodo.license_id(record), citation=zenodo.citation(record)) if fname is None and any(e["key"] == CARD_FILE for e in entries): card = ModelCard(name=f"zenodo.{recid}", **common) slot = root else: entry = zenodo.choose(entries, fname, suffixes(), recid) key = sanitize(entry["key"]) card = ModelCard(name=f"zenodo.{recid}/{key}", filename=entry["key"], format=format or detect_format(Path(entry["key"])), **common) slot = root / key return _fetch_card(card, slot, head, cache, **opts) if s.startswith(("http://", "https://")): if zenodo.is_doi(s): raise ValueError(f"only Zenodo DOIs can be resolved to files; for {s} pass " f"the direct download URL of the model file") key = url_slot(s) path = Path(urllib.parse.urlparse(s).path) card = ModelCard(name=key, url=s, format=format or detect_format(path)) return _fetch_card(card, cache / key, head, cache, **opts) if zenodo.is_doi(s): raise ValueError(f"only Zenodo DOIs can be resolved to files, got {s}") if is_cache_key(s) and not force_download: target = head_dir(cache / s, head) if is_ready(target): return target raise FileNotFoundError( f"{s!r} is not a registered model (see list_models()), a local path, a URL " f"or a Zenodo DOI, and nothing is cached under that name in {cache}")
def _license_notice(card: Optional[ModelCard]) -> None: """Log the terms of a restrictively licensed model, once per process.""" if card is None or not str(card.license or "").upper().startswith("ASL"): return if card.name not in _noticed: _noticed.add(card.name) logger.warning(_ASL.format(name=card.name))
[docs] def load_pretrained(source: Union[str, Path], *, cache_dir: Optional[Union[str, Path]] = None, filename: Optional[str] = None, head: Optional[str] = None, format: Optional[str] = None, force_download: bool = False, local_files_only: bool = False, quiet: bool = False, device: str = "cpu", dtype=None, dispersion: Any = None, compute_forces: bool = True, compute_stress: bool = False, eeq_reuse: bool = False, use_fast: Union[bool, str] = "auto", model_options: Optional[dict[str, Any]] = None) -> PretrainedModel: """Load a pre-trained model together with its config and card. The same as :func:`from_pretrained`, returning a :class:`PretrainedModel` so callers that need the config, card or cutoff (an ASE calculator, the MDI engine, ``xnn export``) get them without reloading. See :func:`fetch_model` for the source and cache options and :func:`~xnn.common.models.hub.checkpoint.build_potential` for ``dtype``, ``dispersion``, ``eeq_reuse`` and ``model_options``. ``use_fast`` (``"auto"``, ``True`` or ``False``) chooses between the fused GPU kernels and the reference implementation of the blocks that have both (see :mod:`xnn.common.models.fast`). Returns ------- PretrainedModel The model and its metadata. Raises ------ ValueError If the checkpoint embeds no config. """ path = fetch_model(source, cache_dir=cache_dir, filename=filename, head=head, format=format, force_download=force_download, local_files_only=local_files_only, quiet=quiet) ck = load_checkpoint(path, format=format, head=head) if ck.config is None: raise ValueError(f"{path} embeds no config; save it with save_pretrained(model, " f"directory, config=cfg) first") card = ck.card or get_card(str(source)) _license_notice(card) model = build_potential(ck.config, ck.state_dict, dtype=dtype, dispersion=dispersion, compute_forces=compute_forces, compute_stress=compute_stress, eeq_reuse=eeq_reuse, model_options=model_options, head=head, label=str(source)).to(device) set_use_fast(model, use_fast) cutoff = float(getattr(model.model, "cutoff", ck.config.model.cutoff)) # the config of what is served: one head, LoRA folded in return PretrainedModel(model, deployment_config(ck.config), card, cutoff, Path(path))
[docs] def from_pretrained(source: Union[str, Path], *, wrap: bool = True, **kwargs: Any) -> nn.Module: """Load a pre-trained model in one call. MACE foundation models, xnn-trained models, Zenodo uploads and local checkpoints all load the same way:: from xnn.common.models import from_pretrained model = from_pretrained("mace-off23-small") # registry name model = from_pretrained("mace-mh-0", head="omat_pbe") # one head model = from_pretrained("doi:10.5281/zenodo.18957344", filename="mace_csfapbbri_al_5_1_stagetwo.model") model = from_pretrained("runs/exp/best.pt") # trainer checkpoint model = from_pretrained("mace-off23-small", cache_dir="/scratch/models") The first call downloads (and verifies) the file into ``cache_dir`` and, for a foreign format such as ``mace-torch``, converts it once; later calls load the cached portable directory without network access and without the upstream package. Parameters ---------- source : str or pathlib.Path Registry name, local directory or checkpoint, URL, or Zenodo DOI; see :func:`fetch_model`. wrap : bool, optional Return the :class:`~xnn.common.models.ForceStressOutput` wrapper (forces, and stress with ``compute_stress=True``). ``False`` returns the bare potential inside it. Defaults to ``True``. **kwargs ``cache_dir``, ``filename``, ``head``, ``format``, ``force_download``, ``local_files_only``, ``quiet``, ``device``, ``dtype``, ``dispersion``, ``compute_forces``, ``compute_stress``, ``eeq_reuse``, ``use_fast``, ``model_options``; see :func:`load_pretrained`. Returns ------- torch.nn.Module The model in eval mode. """ model = load_pretrained(source, **kwargs).model return model if wrap else model.model