"""Central loading and export helpers for pimm models."""
from __future__ import annotations
import json
import os
import tempfile
from pathlib import Path
from typing import Any, Callable, Dict, Mapping, Optional, Type, Union
import torch
from pimm.export.checkpoint import (
clean_state_dict,
filter_state_dict_by_prefix,
load_state_dict_from_checkpoint,
remap_state_dict_keys,
)
from pimm.utils.checkpoints import (
EXPORT_CONFIG_NAME,
EXPORT_CONFIG_READ_NAMES,
EXPORT_WEIGHT_NAMES,
configure_hf_cache,
is_remote_weight,
parse_hf_uri,
resolve_remote_weight,
)
from pimm.utils.config import Config
from pimm.utils.path import split_checkpoint_weight_file
try:
import safetensors.torch as safe_torch
_HAS_SAFETENSORS = True
except ImportError: # pragma: no cover
safe_torch = None
_HAS_SAFETENSORS = False
PathLike = Union[str, Path]
def _to_plain_data(value: Any) -> Any:
"""Convert config-like objects into JSON-serializable Python containers."""
if isinstance(value, Config):
return _to_plain_data(value._cfg_dict)
if hasattr(value, "to_dict"):
try:
return _to_plain_data(value.to_dict())
except TypeError:
pass
if isinstance(value, Mapping):
return {str(k): _to_plain_data(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [_to_plain_data(v) for v in value]
return value
# Config keys whose value is a load-trigger path: null them on export so a
# rebuilt model never tries to re-load a site-specific checkpoint (the exported
# weights are loaded instead). Any other absolute-path string is redacted.
_PATH_LOAD_KEYS = frozenset(
{"weight", "pretrained", "init_cfg", "load_from", "ckpt", "ckpt_path", "checkpoint"}
)
_REDACTED = "<redacted>"
def _sanitize_config(value: Any, *, _key: str | None = None) -> Any:
"""Strip site-specific absolute paths from a config before publishing.
Nulls known load-trigger keys (so a rebuilt model skips re-loading) and
redacts any other absolute-path string (so published configs don't leak the
cluster layout / usernames / data locations). Architecture and hyperparameter
values are left intact.
"""
if isinstance(value, str):
if value.startswith("/") or value.startswith("hf://"):
return None if _key in _PATH_LOAD_KEYS else _REDACTED
return value
if isinstance(value, dict):
return {k: _sanitize_config(v, _key=k) for k, v in value.items()}
if isinstance(value, list):
return [_sanitize_config(v) for v in value]
return value
def _read_json(path: Path) -> Dict[str, Any]:
"""Read a JSON object from ``path``."""
with path.open("r", encoding="utf-8") as f:
return json.load(f)
def _write_json(path: Path, payload: Any) -> None:
"""Write ``payload`` as stable, newline-terminated JSON."""
with path.open("w", encoding="utf-8") as f:
json.dump(payload, f, indent=2, default=str)
f.write("\n")
def _load_config_payload(path: Path) -> Dict[str, Any]:
"""Load a Python or JSON config file into plain Python data."""
if path.suffix == ".py":
return _to_plain_data(Config.fromfile(str(path)))
if path.suffix == ".json":
return _read_json(path)
raise ValueError(f"Unsupported config file for hub export: {path}")
def _extract_model_config(payload: Mapping[str, Any]) -> Dict[str, Any]:
"""Return the nested model config when present, otherwise the payload."""
if "model" in payload and isinstance(payload["model"], Mapping):
return dict(payload["model"])
return dict(payload)
def _find_model_config_by_type(
payload: Any,
model_type: str,
) -> Optional[Dict[str, Any]]:
"""Return the first nested model config whose ``type`` matches."""
if isinstance(payload, Mapping):
if payload.get("type") == model_type:
return dict(payload)
for value in payload.values():
found = _find_model_config_by_type(value, model_type)
if found is not None:
return found
elif isinstance(payload, (list, tuple)):
for value in payload:
found = _find_model_config_by_type(value, model_type)
if found is not None:
return found
return None
def _select_target_model_config(
source_model_cfg: Mapping[str, Any],
model_type: Optional[str],
) -> Dict[str, Any]:
"""Choose the construction config for the requested output model."""
model_cfg = dict(source_model_cfg)
if model_type is None:
return model_cfg
if model_cfg.get("type") == model_type:
return model_cfg
nested_cfg = _find_model_config_by_type(model_cfg, model_type)
if nested_cfg is not None:
return nested_cfg
model_cfg["type"] = model_type
return model_cfg
def _find_run_root(checkpoint_path: Path) -> Optional[Path]:
"""Infer an experiment root from a checkpoint under a ``model`` directory.
Uses the logical (non-symlink-resolved) path: ``MODEL_DIR`` setups symlink
``model/`` to a data filesystem that holds only weights, while ``config.py``
lives in the experiment tree next to the symlink.
"""
checkpoint_path = Path(os.path.abspath(checkpoint_path))
if (
checkpoint_path.name == "weights.pth"
and checkpoint_path.parent.parent.name == "model"
):
return checkpoint_path.parent.parent.parent
if checkpoint_path.parent.name == "model":
return checkpoint_path.parent.parent
return None
def _is_dcp_checkpoint_dir(path: Path) -> bool:
"""Return whether ``path`` looks like a torch distributed checkpoint."""
return path.is_dir() and (path / ".metadata").is_file()
def _training_config_from_run(checkpoint_path: Optional[Path]) -> Optional[Dict[str, Any]]:
"""Recover a run's full config from the experiment dir around a checkpoint.
Lets an export from a loose ``.pth`` still carry its config (so
``from_pretrained`` works for free) without the caller passing ``cfg``.
"""
if checkpoint_path is None:
return None
run_root = _find_run_root(checkpoint_path)
if run_root is None:
return None
for name in EXPORT_CONFIG_READ_NAMES:
candidate = run_root / name
if candidate.is_file():
return _read_json(candidate)
candidate = run_root / "config.py"
if candidate.is_file():
return _load_config_payload(candidate)
return None
def _model_config_from_export(directory: PathLike) -> Optional[Dict[str, Any]]:
"""Read the saved model config from an export's ``config.json`` (or the
legacy ``training_config.json``)."""
directory = Path(directory)
candidate = next(
(directory / name for name in EXPORT_CONFIG_READ_NAMES if (directory / name).is_file()),
None,
)
if candidate is None:
return None
data = _read_json(candidate)
if not isinstance(data, dict):
return None
model_cfg = data.get("model")
if isinstance(model_cfg, dict) and model_cfg:
return model_cfg
if "type" in data: # the file is already a bare model config
return data
return None
def _infer_model_config(
*,
model_config: Optional[Mapping[str, Any]],
cfg: Any,
config_path: Optional[PathLike],
checkpoint_path: Optional[Path],
) -> Dict[str, Any]:
"""Resolve the model construction config from explicit args or run files."""
if model_config is not None:
return _to_plain_data(model_config)
if cfg is not None:
cfg_payload = _to_plain_data(cfg)
return _extract_model_config(cfg_payload)
if config_path is not None:
return _extract_model_config(_load_config_payload(Path(config_path)))
candidate_dirs = []
if checkpoint_path is not None:
run_root = _find_run_root(checkpoint_path)
if run_root is not None:
candidate_dirs.append(run_root)
candidate_dirs.append(checkpoint_path.parent)
for root in candidate_dirs:
for name in EXPORT_CONFIG_READ_NAMES:
candidate = root / name
if candidate.is_file():
return _extract_model_config(_read_json(candidate))
candidate = root / "config.py"
if candidate.is_file():
return _extract_model_config(_load_config_payload(candidate))
raise ValueError(
"model_config, cfg, or config_path is required when it cannot be inferred "
"from an experiment directory."
)
def _state_dict_from_model_or_checkpoint(
model_or_checkpoint: Any,
device: str = "cpu",
) -> Dict[str, Any]:
"""Extract a cleaned state dict from a model, mapping, or checkpoint path."""
if isinstance(model_or_checkpoint, torch.nn.Module):
module = model_or_checkpoint.module if hasattr(model_or_checkpoint, "module") else model_or_checkpoint
return clean_state_dict(module.state_dict())
if isinstance(model_or_checkpoint, Mapping):
if "state_dict" in model_or_checkpoint:
return clean_state_dict(model_or_checkpoint["state_dict"])
if "model" in model_or_checkpoint and isinstance(model_or_checkpoint["model"], Mapping):
model_state = model_or_checkpoint["model"]
if "state_dict" in model_state:
return clean_state_dict(model_state["state_dict"])
return clean_state_dict(model_state)
return clean_state_dict(dict(model_or_checkpoint))
checkpoint_path = Path(model_or_checkpoint)
if Path(split_checkpoint_weight_file(checkpoint_path)).is_file():
return load_state_dict_from_checkpoint(
split_checkpoint_weight_file(checkpoint_path),
device=device,
)
if _is_dcp_checkpoint_dir(checkpoint_path):
raise ValueError(
f"{checkpoint_path} is a raw DCP trainer-state directory. "
"Use the split checkpoint directory containing weights.pth."
)
return load_state_dict_from_checkpoint(str(checkpoint_path), device=device)
def _build_model_from_config(
model_cfg: Mapping[str, Any],
*,
model_cls: Optional[Type[torch.nn.Module]] = None,
tolerate_drift: bool = True,
) -> torch.nn.Module:
"""Construct a model from registry config or an explicit class.
With ``tolerate_drift`` (default), if the saved config carries kwargs the
current constructor no longer accepts (config drift across code versions),
they are dropped with a warning and construction is retried -- so
``from_pretrained`` still loads older exports for free.
"""
import pimm.models # noqa: F401 - populate import-all registries
from pimm.models.builder import MODELS, build_model
def _construct(cfg):
if model_cls is not None:
kwargs = dict(cfg)
kwargs.pop("type", None)
return model_cls(**kwargs)
return build_model(dict(cfg))
try:
return _construct(model_cfg)
except TypeError as exc:
if not tolerate_drift or "unexpected keyword argument" not in str(exc):
raise
import inspect
import warnings
cls = model_cls or MODELS.get(model_cfg.get("type"))
if cls is None:
raise
accepted = set(inspect.signature(cls.__init__).parameters)
filtered = {k: v for k, v in model_cfg.items() if k == "type" or k in accepted}
dropped = sorted(set(model_cfg) - set(filtered))
if not dropped:
raise
warnings.warn(
f"Dropping config kwargs not accepted by {model_cfg.get('type')}: "
f"{dropped} (config drift); rebuilding."
)
return _construct(filtered)
[docs]
def save_pretrained(
model_or_checkpoint: Any,
save_directory: PathLike,
*,
cfg: Any = None,
config_path: Optional[PathLike] = None,
training_config: Optional[Mapping[str, Any]] = None,
safe_serialization: bool = True,
device: str = "cpu",
model_card: Optional[str] = None,
) -> Path:
"""Save model weights to a directory as bare serialized tensors.
Writes ``model.safetensors`` (or ``model.bin`` when
``safe_serialization=False``) and, optionally, ``training_config.json``
(provenance) and ``README.md`` (model card). No model config is written, so
loading requires the architecture from elsewhere (a fine-tune config for
warm-start, or ``model_config``/``config_path`` for ``from_pretrained``).
``model_or_checkpoint`` may be an nn.Module, checkpoint mapping, or path.
"""
save_dir = Path(save_directory)
save_dir.mkdir(parents=True, exist_ok=True)
checkpoint_path = Path(model_or_checkpoint) if isinstance(model_or_checkpoint, (str, Path)) else None
state_dict = _state_dict_from_model_or_checkpoint(model_or_checkpoint, device=device)
if safe_serialization:
if not _HAS_SAFETENSORS:
raise ImportError("safetensors is required for safe_serialization=True")
weights_name = "model.safetensors"
safe_torch.save_file(state_dict, str(save_dir / weights_name))
else:
weights_name = "model.bin"
torch.save(state_dict, save_dir / weights_name)
# Bare-weights export: the serialized tensors are the only required artifact.
# Architecture comes from the loading side (a fine-tune config for warm-start,
# or, for from_pretrained, the config carried in training_config.json). We save the
# config whenever it is discoverable so from_pretrained("<repo>") works for free:
# explicit training_config > cfg > config_path > the run dir around a checkpoint.
if training_config is None and cfg is not None:
training_config = _to_plain_data(cfg)
if training_config is None and config_path is not None:
training_config = _load_config_payload(Path(config_path))
if training_config is None:
training_config = _training_config_from_run(checkpoint_path)
if training_config is not None:
# Redact site-specific absolute paths so the published config does not
# leak the cluster layout and a rebuilt model never re-loads a stale path.
_write_json(
save_dir / EXPORT_CONFIG_NAME,
_sanitize_config(_to_plain_data(training_config)),
)
if model_card is not None:
(save_dir / "README.md").write_text(model_card, encoding="utf-8")
return save_dir
def _resolve_pretrained_path(
pretrained_model_name_or_path: PathLike,
*,
cache_dir: Optional[PathLike] = None,
revision: Optional[str] = None,
) -> Path:
"""Resolve a local path, ``hf://`` URI, or bare repo id to a model dir."""
name = str(pretrained_model_name_or_path)
path = Path(name)
if path.exists():
return path
# Accept the same hf:// scheme used by training `weight=` references, so
# from_pretrained("hf://ns/name[@rev]") works like from_pretrained("ns/name").
if is_remote_weight(name):
repo_id, uri_revision, filename = parse_hf_uri(name)
revision = revision or uri_revision
# An explicit in-repo file (e.g. hf://ns/name/panda_base.pth) names a
# single weight file -- including raw .pth checkpoints that the export
# snapshot patterns below would never fetch. Delegate to the warm-start
# resolver, which honors the filename and returns just that file.
if filename:
rev = f"@{revision}" if revision else ""
return Path(resolve_remote_weight(f"hf://{repo_id}{rev}/{filename}"))
else:
repo_id = name
try:
from huggingface_hub import snapshot_download
except ImportError as exc: # pragma: no cover - depends on optional package
raise ImportError("huggingface_hub is required to load pimm models from the Hub") from exc
# Export pimm's cache to HF's env (process-wide sharing); pass it explicitly
# too, since HF caches HF_HUB_CACHE in a constant at import time.
if cache_dir is not None:
os.environ["HF_HUB_CACHE"] = str(cache_dir)
else:
cache_dir = configure_hf_cache()
downloaded = snapshot_download(
repo_id=repo_id,
cache_dir=str(cache_dir) if cache_dir is not None else None,
revision=revision,
allow_patterns=[*EXPORT_WEIGHT_NAMES, *EXPORT_CONFIG_READ_NAMES, "README.md"],
)
snapshot = Path(downloaded)
# A consolidated export carries its weights (and config) in the snapshot dir.
# A repo of raw checkpoints (no model.safetensors/model.bin) yields a snapshot
# with no usable weights -- fall back to the warm-start resolver, which picks
# and fetches the single raw .pth so the bare-repo form also resolves.
if is_remote_weight(name) and not any(
(snapshot / candidate).is_file() for candidate in EXPORT_WEIGHT_NAMES
):
rev = f"@{revision}" if revision else ""
return Path(resolve_remote_weight(f"hf://{repo_id}{rev}"))
return snapshot
[docs]
def from_pretrained(
pretrained_model_name_or_path: PathLike,
*,
model_config: Optional[Mapping[str, Any]] = None,
model_type: Optional[str] = None,
model_cls: Optional[Type[torch.nn.Module]] = None,
config_path: Optional[PathLike] = None,
cache_dir: Optional[PathLike] = None,
revision: Optional[str] = None,
device: Optional[Union[str, torch.device]] = "cpu",
strict: bool = True,
prefix: Optional[str] = None,
remove_prefix: bool = True,
key_mapping: Optional[Dict[str, str]] = None,
keep_unmapped_keys: bool = False,
filter_fn: Optional[Callable[[Dict[str, Any], torch.nn.Module], Dict[str, Any]]] = None,
return_metadata: bool = False,
**model_kwargs: Any,
):
"""Load a pimm model from a local export, Hub repo, or raw checkpoint.
Exports are bare weights (``model.safetensors``/``model.bin``) and carry no
config, so the model config must come from ``model_config``, a supplied
``config_path``, or an experiment directory next to a raw checkpoint.
``model_type`` can override the registry ``type`` for the constructed
output model. ``model_cls`` can construct an explicit Python class instead
of using the registry. Extra ``model_kwargs`` override config fields before
construction.
``prefix``/``remove_prefix`` and ``key_mapping`` mirror ``load_pretrained``
so exported pretraining checkpoints can be loaded into a different model
shape, for example ``key_mapping={"student.backbone.": "backbone."}``.
By default, ``from_pretrained`` drops keys that do not match ``key_mapping``;
pass ``keep_unmapped_keys=True`` to preserve them.
"""
resolved = _resolve_pretrained_path(
pretrained_model_name_or_path,
cache_dir=cache_dir,
revision=revision,
)
if Path(split_checkpoint_weight_file(resolved)).is_file():
manifest = {}
weights_path = Path(split_checkpoint_weight_file(resolved))
model_cfg = _infer_model_config(
model_config=model_config,
cfg=None,
config_path=config_path,
checkpoint_path=weights_path,
)
elif resolved.is_dir() and not _is_dcp_checkpoint_dir(resolved):
# Export dir: probe for the serialized tensors. The architecture comes
# from an explicit arg, the config saved alongside the weights
# (training_config.json), or, failing that, a config_path/run dir.
manifest = {}
weights_name = None
for candidate in EXPORT_WEIGHT_NAMES:
if (resolved / candidate).is_file():
weights_name = candidate
break
if weights_name is None:
raise FileNotFoundError(f"No model weights found in {resolved}")
weights_path = resolved / weights_name
model_cfg = model_config
if model_cfg is None and config_path is None:
model_cfg = _model_config_from_export(resolved)
if model_cfg is None:
model_cfg = _infer_model_config(
model_config=None,
cfg=None,
config_path=config_path,
checkpoint_path=weights_path,
)
else:
manifest = {}
weights_path = resolved
model_cfg = _infer_model_config(
model_config=model_config,
cfg=None,
config_path=config_path,
checkpoint_path=resolved,
)
source_model_cfg = _to_plain_data(model_cfg)
model_cfg = _select_target_model_config(source_model_cfg, model_type)
model_cfg.update(model_kwargs)
model = _build_model_from_config(model_cfg, model_cls=model_cls)
if _is_dcp_checkpoint_dir(weights_path):
raise ValueError(
f"{weights_path} is a raw DCP trainer-state directory. "
"Use the split checkpoint directory containing weights.pth."
)
if str(weights_path).endswith(".safetensors"):
if not _HAS_SAFETENSORS:
raise ImportError("safetensors is required to load model.safetensors")
state_dict = safe_torch.load_file(str(weights_path), device=str(device or "cpu"))
else:
state_dict = load_state_dict_from_checkpoint(str(weights_path), device=device or "cpu")
if prefix is not None:
state_dict = filter_state_dict_by_prefix(
state_dict,
prefix=prefix,
remove_prefix=remove_prefix,
)
if not state_dict:
raise ValueError(f"No keys found with prefix '{prefix}' in {weights_path}")
if key_mapping is not None:
state_dict = remap_state_dict_keys(
state_dict,
key_mapping,
keep_unmapped=keep_unmapped_keys,
)
if filter_fn is not None:
state_dict = filter_fn(state_dict, model)
if not state_dict:
raise ValueError(f"No checkpoint keys selected for loading from {weights_path}")
incompatible_keys = model.load_state_dict(state_dict, strict=strict)
if device is not None:
model.to(device)
model.eval()
metadata = {
"config": manifest,
"model_config": model_cfg,
"source_model_config": source_model_cfg,
"path": str(resolved),
"weights": str(weights_path),
}
if not strict:
metadata["incompatible_keys"] = {
"missing_keys": list(incompatible_keys.missing_keys),
"unexpected_keys": list(incompatible_keys.unexpected_keys),
}
if return_metadata:
return model, metadata
return model
[docs]
def push_to_hub(
model_or_directory: Any,
repo_id: str,
*,
save_directory: Optional[PathLike] = None,
private: Optional[bool] = None,
token: Optional[str] = None,
revision: Optional[str] = None,
commit_message: str = "Upload pimm model",
**save_kwargs: Any,
):
"""Upload an exported pimm model directory to the Hugging Face Hub.
``model_or_directory`` can already be an exported directory or any input
accepted by ``save_pretrained``. Non-directory inputs are exported to a
temporary or provided save directory before upload.
"""
try:
from huggingface_hub import HfApi
except ImportError as exc: # pragma: no cover - depends on optional package
raise ImportError("huggingface_hub is required for push_to_hub") from exc
api = HfApi(token=token)
api.create_repo(repo_id=repo_id, repo_type="model", private=private, exist_ok=True)
if revision is not None:
api.create_branch(repo_id=repo_id, repo_type="model", branch=revision, exist_ok=True)
temp_dir = None
path = Path(model_or_directory) if isinstance(model_or_directory, (str, Path)) else None
is_export_dir = path is not None and path.is_dir() and any(
(path / name).is_file() for name in EXPORT_WEIGHT_NAMES
)
if is_export_dir:
export_dir = path
else:
if save_directory is None:
temp_dir = tempfile.TemporaryDirectory()
export_dir = Path(temp_dir.name)
else:
export_dir = Path(save_directory)
save_pretrained(model_or_directory, export_dir, **save_kwargs)
try:
return api.upload_folder(
repo_id=repo_id,
repo_type="model",
folder_path=str(export_dir),
commit_message=commit_message,
revision=revision,
allow_patterns=[*EXPORT_WEIGHT_NAMES, *EXPORT_CONFIG_READ_NAMES, "README.md"],
)
finally:
if temp_dir is not None:
temp_dir.cleanup()