Source code for model.model_object
from __future__ import annotations
from abc import ABC, abstractmethod
from functools import wraps
import numpy as np
import pandas as pd
import torch
from sklearn.ensemble import RandomForestClassifier
from dataset.dataset_object import DatasetObject
[docs]
def process_nan():
"""Decorator factory for :meth:`ModelObject.get_prediction`.
Wraps a prediction method so that any input row containing ``NaN`` (the
convention for a failed counterfactual) is temporarily zero-filled before
inference and its output is forced to ``-1`` afterwards, keeping invalid
rows from contaminating predictions.
Returns
-------
callable
A decorator to apply to a ``get_prediction(self, X, ...)`` method.
"""
def decorator(func):
@wraps(func)
def wrapper(self, X: pd.DataFrame, *args, **kwargs):
X_work = X.copy(deep=True)
nan_rows = X_work.isna().any(axis=1)
if nan_rows.any():
X_work.loc[nan_rows, :] = 0.0
y = func(self, X_work, *args, **kwargs)
if nan_rows.any():
y = y.clone()
y[nan_rows.to_numpy()] = -1
return y
return wrapper
return decorator
[docs]
class ModelObject(ABC):
"""Base class for a target classifier that recourse methods explain.
Subclasses implement :meth:`fit`, :meth:`get_prediction`, and
:meth:`forward`. The :meth:`predict`/:meth:`predict_proba` methods are
inherited batching wrappers over :meth:`get_prediction`. Differentiable
(torch) models implement :meth:`forward` for gradient-based recourse;
tree/sklearn models may raise from :meth:`forward`. ``device`` must match
the recourse method's device.
Attributes
----------
_need_grad : bool
Whether the model supports differentiable (gradient) access.
_is_trained : bool
Set to ``True`` by :meth:`fit`; guards inference.
"""
_model: torch.nn.Module | RandomForestClassifier
_seed: int | None = None
_device: str
_need_grad: bool
_is_trained: bool = False
_class_to_index: dict[int | str, int] | None = None
@abstractmethod
def __init__(self, seed: int | None = None, device: str = "cpu", **kwargs):
"""Configure the model.
Parameters
----------
seed : int, optional
Seed for weight initialization and training.
device : str, default "cpu"
Device for torch models (``"cpu"`` or ``"cuda"``).
**kwargs
Implementation-specific hyperparameters.
"""
raise NotImplementedError
[docs]
@abstractmethod
def fit(self, trainset: DatasetObject | None):
"""Train the model on a frozen training dataset.
Parameters
----------
trainset : DatasetObject or None
Finalized training data. Implementations should set
``self._is_trained = True`` on success.
"""
raise NotImplementedError
[docs]
@abstractmethod
def get_prediction(self, X: pd.DataFrame, proba: bool = True) -> torch.Tensor:
"""Predict on a feature dataframe.
Parameters
----------
X : pandas.DataFrame
Feature rows (no target column).
proba : bool, default True
If ``True`` return class probabilities; otherwise return logits.
Returns
-------
torch.Tensor
A ``(n_rows, n_classes)`` tensor. Typically decorated with
:func:`process_nan`.
"""
raise NotImplementedError
[docs]
def predict(self, testset: DatasetObject, batch_size: int = 20) -> torch.Tensor:
"""Batched logits over a frozen dataset.
Parameters
----------
testset : DatasetObject
Frozen dataset to predict on.
batch_size : int, default 20
Rows per inference batch.
Returns
-------
torch.Tensor
Concatenated ``(n_rows, n_classes)`` logits on CPU.
Raises
------
RuntimeError
If the model is not trained.
"""
if not self._is_trained:
raise RuntimeError("Target model is not trained")
X = testset.get(target=False)
outputs: list[torch.Tensor] = []
for start in range(0, len(X), batch_size):
batch = X.iloc[start : start + batch_size]
outputs.append(self.get_prediction(batch, proba=False).detach().cpu())
return torch.cat(outputs, dim=0) if outputs else torch.empty(0)
[docs]
def predict_proba(
self, testset: DatasetObject, batch_size: int = 20
) -> torch.Tensor:
"""Batched class probabilities over a frozen dataset.
Parameters
----------
testset : DatasetObject
Frozen dataset to predict on.
batch_size : int, default 20
Rows per inference batch.
Returns
-------
torch.Tensor
Concatenated ``(n_rows, n_classes)`` probabilities on CPU.
"""
if not self._is_trained:
raise RuntimeError("Target model is not trained")
X = testset.get(target=False)
outputs: list[torch.Tensor] = []
for start in range(0, len(X), batch_size):
batch = X.iloc[start : start + batch_size]
outputs.append(self.get_prediction(batch, proba=True).detach().cpu())
return torch.cat(outputs, dim=0) if outputs else torch.empty(0)
[docs]
@abstractmethod
def forward(self, X: torch.Tensor) -> torch.Tensor:
"""Differentiable forward pass on a feature tensor.
Parameters
----------
X : torch.Tensor
Batch of feature rows.
Returns
-------
torch.Tensor
Logits. Non-differentiable models should raise ``RuntimeError``.
"""
raise NotImplementedError
[docs]
def __call__(self, X: torch.Tensor) -> torch.Tensor:
"""Alias for :meth:`forward`."""
return self.forward(X)
[docs]
def get_class_to_index(self) -> dict[int | str, int]:
"""Return the mapping from dataset label to model output index.
Returns
-------
dict
Copy of the label-to-index mapping established during :meth:`fit`.
Raises
------
RuntimeError
If the mapping is unavailable (model not yet trained).
"""
if self._class_to_index is None:
raise RuntimeError("Target model class mapping is unavailable")
return dict(self._class_to_index)