Source code for personalitylinmult.inference.mlflow_pyfunc

"""Wrap an FI checkpoint as an MLflow ``pyfunc`` model and register it.

Training logs raw ``.ckpt`` artifacts, which ``mlflow models serve`` cannot load
on their own. This module wraps a checkpoint in the standard ``pyfunc`` flavour —
a self-contained model MLflow can serve as a REST endpoint or bake into a Docker
image — and registers it in the Model Registry so it carries a version.

Promotion uses **aliases** (:func:`set_champion`), not the deprecated Staging /
Production stage mechanism: an alias is scoped to one registered-model *name*,
so each model family (e.g. the AVT fusion model and the audio-only model) keeps
its own independent ``@champion`` without any conflict, and moving the alias to
a new version is a single, deliberate call -- never automatic on registration.

The wrapped model consumes the same per-modality features as
:class:`~personalitylinmult.inference.predictor.FiPredictor`: at serving time the
JSON payload is ``{"inputs": {"wavlm": [[...]], "emotion2vec": [[...]]}}`` — one
``(T, F)`` array per feature the model expects.
"""

from __future__ import annotations

import logging
from pathlib import Path
from typing import Any

import numpy as np

logger = logging.getLogger(__name__)
"""Module-level logger."""


[docs] class FiPyfunc: """An ``mlflow.pyfunc.PythonModel`` around an FI checkpoint. Not typed as a subclass at import time so this module stays importable without mlflow installed; :func:`log_and_register` binds it to ``mlflow.pyfunc.PythonModel`` where mlflow is guaranteed present. """
[docs] def load_context(self, context: Any) -> None: """Load the checkpoint packaged with the model. Args: context: MLflow context; ``context.artifacts['checkpoint']`` is the packaged ``.ckpt`` path. """ from personalitylinmult.inference.predictor import FiPredictor self.predictor = FiPredictor.from_checkpoint(context.artifacts["checkpoint"])
[docs] def predict(self, context: Any, model_input: Any, params: Any = None) -> list[dict[str, float]]: # noqa: ARG002 -- context/params are required by the mlflow pyfunc predict signature """Predict Big Five scores for one or more samples. Args: context: MLflow context (unused; the model is already loaded). model_input: A ``{feature_name: (T, F) array}`` mapping for one sample, or a list of such mappings for a batch. params: Optional inference params (unused). Returns: list[dict[str, float]]: One ``{trait: score}`` dict per input sample. """ samples = model_input if isinstance(model_input, list) else [model_input] results = [] for sample in samples: features = {name: np.asarray(array) for name, array in sample.items()} results.append(self.predictor.predict(features)) return results
def _mlflow_model() -> Any: """Return an ``mlflow.pyfunc.PythonModel`` instance wrapping :class:`FiPyfunc`. Returns: A pyfunc model MLflow can log. Built here so the class body above does not import mlflow at module import time. """ import mlflow.pyfunc # Compose rather than subclass at definition time so this module imports # without mlflow; the delegate holds the real logic. delegate = FiPyfunc() class _Model(mlflow.pyfunc.PythonModel): def load_context(self, context): delegate.load_context(context) def predict(self, context, model_input, params=None): return delegate.predict(context, model_input, params) return _Model() REGISTRATION_EXPERIMENT = "FI-Model-Registry" """Experiment the pyfunc registration runs log under. Set explicitly so registration never falls back to the default experiment (id 0), which may be in a ``deleted`` state and would reject a new run. """
[docs] def log_and_register( checkpoint: str | Path, registered_model_name: str, run_id: str | None = None, artifact_path: str = "pyfunc_model", experiment: str = REGISTRATION_EXPERIMENT, ) -> str: """Log the checkpoint as a pyfunc model and register it. Args: checkpoint (str | Path): Path to the ``.ckpt`` to wrap. registered_model_name (str): Name in the Model Registry (e.g. ``"fi-bigfive-avt"``). run_id (str | None): Existing run to log under; a new run is started under ``experiment`` when ``None``. artifact_path (str): Artifact subpath for the model. experiment (str): Experiment to log the registration run under when ``run_id`` is ``None``. Returns: str: The registered model version. Raises: FileNotFoundError: If the checkpoint does not exist. """ import mlflow checkpoint = Path(checkpoint) if not checkpoint.is_file(): raise FileNotFoundError(f"No checkpoint at {checkpoint}.") # Ensure an active experiment so a fresh registration run is not dropped into # the (possibly deleted) default experiment. if run_id is None: mlflow.set_experiment(experiment) with mlflow.start_run(run_id=run_id): info = mlflow.pyfunc.log_model( artifact_path=artifact_path, python_model=_mlflow_model(), artifacts={"checkpoint": str(checkpoint)}, registered_model_name=registered_model_name, code_paths=[str(Path(__file__).resolve().parents[1])], ) version = _latest_version(registered_model_name) logger.info( f"Registered {registered_model_name} v{version} from {checkpoint} " f"(model_uri={info.model_uri})." ) return version
def _latest_version(registered_model_name: str) -> str: """Return the newest version number of a registered model. Args: registered_model_name (str): The registered model name. Returns: str: The highest version number as a string. """ import mlflow client = mlflow.tracking.MlflowClient() versions = client.search_model_versions(f"name = '{registered_model_name}'") return str(max(int(v.version) for v in versions))
[docs] def set_champion(registered_model_name: str, version: str, alias: str = "champion") -> None: """Promote a registered version to an alias. A deliberate, separate step from registration: a new version is never auto-promoted, so a run finishing (or even being registered) never silently changes what a deployment currently serves. Aliases are per registered-model name, so multiple model families (AVT, audio-only, ...) each carry their own independent ``@champion`` with no conflict. Args: registered_model_name (str): The registered model name. version (str): The version to promote. alias (str): Alias to set. Defaults to ``"champion"``; a second alias (e.g. ``"candidate"``) can stage a version before promoting it. """ import mlflow client = mlflow.tracking.MlflowClient() client.set_registered_model_alias(registered_model_name, alias, version) logger.info(f"{registered_model_name}@{alias} -> v{version}.")