Skip to content

Commit 6aa5158

Browse files
authored
feat: refactor inference (#338)
* feat: refactor inference * test coverage * test coverage * add 'tests' extra so skops and scikit-learn get installed in CI
1 parent f9e7614 commit 6aa5158

13 files changed

Lines changed: 2138 additions & 242 deletions

File tree

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ install:
88
uv run pre-commit install
99

1010
install-no-pre-commit:
11-
uv pip install ".[dev,distill,inference,train,onnx,quantization,integration]"
11+
uv pip install ".[dev,distill,train,onnx,quantization,integration,tests]"
1212

1313
install-base:
1414
uv sync --extra dev

model2vec/inference/README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Inference
22

3-
This subpackage mainly contains helper functions for inference with trained models that have been exported to `scikit-learn` compatible pipelines.
3+
This subpackage mainly contains helper functions for inference with trained classifier/projector heads, persisted as a `safetensors` file and `config.json` metadata.
44

55
If you're looking for information on how to train a model, see [here](../train/README.md).
66

@@ -16,3 +16,16 @@ label = classifier.predict("Attitudes towards cattle in the Alps: a study in let
1616
```
1717

1818
This should just work.
19+
20+
# Migrating a legacy pipeline
21+
22+
Pipelines saved by older versions of model2vec store the head as a `scikit-learn`/`skops` `pipeline.skops` file instead of `head.safetensors`. `from_pretrained` still loads these automatically, falling back to the legacy format and emitting a warning. This requires `scikit-learn` and `skops` to be installed.
23+
24+
To upgrade a pipeline to the current format (and silence the warning), convert it with `convert_legacy_pipeline` and save the result:
25+
26+
```python
27+
from model2vec.inference import convert_legacy_pipeline
28+
29+
pipeline = convert_legacy_pipeline("path/or/repo-id/of/legacy/pipeline")
30+
pipeline.save_pretrained("path/to/save")
31+
```

model2vec/inference/__init__.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,4 @@
1-
from model2vec.utils import get_package_extras, importable
1+
from model2vec.inference.evaluation import evaluate_single_or_multi_label
2+
from model2vec.inference.model import StaticModelPipeline, convert_legacy_pipeline
23

3-
_REQUIRED_EXTRA = "inference"
4-
5-
for extra_dependency in get_package_extras("model2vec", _REQUIRED_EXTRA):
6-
importable(extra_dependency, _REQUIRED_EXTRA)
7-
8-
from model2vec.inference.model import StaticModelPipeline, evaluate_single_or_multi_label
9-
10-
__all__ = ["StaticModelPipeline", "evaluate_single_or_multi_label"]
4+
__all__ = ["StaticModelPipeline", "convert_legacy_pipeline", "evaluate_single_or_multi_label"]

model2vec/inference/evaluation.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Iterable, Sequence
4+
from typing import Any, cast
5+
6+
import numpy as np
7+
8+
9+
def _is_multi_label_shaped(y: list[int] | list[str] | list[list[int]] | list[list[str]]) -> bool:
10+
"""Check if the labels are in a multi-label shape."""
11+
return isinstance(y, (list, tuple)) and len(y) > 0 and isinstance(y[0], (list, tuple, set))
12+
13+
14+
def _one_hot(labels: Sequence[Any], classes: Sequence[Any]) -> np.ndarray:
15+
"""One-hot encode a flat sequence of labels against a fixed set of classes."""
16+
index = {label: position for position, label in enumerate(classes)}
17+
encoded = np.zeros((len(labels), len(classes)), dtype=int)
18+
for row, label in enumerate(labels):
19+
encoded[row, index[label]] = 1
20+
return encoded
21+
22+
23+
def _multi_hot(label_lists: Iterable[Iterable[Any]], classes: Sequence[Any]) -> np.ndarray:
24+
"""Multi-hot encode a sequence of label lists against a fixed set of classes."""
25+
index = {label: position for position, label in enumerate(classes)}
26+
label_lists = list(label_lists)
27+
encoded = np.zeros((len(label_lists), len(classes)), dtype=int)
28+
for row, labels in enumerate(label_lists):
29+
for label in labels:
30+
encoded[row, index[label]] = 1
31+
return encoded
32+
33+
34+
def _precision_recall_f1_support(
35+
y_true: np.ndarray, y_pred: np.ndarray
36+
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
37+
"""Compute per-class precision, recall, f1, and support from one-hot / multi-hot encoded labels."""
38+
true_positive = ((y_true == 1) & (y_pred == 1)).sum(axis=0).astype(float)
39+
false_positive = ((y_true == 0) & (y_pred == 1)).sum(axis=0).astype(float)
40+
false_negative = ((y_true == 1) & (y_pred == 0)).sum(axis=0).astype(float)
41+
support = y_true.sum(axis=0)
42+
43+
predicted_positive = true_positive + false_positive
44+
actual_positive = true_positive + false_negative
45+
precision = np.divide(
46+
true_positive, predicted_positive, out=np.zeros_like(true_positive), where=predicted_positive > 0
47+
)
48+
recall = np.divide(true_positive, actual_positive, out=np.zeros_like(true_positive), where=actual_positive > 0)
49+
precision_plus_recall = precision + recall
50+
f1 = np.divide(
51+
2 * precision * recall, precision_plus_recall, out=np.zeros_like(precision), where=precision_plus_recall > 0
52+
)
53+
54+
return precision, recall, f1, support
55+
56+
57+
def evaluate_single_or_multi_label(
58+
predictions: np.ndarray,
59+
y: list[int] | list[str] | list[list[int]] | list[list[str]],
60+
) -> dict[str, dict[str, float]]:
61+
"""Evaluate the classifier on a given dataset using a classification report.
62+
63+
This function computes per-class precision, recall and f1-score (via one-vs-rest / multi-hot encoding), plus
64+
overall accuracy, macro average, and weighted average.
65+
66+
:param predictions: The predictions.
67+
:param y: The ground truth labels.
68+
:return: A classification report, as a dictionary.
69+
"""
70+
if _is_multi_label_shaped(y):
71+
y = cast(list[list[str]] | list[list[int]], y)
72+
predictions = cast(np.ndarray, predictions)
73+
y_labels = {label for labels in y for label in labels}
74+
predicted_labels = {label for labels in predictions for label in labels}
75+
classes = sorted(y_labels | predicted_labels)
76+
y_transformed = _multi_hot(y, classes)
77+
predictions_transformed = _multi_hot(predictions, classes)
78+
else:
79+
y = cast(list[str] | list[int], y)
80+
classes = sorted(set(y) | set(predictions.tolist()))
81+
y_transformed = _one_hot(y, classes)
82+
predictions_transformed = _one_hot(predictions.tolist(), classes)
83+
84+
target_names = [str(c) for c in classes]
85+
precision, recall, f1, support = _precision_recall_f1_support(y_transformed, predictions_transformed)
86+
total_support = float(support.sum())
87+
accuracy = float(np.all(y_transformed == predictions_transformed, axis=1).mean())
88+
89+
report: dict[str, Any] = {
90+
name: {
91+
"precision": float(precision[idx]),
92+
"recall": float(recall[idx]),
93+
"f1-score": float(f1[idx]),
94+
"support": float(support[idx]),
95+
}
96+
for idx, name in enumerate(target_names)
97+
}
98+
report["accuracy"] = accuracy
99+
report["macro avg"] = {
100+
"precision": float(precision.mean()),
101+
"recall": float(recall.mean()),
102+
"f1-score": float(f1.mean()),
103+
"support": total_support,
104+
}
105+
weights = support / total_support if total_support > 0 else np.zeros_like(support, dtype=float)
106+
report["weighted avg"] = {
107+
"precision": float((precision * weights).sum()),
108+
"recall": float((recall * weights).sum()),
109+
"f1-score": float((f1 * weights).sum()),
110+
"support": total_support,
111+
}
112+
113+
return report

model2vec/inference/mlp.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from enum import Enum
5+
6+
import numpy as np
7+
8+
9+
class Activation(str, Enum):
10+
SOFTMAX = "softmax"
11+
SIGMOID = "sigmoid"
12+
IDENTITY = "identity"
13+
14+
15+
def _softmax(x: np.ndarray) -> np.ndarray:
16+
"""Numerically stable softmax over the last axis."""
17+
shifted = x - x.max(axis=-1, keepdims=True)
18+
exponentiated = np.exp(shifted)
19+
return exponentiated / exponentiated.sum(axis=-1, keepdims=True)
20+
21+
22+
def _sigmoid(x: np.ndarray) -> np.ndarray:
23+
"""Numerically stable sigmoid."""
24+
return np.where(x >= 0, 1 / (1 + np.exp(-x)), np.exp(x) / (1 + np.exp(x)))
25+
26+
27+
@dataclass
28+
class Layer:
29+
weight: np.ndarray
30+
bias: np.ndarray
31+
32+
def __call__(self, x: np.ndarray) -> np.ndarray:
33+
"""Apply the linear transformation."""
34+
return x @ self.weight.T + self.bias
35+
36+
37+
class MLPHead:
38+
def __init__(
39+
self,
40+
layers: list[Layer],
41+
activation: Activation,
42+
classes: np.ndarray | None = None,
43+
) -> None:
44+
"""An MLP with ReLU activation.
45+
46+
:param layers: The linear layers, in order.
47+
:param activation: The output activation.
48+
:param classes: The classes, if the task is a classification task.
49+
"""
50+
self.layers = layers
51+
self.activation = activation
52+
self.classes_ = classes
53+
54+
def _logits(self, X: np.ndarray) -> np.ndarray:
55+
"""Run the forward through the layers."""
56+
out = X
57+
*hidden_layers, last_layer = self.layers
58+
for layer in hidden_layers:
59+
out = np.maximum(layer(out), 0.0)
60+
return last_layer(out)
61+
62+
def predict_proba(self, X: np.ndarray) -> np.ndarray:
63+
"""Predict probabilities, applying the output activation to the raw logits."""
64+
logits = self._logits(X)
65+
match self.activation:
66+
case Activation.SOFTMAX:
67+
return _softmax(logits)
68+
case Activation.SIGMOID:
69+
return _sigmoid(logits)
70+
case Activation.IDENTITY:
71+
return logits
72+
73+
def predict_index(self, X: np.ndarray) -> np.ndarray:
74+
"""Predict the index of the most likely class."""
75+
return self._logits(X).argmax(axis=1)
76+
77+
def predict_regression(self, X: np.ndarray) -> np.ndarray:
78+
"""Predict the raw (identity-activation) output, e.g. for a projector head."""
79+
return self._logits(X)

0 commit comments

Comments
 (0)