Source code for personalitylinmult.inference.predictor
"""Load a trained FI checkpoint and predict Big Five scores from features.
A checkpoint is self-describing: :meth:`FiLightningModule.load_from_checkpoint`
rebuilds the exact model from the ``feature_names`` / ``input_feature_dim`` /
configs stored in its hyperparameters, so :class:`FiPredictor` needs no external
metadata — the path alone is enough.
The predictor is deliberately model-agnostic (the checkpoint decides which
features it consumes and which traits it emits), so the same class serves the
audio-only (A) and full multimodal (AVT) models without a code change.
"""
from __future__ import annotations
import logging
from pathlib import Path
import numpy as np
import torch
from personalitylinmult.train.metrics import clamp_predictions
from personalitylinmult.train.module import FiLightningModule
logger = logging.getLogger(__name__)
"""Module-level logger."""
[docs]
class InferenceError(Exception):
"""Raised when features do not match what the checkpoint expects."""
[docs]
class FiPredictor:
"""Predicts Big Five trait scores from per-modality features.
Args:
module (FiLightningModule): A loaded, eval-mode Lightning module.
Attributes:
feature_names (list[str]): Features this model consumes, in input order.
traits (list[str]): Trait names, in output-column order.
"""
def __init__(self, module: FiLightningModule):
self.module = module.eval()
self.feature_names: list[str] = list(module.feature_names)
self.traits: list[str] = list(module.train_config.targets)
[docs]
@classmethod
def from_checkpoint(cls, path: str | Path, map_location: str = "cpu") -> FiPredictor:
"""Load a predictor from a training checkpoint.
Args:
path (str | Path): Path to a ``.ckpt`` written by training.
map_location (str): Device to map tensors onto. ``"cpu"`` keeps
inference portable (a demo host rarely has the training GPU).
Returns:
FiPredictor: Ready to predict.
Raises:
FileNotFoundError: If the checkpoint does not exist.
"""
path = Path(path)
if not path.is_file():
raise FileNotFoundError(f"No checkpoint at {path}.")
module = FiLightningModule.load_from_checkpoint(str(path), map_location=map_location)
logger.info(
f"Loaded {type(module.model).__name__} from {path} "
f"(features={module.feature_names}, traits={list(module.train_config.targets)})."
)
return cls(module)
def _to_batch(
self, features: dict[str, np.ndarray]
) -> tuple[list[torch.Tensor], list[torch.Tensor]]:
"""Turn a ``{name: (T, F)}`` mapping into the model's ``(inputs, masks)``.
Each feature is padded to a batch of one and paired with an all-valid
mask: a caller supplying features has already trimmed padding, so every
provided timestep is real.
Args:
features (dict[str, np.ndarray]): One ``(T, F)`` array per feature
the model expects.
Returns:
tuple: ``(inputs, masks)``, each a list of one ``(1, T, F)`` /
``(1, T)`` tensor per feature, in model input order.
Raises:
InferenceError: If a required feature is missing, an extra feature is
supplied, or an array is not 2-D.
"""
missing = [name for name in self.feature_names if name not in features]
if missing:
raise InferenceError(
f"Missing features {missing}. This model needs {self.feature_names}."
)
extra = [name for name in features if name not in self.feature_names]
if extra:
raise InferenceError(
f"Unexpected features {extra}. This model consumes only {self.feature_names}."
)
inputs: list[torch.Tensor] = []
masks: list[torch.Tensor] = []
for name in self.feature_names:
array = np.asarray(features[name])
if array.ndim != 2:
raise InferenceError(
f"Feature {name!r} must be 2-D (T, F); got shape {array.shape}."
)
tensor = torch.from_numpy(array).float().unsqueeze(0) # (1, T, F)
inputs.append(tensor)
masks.append(torch.ones(tensor.shape[:2], dtype=torch.bool)) # (1, T)
return inputs, masks
[docs]
@torch.inference_mode()
def predict(self, features: dict[str, np.ndarray]) -> dict[str, float]:
"""Predict Big Five scores for one sample.
Args:
features (dict[str, np.ndarray]): One ``(T, F)`` array per feature the
model expects (see :attr:`feature_names`).
Returns:
dict[str, float]: ``{trait: score}`` with scores clamped to ``[0, 1]``,
in the model's trait order.
Raises:
InferenceError: If the features do not match the model.
"""
inputs, masks = self._to_batch(features)
preds = clamp_predictions(self.module(inputs, masks)) # (1, M)
row = preds.squeeze(0).tolist()
return dict(zip(self.traits, row, strict=True))