# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 Andrew Hendel. Part of mojolearn, https://doi.org/10.5281/zenodo.22068632
"""Scoring functions: classification, regression, ranking and clustering metrics.
``mojolearn.metrics`` is shaped like ``sklearn.metrics``: plain functions, no
estimator class. Function and argument names follow scikit-learn, and the
work runs on the GPU. Where cuML has the same function, its defaults and
semantics are followed, and each function states where scikit-learn and cuML
differ. Three differences matter most:
* ``entropy`` and ``mutual_info_score`` are in nats.
* ``r2_score`` returns scikit-learn's finite values for constant targets
(``force_finite=True``) by default.
* ``kl_divergence`` does not normalize ``P`` and ``Q``, as in cuML.
A parameter a function cannot honor raises an error naming it; nothing is
silently accepted and ignored. Inputs are checked here: non-finite values are
refused where the docstring says so.
Functions that take ``numeric_mode`` accept ``'identical'`` (the same bits
on NVIDIA and AMD), ``'deterministic'``, or ``'fast'`` (Apple only); None
uses the library default. Other functions run in the library default mode.
Examples
--------
>>> import numpy as np
>>> from mojolearn import metrics
>>> rng = np.random.default_rng(0)
>>> y_true = rng.integers(0, 2, 1000).astype(np.int32)
>>> y_pred = rng.integers(0, 2, 1000).astype(np.int32)
>>> acc = metrics.accuracy_score(y_true, y_pred)
"""
# Maintainer notes (moved from the module docstring):
# HISTORICAL QUALIFICATION (excludes new regression-error and A2
# classification kernels). The original metrics kernels were bit-identical
# across Apple M4, NVIDIA H100 and AMD MI325X at leg 11
# (`archive/evidence/E3_RESULTS.md` round 11, commit 144aa5b,
# `tools/e3_round_judge.sh` section 7, 34 card stages, both boxes) under
# MOJOLEARN_NUMERIC_MODE=identical. The card has since grown to 61 stages and
# the three-vendor leg on the grown card is owed (`metrics/README.md` Status).
# The FAST arm makes no cross-vendor claim; FAST cards differ between vendors.
# Evidence caveat: the two box cards are committed and byte-identical
# (`bench/results/e1/2026-08-23_165142-mojolearn-e2-nv/lanes/metrics.identical.card`
# and its `..._172650-mojolearn-e2-amd` sibling, commit 144aa5b, 34 stages).
# The Apple reference directory that round was judged against is not in the
# repository (`archive/evidence/E3_RESULTS.md:253` names it `<mac 123452>`), so
# NVIDIA <-> AMD is reproducible from committed artifacts and the Apple
# comparisons rest on the recorded judge output.
# Input validation lives here deliberately: the Mojo entries do not scan their
# device inputs (`metrics/README.md` HAND-OFF ask 3), so these functions do the
# `check_array` work (finite X for silhouette and trustworthiness, finite
# y/y_pred for r2_score, finite non-negative P/Q for kl_divergence). The
# kernel-level answers for out-of-contract values (a NaN reaching `sil_op` as
# +0.0 through DEVIATION 656, r2_score's canonical NaN 0x7fc00000 through
# DEVIATION 657) are therefore not reachable from Python; they are gated in
# `metrics/checks/regression_metrics_check.mojo` and `silhouette_check.mojo`.
# cuML defaults come from the pinned v26.08.00 checkout.
from . import _portable_math as math
import numbers
from ._buffer import _materialize, _native, materialize_f32_lists
from ._labels import is_bool, flatten_labels, flat_view
from ._arrays import _addr, _addr_ro
from . import _backend
from ._array import Array
from ._buffer import addr, addr_ro, all_finite, as_f32_c, as_i64_c, empty
from ._labels import encode_labels, sorted_classes
from .linear_model import (
_buffer_format, _dtype_name, _flatten, _is_integer_labels, _shape_of,
)
__all__ = [
"accuracy_score",
"confusion_matrix",
"precision_score",
"recall_score",
"f1_score",
"log_loss",
"roc_auc_score",
"precision_recall_curve",
"adjusted_rand_score",
"completeness_score",
"entropy",
"fowlkes_mallows_score",
"homogeneity_completeness_v_measure",
"homogeneity_score",
"kl_divergence",
"mutual_info_score",
"mean_squared_error",
"mean_absolute_error",
"root_mean_squared_error",
"r2_score",
"rand_score",
"silhouette_samples",
"silhouette_score",
"trustworthiness",
"v_measure_score",
# the metrics lane (lane/metrics, 2026-09-27; python/mojolearn/_expansion_metrics.py)
"balanced_accuracy_score",
"class_likelihood_ratios",
"classification_report",
"cohen_kappa_score",
"fbeta_score",
"hamming_loss",
"jaccard_score",
"matthews_corrcoef",
"multilabel_confusion_matrix",
"precision_recall_fscore_support",
"zero_one_loss",
"d2_absolute_error_score",
"d2_pinball_score",
"d2_tweedie_score",
"explained_variance_score",
"max_error",
"mean_absolute_percentage_error",
"mean_gamma_deviance",
"mean_pinball_loss",
"mean_poisson_deviance",
"mean_squared_log_error",
"mean_tweedie_deviance",
"median_absolute_error",
"root_mean_squared_log_error",
"auc",
"average_precision_score",
"brier_score_loss",
"coverage_error",
"d2_brier_score",
"d2_log_loss_score",
"dcg_score",
"det_curve",
"hinge_loss",
"label_ranking_average_precision_score",
"label_ranking_loss",
"ndcg_score",
"roc_curve",
"top_k_accuracy_score",
"adjusted_mutual_info_score",
"calinski_harabasz_score",
"contingency_matrix",
"davies_bouldin_score",
"normalized_mutual_info_score",
"pair_confusion_matrix",
]
# ---------------------------------------------------------------------------
# Loading the binding, mode-aware.
# ---------------------------------------------------------------------------
# All metric calls share the estimator loader. Resolve the current default at
# call time; an explicit mode selects its own compiled artifact without changing
# the process default. The backend checks vendor provenance and never falls back.
def _get_binding(numeric_mode=None):
if numeric_mode is not None and (
not isinstance(numeric_mode, str)
or numeric_mode.strip().lower() not in ("fast", "deterministic", "identical")
):
raise ValueError("numeric_mode must be 'fast', 'deterministic' or 'identical'")
expected = (numeric_mode or _backend.default_mode()).strip().lower()
mod = _backend.binding("_mojolearn_metrics", expected)
read_mode = getattr(mod, "metrics_numeric_mode", None)
if read_mode is None:
read_mode = getattr(mod, "umap_numeric_mode", None)
if read_mode is not None:
actual = {0: "fast", 1: "identical", 2: "deterministic"}.get(read_mode())
if actual != expected:
raise RuntimeError(
f"mojolearn metrics: requested {expected}, binary reports {actual}; rebuild it"
)
return mod
# ---------------------------------------------------------------------------
# Array handling. `_buffer.py` owns the 2-D float32 contract and is used for
# X; these are its 1-D siblings, under the same rule: the returned Array MUST
# be kept alive in a local across the Mojo call, because the Mojo side takes
# a raw address, borrows it, and holds nothing after the call returns.
# NumPy-free since DEVIATION 2377: the shape and dtype questions are asked
# of the caller's object through `_shape_of` / the buffer protocol BEFORE
# it is converted, so every message below keeps its wording.
# ---------------------------------------------------------------------------
def _vector_shape(x, name):
"""`(n, column)`: the length of a 1-D input, or of an `(n, 1)` column,
which is accepted and flattened as before; every other shape refused."""
shape = _shape_of(x)
column = len(shape) == 2 and shape[1] == 1
if len(shape) != 1 and not column:
raise ValueError(
f"mojolearn metrics: {name} must be 1-D, got shape {shape}"
)
if shape[0] == 0:
raise ValueError(f"mojolearn metrics: {name} is empty")
return shape, column
def _is_float64(x):
"""`numpy.asarray(x).dtype == numpy.float64`: a buffer of format 'd', or a
list/tuple with a Python float among its leaves (NumPy made float64 of
that too)."""
fmt = _buffer_format(x)
if fmt is not None:
return fmt == "d"
return any(type(v) is float for v in _flatten(x)) # glue: type probe of a list argument
def _as_i32_1d(x, name):
shape, column = _vector_shape(x, name)
if not _is_integer_labels(x):
raise ValueError(
f"mojolearn metrics: {name} must be an integer label array, got "
f"{_dtype_name(x)}; the ported kernels take int32 labels"
)
a, _ = as_i64_c(x, ndim=len(shape), name=name)
if column:
a = a.reshape((shape[0],))
if a.min() < -(1 << 31) or a.max() > (1 << 31) - 1:
raise ValueError(
f"mojolearn metrics: {name} does not fit in int32; the implemented "
"kernels are the int32 instantiation (the int64 overload of "
"adjusted_rand_index is the same code at a wider type and is "
"not instantiated, metrics/NOT_IMPLEMENTED.tsv)"
)
# int64 -> int32 through `Array.astype`: the core helper `cast_elements`
# (DEVIATION 3100), else `array.array`'s item loop, which has no Python
# loop BODY but makes one Python int per element (69 ms per 1,000,000
# rows on an EPYC 7713 before the helper, 1.9 ms with it). Exact after
# the range check.
return a.astype("<i4")
def _as_f32_1d(x, name, *, require_finite=True):
shape, column = _vector_shape(x, name)
if _is_float64(x):
# Named rather than silent: cuML has a float64 overload of r2_score,
# kl_divergence and silhouette_score and this implementation does not, because
# Apple's GPU has no float64 (mojolearn-hardware-limits). The cast is
# the caller's to make, so the precision they run at is the precision
# they asked for.
raise TypeError(
f"mojolearn metrics: {name} is float64; the float64 overloads of "
"r2_score, kl_divergence and silhouette_score are NOT implemented "
"(no float64 on this GPU). Cast to float32 yourself so the "
"precision you run at is the one you chose."
)
out, _ = as_f32_c(x, ndim=len(shape), name=name)
if column:
out = out.reshape((shape[0],))
if require_finite and not all_finite(out):
raise ValueError(
f"mojolearn metrics: {name} contains NaN or infinity; refused "
"here (metrics/README.md HAND-OFF ask 3) rather than letting it "
"reach a kernel whose answer for it is a washed sentinel"
)
return out
def _sample_weight_f32(sample_weight, n, caller):
"""Weights for the pinned-sum weighted arms (`metrics/impl/weighted_scores.mojo`):
one finite, non-negative Float32 per sample with a positive total, the
rule `GradientBoosting.fit` applies to its own sample_weight. Cast to
Float32 like the estimators' weights. scikit-learn's `np.average` also
accepts negative weights; those are refused here by name."""
shape = _shape_of(sample_weight)
if len(shape) != 1:
raise ValueError(
f"mojolearn {caller}: sample_weight must be 1-D, got shape {shape}"
)
if shape[0] != n:
raise ValueError(
f"mojolearn {caller}: sample_weight has {shape[0]} entries for {n} samples"
)
w, _ = as_f32_c(sample_weight, ndim=1, name="sample_weight")
# `Array.min` / `Array.max` answer as `min()` / `max()` over the storage
# view did, without one Python float per weight (DEVIATION 3101).
if not all_finite(w) or w.min() < 0:
raise ValueError(
f"mojolearn {caller}: sample_weight must have finite nonnegative entries"
)
if not w.max() > 0:
raise ValueError(
f"mojolearn {caller}: sample_weight must have positive total weight"
)
return w
def _pair_1d(a, b, name_a, name_b, loader):
x = loader(a, name_a)
y = loader(b, name_b)
if x.shape[0] != y.shape[0]:
raise ValueError(
f"mojolearn metrics: {name_a} has {x.shape[0]} entries and "
f"{name_b} has {y.shape[0]}; they must be the same length"
)
return x, y
def _prepare_cluster_labels(labels_true, labels_pred):
"""cuML 26.08's `prepare_cluster_metric_inputs`
(`python/cuml/cuml/metrics/cluster/utils.py`), same behavior.
Both arrays are remapped onto the CONTIGUOUS range `[0, n_classes - 1]`
over the UNION of their distinct labels, and the range handed to the
kernel is `(0, n_classes - 1)`. That is what keeps the contingency
matrix `n_classes` square instead of `(max - min + 1)` square.
NOTE FOR ANYONE READING `metrics/README.md`'s HAND-OFF: that paragraph
says the Python side passes `min` and `max` of the two arrays "exactly
as cuML's .pyx files do". That is true of `entropy` and false of these
four at the 26.08 pin, where the remap above was introduced. The remap
is what runs here.
"""
yt = _as_i32_1d(labels_true, "labels_true")
yp = _as_i32_1d(labels_pred, "labels_pred")
if yt.shape[0] != yp.shape[0]:
raise ValueError(
f"mojolearn metrics: labels_true has {yt.shape[0]} entries and "
f"labels_pred has {yp.shape[0]}; they must be the same length"
)
# `np.unique` of the union, then `np.searchsorted`: the package-wide
# order rule (`_labels.sorted_classes`, DEVIATION 2340) over the
# concatenation and a dict lookup, O(rows) label loops (DEVIATION 2377).
fast = _native_union_codes(yt, yp)
if fast is None:
return yt, yp, 0, 0, -1 # both sides empty: no classes
return fast[0], fast[1], int(yt.shape[0]), 0, fast[2] - 1
#: DEVIATION 3107 (lane/python-hotpath, 2026-09-17). Below this many labels
#: the Python label routines were the only path; lane apple-fast-py2mojo-core
#: (2026-10-03) sends every non-empty label buffer to the native encoder.
_NATIVE_MIN_LABELS = 1
def _native_union_codes(yt, yp):
"""`(codes_true, codes_pred, n_classes)` over the sorted union of two
int32 label Arrays, or None when both are empty (lane pyglue-sweep: one
empty side is encoded too; the Python dict route is gone).
lane apple-fast-py2mojo-core (2026-10-03): the two arrays are laid end
to end and encoded ONCE by the base binding's `unique_inverse` (the
device sort, flag/scan compaction and gather on a GPU install, the core
host twin on a CPU-only one): its sorted distinct values ARE the sorted
union and its codes, split at len(yt), are each side's codes on it. No
class cap (the 4096-class encoder and the Python merge of the two class
lists are gone, and with them the dict fallback for more classes)."""
import ctypes
from ._labels import unique_inverse
nt, npred = int(yt.size), int(yp.size)
if nt + npred == 0:
return None
yt, yp = yt._as_c(), yp._as_c()
both = empty((nt + npred,), "<i4")
ctypes.memmove(_addr(both), _addr_ro(yt), 4 * nt)
ctypes.memmove(_addr(both) + 4 * nt, _addr_ro(yp), 4 * npred)
classes, codes = unique_inverse(both)
return codes[0:nt], codes[nt:nt + npred], int(classes.size)
# ===========================================================================
# Group A: the label metrics
# ===========================================================================
# Maintainer notes: backed by `ML::Metrics::accuracy_score_py` (cuML
# `accuracy_score.cu`): one fused subtract-and-count with an INTEGER atomic,
# exact and order-free on every vendor; the returned float32 is
# `count * (1 / n)`. The int32 instantiation is the one cuML's Python passes.
# sample_weight: pinned-sum path (metrics/impl/weighted_scores.mojo), the
# weights of agreeing samples and all weights folded by the PINNED_SUM_W tree,
# then one division; RAFT has no weighted arm and cuML weights in cupy, so this
# arm follows scikit-learn. normalize=False goes to the x_metrics binding.
# `n == 0` is refused inside the kernel (`count / n` is 0/0 in RAFT and a NaN
# may not reach a recorded value).
[docs]
def accuracy_score(
y_true, y_pred, *, normalize=True, sample_weight=None, numeric_mode=None
):
"""Accuracy: the fraction (or number) of positions where two label arrays agree.
Follows scikit-learn's ``accuracy_score``; the unweighted fraction uses
cuML's kernel. The count of matches is exact on every device.
Parameters
----------
y_true, y_pred : array-like of shape (n_samples,)
Integer labels of the same length that fit in int32. Larger labels
raise ``ValueError``. With ``normalize=False``, string labels are
accepted too.
normalize : bool, default True
True returns the fraction of matches. False returns the number of
matches (an int), or with ``sample_weight`` their summed weight.
sample_weight : array-like of shape (n_samples,), default None
Per-sample weights, as in scikit-learn's
``np.average(y_true == y_pred, weights=sample_weight)``, computed in
float32. Weights must be finite, non-negative and have a positive
total; negative weights, which scikit-learn accepts, raise
``ValueError``.
numeric_mode : {'identical', 'deterministic', 'fast'} or None, default None
Numeric mode for this call (``'fast'`` is Apple only). None uses the
library default.
Returns
-------
score : float or int
Raises
------
ValueError
For empty input or arrays of different lengths.
Examples
--------
>>> import numpy as np
>>> from mojolearn import metrics
>>> metrics.accuracy_score(np.array([0, 1, 1, 0], dtype=np.int32),
... np.array([0, 1, 0, 0], dtype=np.int32))
0.75
"""
if not is_bool(normalize):
raise ValueError("normalize must be a bool")
if not normalize:
# The COUNT (lane/metrics, 2026-09-27): exact integer matches, or the
# Float32 PairSum of the matching rows' weights, from the x_metrics
# binding (strings or integers, like scikit-learn). The fraction
# keeps its original kernel and bits.
from ._expansion_metrics import accuracy_count
return accuracy_count(y_true, y_pred, sample_weight, numeric_mode)
yt, yp = _pair_1d(y_true, y_pred, "y_true", "y_pred", _as_i32_1d)
if sample_weight is not None:
w = _sample_weight_f32(sample_weight, int(yt.shape[0]), "accuracy_score")
# ORDER MATCHES bindings/_mojolearn_metrics.mojo::accuracy_score_weighted_binding.
# n
return float(
_get_binding(numeric_mode).accuracy_score_weighted(
_addr_ro(yt), _addr_ro(yp), _addr_ro(w), [int(yt.shape[0])]
)
)
# ORDER MATCHES bindings/_mojolearn_metrics.mojo::accuracy_score_binding.
# n
return float(
_get_binding(numeric_mode).accuracy_score(
_addr_ro(yt), _addr_ro(yp), [int(yt.shape[0])]
)
)
# Maintainer notes: backed by `ML::Metrics::rand_index` (cuML
# `rand_index.cu`, DEVIATION 652: per-block partials plus a host Int64 sum,
# because Apple has no 64-bit float atomic). Raw labels, no label range on
# this entry. cuML's Python exports only adjusted_rand_score; the unadjusted
# index has a C++ entry and is exposed under scikit-learn's name.
[docs]
def rand_score(labels_true, labels_pred):
"""Rand index: the fraction of sample pairs on which two clusterings agree.
Follows scikit-learn's ``rand_score``. Labels are used as given; they
do not need to be contiguous.
Parameters
----------
labels_true, labels_pred : array-like of shape (n_samples,)
Integer cluster labels of the same length that fit in int32.
Returns
-------
score : float
Examples
--------
>>> import numpy as np
>>> from mojolearn import metrics
>>> score = metrics.rand_score(np.array([0, 0, 1, 1], dtype=np.int32),
... np.array([1, 1, 0, 0], dtype=np.int32))
"""
yt, yp = _pair_1d(
labels_true, labels_pred, "labels_true", "labels_pred", _as_i32_1d
)
# ORDER MATCHES bindings/_mojolearn_metrics.mojo::rand_score_binding.
# n
return float(
_get_binding().rand_score(
addr_ro(yt, name="yt"), addr_ro(yp, name="yp"), [int(yt.shape[0])]
)
)
# Maintainer notes: backed by `ML::Metrics::adjusted_rand_index`, the int32
# instantiation (the int64 overload is the same code at a wider label type and
# is not instantiated). Builds its own contingency matrix over its own label
# range from raw labels, as cuML does. A difference of large numbers over a
# difference of large numbers: the three exact integers are card stages
# (`metrics.ari.pair_sums`), and the host epilogue is float64
# divide/multiply/subtract only, identity-safe in both modes.
[docs]
def adjusted_rand_score(labels_true, labels_pred):
"""Rand index adjusted for chance.
Follows scikit-learn's and cuML's ``adjusted_rand_score``: 1.0 for
identical clusterings (up to relabeling), about 0.0 for random ones.
Labels are used as given.
Parameters
----------
labels_true, labels_pred : array-like of shape (n_samples,)
Integer cluster labels of the same length that fit in int32.
Returns
-------
score : float
Examples
--------
>>> import numpy as np
>>> from mojolearn import metrics
>>> rng = np.random.default_rng(0)
>>> a = rng.integers(0, 4, 1000).astype(np.int32)
>>> score = metrics.adjusted_rand_score(a, a)
"""
yt, yp = _pair_1d(
labels_true, labels_pred, "labels_true", "labels_pred", _as_i32_1d
)
# ORDER MATCHES bindings/_mojolearn_metrics.mojo::adjusted_rand_score_binding.
# n
return float(
_get_binding().adjusted_rand_score(
addr_ro(yt, name="yt"), addr_ro(yp, name="yp"), [int(yt.shape[0])]
)
)
# Maintainer notes: backed by `ML::Metrics::entropy` (cuML `entropy.cu`, RAFT
# `entropy.cuh`, DEVIATIONS 650 and 651). `base` uses cuML's `entropy.pyx:72-74`
# spelling exactly, `math.log(math.exp(S), base)`, rather than the algebraically
# equal `S / math.log(base)`. The min..max histogram is cuML's own behavior
# (`entropy.pyx:61-62` passes min and max with no remapping, unlike the four
# cluster metrics). Under identical the float epilogue is Float32 through
# `identical_log` and differs from FAST's Float64 in the seventh digit by
# design (DEVIATION 651): `checks/numerics.mojo` has no portable Float64 log
# yet; `metrics/README.md` hand-off ask 1.
[docs]
def entropy(clustering, *, base=None):
"""Entropy of a labeling, in nats unless ``base`` is given.
Follows cuML's ``cuml.metrics.cluster.entropy``; scikit-learn has no
public equivalent.
The label histogram spans ``max(labels) - min(labels) + 1`` bins, not the
number of distinct labels, as in cuML. Sparse, large-valued labels
therefore allocate that many bins; encode them as ``0..k-1`` first if
that matters.
In the ``'identical'`` numeric mode the final logarithms run in float32,
so the result can differ from the other modes in about the seventh
significant digit.
Parameters
----------
clustering : array-like of shape (n_samples,)
Integer labels that fit in int32.
base : float or None, default None
Logarithm base. None means nats.
Returns
-------
entropy : float
Examples
--------
>>> import numpy as np
>>> from mojolearn import metrics
>>> h = metrics.entropy(np.array([0, 0, 1, 1], dtype=np.int32), base=2)
"""
lab = _as_i32_1d(clustering, "clustering")
lower = int(lab.min())
upper = int(lab.max())
# ORDER MATCHES bindings/_mojolearn_metrics.mojo::entropy_binding.
# n, lower_class_range, upper_class_range
value = float(
_get_binding().entropy(
addr_ro(lab, name="lab"), [int(lab.shape[0]), lower, upper]
)
)
if base is not None:
value = math.log(math.exp(value), base)
return value
# Maintainer notes: backed by `ML::Metrics::mutual_info_score` (DEVIATIONS
# 650 and 651): the contingency matrix is built with INTEGER atomics on the
# device, exact and order-free everywhere, and the `c (log(nc) - log(ab))`
# epilogue runs serially and ascending on the host. The labels are remapped
# as cuML 26.08's `prepare_cluster_metric_inputs` does.
[docs]
def mutual_info_score(labels_true, labels_pred, *, contingency=None):
"""Mutual information between two clusterings, in nats.
Follows scikit-learn's and cuML's ``mutual_info_score``, in nats as cuML
computes it. Both label arrays are first mapped onto ``0..k-1`` over the
union of their distinct labels, as cuML does. The contingency counts
are exact on every device.
Parameters
----------
labels_true, labels_pred : array-like of shape (n_samples,)
Integer cluster labels of the same length that fit in int32.
contingency : None, default None
A precomputed contingency matrix is not supported; passing one
raises ``NotImplementedError``.
Returns
-------
mi : float
Examples
--------
>>> import numpy as np
>>> from mojolearn import metrics
>>> mi = metrics.mutual_info_score(np.array([0, 0, 1, 1], dtype=np.int32),
... np.array([0, 0, 1, 2], dtype=np.int32))
"""
if contingency is not None:
raise NotImplementedError(
"mojolearn mutual_info_score: contingency= is refused; the "
"implemented entry builds the contingency matrix on the device "
"(metrics/impl/stats/detail/contingency_matrix.mojo) and has "
"no arm that consumes a precomputed one"
)
yt, yp, n, lower, upper = _prepare_cluster_labels(labels_true, labels_pred)
# ORDER MATCHES bindings/_mojolearn_metrics.mojo::mutual_info_score_binding.
# n, lower_class_range, upper_class_range
return float(
_get_binding().mutual_info_score(
addr_ro(yt, name="yt"), addr_ro(yp, name="yp"), [n, lower, upper]
)
)
# Maintainer notes: reference `sklearn/metrics/cluster/_supervised.py`. The
# contingency matrix is built with INTEGER atomics on the device (the
# mutual_info_score kernel); tk = sum_ij c_ij^2 - n, pk = sum_j colsum^2 - n,
# qk = sum_i rowsum^2 - n are exact Int64 sums on the host and the score is
# Float64.
[docs]
def fowlkes_mallows_score(labels_true, labels_pred, *, sparse="deprecated"):
"""Fowlkes-Mallows index between two clusterings.
Follows scikit-learn's ``fowlkes_mallows_score`` (cuML has no such
metric): the geometric mean of pairwise precision and recall,
``sqrt(tk / pk) * sqrt(tk / qk)``. Returns 0.0 when ``tk == 0`` (every
cluster a singleton, a single sample, or no samples), as scikit-learn
does. Labels are mapped onto ``0..k-1`` over their union first.
Parameters
----------
labels_true, labels_pred : array-like of shape (n_samples,)
Integer cluster labels of the same length that fit in int32.
sparse : default 'deprecated'
Deprecated and ignored in scikit-learn; any explicit value raises
``NotImplementedError``.
Returns
-------
score : float
Examples
--------
>>> import numpy as np
>>> from mojolearn import metrics
>>> score = metrics.fowlkes_mallows_score(np.array([0, 0, 1, 1], dtype=np.int32),
... np.array([0, 0, 1, 2], dtype=np.int32))
"""
if sparse != "deprecated":
raise NotImplementedError(
"mojolearn fowlkes_mallows_score: sparse= is refused; scikit-learn "
"deprecated and ignores it, and this surface has no sparse arm"
)
try:
empty = len(labels_true) == 0 and len(labels_pred) == 0
except TypeError:
empty = False
if empty:
# scikit-learn's check_clusterings allows no samples
# (ensure_min_samples=0); the empty contingency matrix gives
# tk == 0 and the score 0.0. The label loaders refuse empty input,
# so the case is answered here, before them.
return 0.0
yt, yp, n, lower, upper = _prepare_cluster_labels(labels_true, labels_pred)
# ORDER MATCHES bindings/_mojolearn_metrics.mojo::fowlkes_mallows_score_binding.
# n, lower_class_range, upper_class_range
return float(
_get_binding().fowlkes_mallows_score(
addr_ro(yt, name="yt"), addr_ro(yp, name="yp"), [n, lower, upper]
)
)
# Maintainer note: backed by `ML::Metrics::homogeneity_score`.
[docs]
def homogeneity_score(labels_true, labels_pred):
"""Homogeneity: whether each cluster holds members of a single class.
``MI(true, pred) / H(true)``, in ``[0, 1]``. Follows scikit-learn's and
cuML's ``homogeneity_score``. Labels are mapped onto ``0..k-1`` over their
union first, as cuML does.
Parameters
----------
labels_true, labels_pred : array-like of shape (n_samples,)
Integer labels of the same length that fit in int32.
Returns
-------
score : float
Examples
--------
>>> import numpy as np
>>> from mojolearn import metrics
>>> h = metrics.homogeneity_score(np.array([0, 0, 1, 1], dtype=np.int32),
... np.array([0, 0, 1, 2], dtype=np.int32))
"""
yt, yp, n, lower, upper = _prepare_cluster_labels(labels_true, labels_pred)
# ORDER MATCHES bindings/_mojolearn_metrics.mojo::homogeneity_score_binding.
# n, lower_class_range, upper_class_range
return float(
_get_binding().homogeneity_score(
addr_ro(yt, name="yt"), addr_ro(yp, name="yp"), [n, lower, upper]
)
)
# Maintainer notes: backed by `ML::Metrics::completeness_score`, which RAFT
# computes as homogeneity_score with the two arrays swapped. The swapped call
# folds the TRANSPOSED contingency matrix, so the host's serial ascending walk
# visits the cells in a different sequence: the same quantity in exact
# arithmetic, not necessarily the same float32 bits. Implemented as theirs;
# both folds are stages on the metrics card.
[docs]
def completeness_score(labels_true, labels_pred):
"""Completeness: whether all members of a class are in the same cluster.
``MI(pred, true) / H(pred)``, in ``[0, 1]``. Follows scikit-learn's and
cuML's ``completeness_score``. Labels are mapped onto ``0..k-1`` over
their union first, as cuML does.
Parameters
----------
labels_true, labels_pred : array-like of shape (n_samples,)
Integer labels of the same length that fit in int32.
Returns
-------
score : float
Examples
--------
>>> import numpy as np
>>> from mojolearn import metrics
>>> c = metrics.completeness_score(np.array([0, 0, 1, 1], dtype=np.int32),
... np.array([0, 0, 1, 2], dtype=np.int32))
"""
yt, yp, n, lower, upper = _prepare_cluster_labels(labels_true, labels_pred)
# ORDER MATCHES bindings/_mojolearn_metrics.mojo::completeness_score_binding.
# n, lower_class_range, upper_class_range
return float(
_get_binding().completeness_score(
addr_ro(yt, name="yt"), addr_ro(yp, name="yp"), [n, lower, upper]
)
)
# Maintainer note: backed by `ML::Metrics::v_measure` (cuML `v_measure.cu`).
[docs]
def v_measure_score(labels_true, labels_pred, *, beta=1.0):
"""V-measure: the weighted harmonic mean of homogeneity and completeness.
``(1 + beta) * h * c / (beta * h + c)``. Follows scikit-learn's and
cuML's ``v_measure_score``. Labels are mapped onto ``0..k-1`` over their
union first.
Parameters
----------
labels_true, labels_pred : array-like of shape (n_samples,)
Integer labels of the same length that fit in int32.
beta : float, default 1.0
Above 1 weights completeness more, below 1 weights homogeneity more.
Returns
-------
score : float
Examples
--------
>>> import numpy as np
>>> from mojolearn import metrics
>>> v = metrics.v_measure_score(np.array([0, 0, 1, 1], dtype=np.int32),
... np.array([0, 0, 1, 2], dtype=np.int32))
"""
yt, yp, n, lower, upper = _prepare_cluster_labels(labels_true, labels_pred)
# ORDER MATCHES bindings/_mojolearn_metrics.mojo::v_measure_score_binding.
# n, lower_class_range, upper_class_range, beta
return float(
_get_binding().v_measure_score(
addr_ro(yt, name="yt"), addr_ro(yp, name="yp"), [n, lower, upper, float(beta)]
)
)
[docs]
def homogeneity_completeness_v_measure(labels_true, labels_pred, *, beta=1.0):
"""Homogeneity, completeness and V-measure in one call.
Returns ``(homogeneity_score, completeness_score, v_measure_score)``, as
scikit-learn's function of the same name. Each value is computed by its
own call, so this costs about three separate metric calls; call the one
you need if speed matters.
Parameters
----------
labels_true, labels_pred : array-like of shape (n_samples,)
Integer labels of the same length that fit in int32.
beta : float, default 1.0
Passed to ``v_measure_score``.
Returns
-------
homogeneity, completeness, v_measure : float
Examples
--------
>>> import numpy as np
>>> from mojolearn import metrics
>>> h, c, v = metrics.homogeneity_completeness_v_measure(
... np.array([0, 0, 1, 1], dtype=np.int32), np.array([0, 0, 1, 2], dtype=np.int32))
"""
return (
homogeneity_score(labels_true, labels_pred),
completeness_score(labels_true, labels_pred),
v_measure_score(labels_true, labels_pred, beta=beta),
)
# ===========================================================================
# Group B: r2 and KL divergence
# ===========================================================================
# Maintainer notes: the 1-D default call is backed by `ML::Metrics::r2_score_py`,
# the float overload (DEVIATIONS 653 and 657). The three sums (y_bar, sse,
# ssto) are folded by a PINNED_SUM_W = 256 slab tree whose width is a constant
# of the source, not of the launch, gated launch-invariant across two block
# sizes and two grid shapes. float64 is refused: cuML has a double overload and
# this implementation does not (no float64 on Apple GPUs). sample_weight:
# pinned-sum path (metrics/impl/weighted_scores.mojo; RAFT's r2_score has no
# weighted arm), `sum w (y - y_pred)^2` over `sum w (y - y_avg)^2` with the same
# force_finite guards. force_finite=True: ssto == 0 returns 1.0 when sse == 0
# and 0.0 otherwise. multioutput, 2-D targets and force_finite=False go to the
# x_metrics binding, whose NaN / -inf are chosen by value in the binary64
# epilogue (DEVIATION 6103). An overflow-scale y (|y - y_hat| above ~1.8e19)
# returns the one canonical NaN payload 0x7fc00000 on every vendor.
[docs]
def r2_score(
y_true,
y_pred,
*,
sample_weight=None,
multioutput="uniform_average",
force_finite=True,
numeric_mode=None,
):
"""Coefficient of determination (R^2), computed in float32.
Follows scikit-learn's ``r2_score``. When the targets are constant
(total sum of squares 0), the default ``force_finite=True`` returns 1.0
for a perfect prediction and 0.0 otherwise, as scikit-learn and cuML do.
Parameters
----------
y_true, y_pred : array-like of shape (n_samples,) or (n_samples, n_outputs)
Finite float32 targets and predictions of the same shape. float64
arrays raise ``TypeError`` (for 1-D input, so do Python lists of
floats): cast to float32 first. NaN or infinity raises
``ValueError``.
sample_weight : array-like of shape (n_samples,), default None
Per-sample weights, as in scikit-learn. They must be finite,
non-negative and have a positive total.
multioutput : {'uniform_average', 'raw_values', 'variance_weighted'} or array-like, default 'uniform_average'
How to combine the scores of several outputs, as in scikit-learn.
force_finite : bool, default True
False returns scikit-learn's raw values for constant targets
(``nan`` or ``-inf``).
numeric_mode : {'identical', 'deterministic', 'fast'} or None, default None
Numeric mode for this call (``'fast'`` is Apple only). None uses the
library default.
Returns
-------
score : float or Array
A float, or one float64 value per output for ``'raw_values'``.
Examples
--------
>>> import numpy as np
>>> from mojolearn import metrics
>>> rng = np.random.default_rng(0)
>>> y = rng.random(1000, dtype=np.float32)
>>> pred = y + rng.normal(0, 0.1, 1000).astype(np.float32)
>>> r2 = metrics.r2_score(y, pred)
"""
if (not isinstance(multioutput, str) or multioutput != "uniform_average" or not force_finite
or len(_shape_of(y_true)) == 2 and _shape_of(y_true)[1] != 1):
# lane/metrics: multioutput ('raw_values', 'variance_weighted', an
# array of weights), 2-D targets and force_finite=False through the
# x_metrics binding. force_finite=False's NaN / -inf are chosen BY
# VALUE in the binary64 epilogue, never computed on a device, so no
# vendor payload reaches the result (DEVIATION 6103). The 1-D default
# call keeps its kernel and bits.
from ._expansion_metrics import r2_score_options
return r2_score_options(y_true, y_pred, sample_weight, multioutput, force_finite, numeric_mode)
y, yh = _pair_1d(y_true, y_pred, "y_true", "y_pred", _as_f32_1d)
if sample_weight is not None:
w = _sample_weight_f32(sample_weight, int(y.shape[0]), "r2_score")
# ORDER MATCHES bindings/_mojolearn_metrics.mojo::r2_score_weighted_binding.
# n
return float(
_get_binding(numeric_mode).r2_score_weighted(
_addr_ro(y), _addr_ro(yh), _addr_ro(w), [int(y.shape[0])]
)
)
# ORDER MATCHES bindings/_mojolearn_metrics.mojo::r2_score_binding.
# n
return float(
_get_binding(numeric_mode).r2_score(_addr_ro(y), _addr_ro(yh), [int(y.shape[0])])
)
def _as_regression_f32_1d(x, name):
# A plain Python list or tuple has no float32 spelling; it is cast once,
# the same rounding `as_f32_c` applies (`materialize_f32_lists`). A
# buffer keeps the float32-only contract (Sep 22 pip smoke: plain lists
# were refused as "must have dtype float32").
if _buffer_format(x) != "f" and not isinstance(x, (list, tuple)):
raise TypeError(f"mojolearn regression metrics: {name} must have dtype float32; cast explicitly before scoring")
a = materialize_f32_lists(x, "input")[0]
if a.dtype != "<f4":
raise TypeError(
f"mojolearn regression metrics: {name} must have dtype float32, "
f"got {a.dtype}; cast explicitly before scoring"
)
return _as_f32_1d(a, name)
def _regression_error(name, y_true, y_pred, sample_weight, multioutput, numeric_mode):
if (sample_weight is not None or not isinstance(multioutput, str)
or multioutput != "uniform_average" or len(_shape_of(y_true)) == 2 and _shape_of(y_true)[1] != 1):
# lane/metrics: sample_weight, multioutput and 2-D targets through the
# x_metrics binding; the 1-D unweighted call keeps its kernel and bits.
from ._expansion_metrics import regression_error_options
return regression_error_options(name, y_true, y_pred, sample_weight, multioutput, numeric_mode)
yt, yp = _pair_1d(y_true, y_pred, "y_true", "y_pred", _as_regression_f32_1d)
return float(getattr(_get_binding(numeric_mode), name)(
_addr_ro(yt), _addr_ro(yp), [int(yt.size)]
))
# Maintainer notes: IDENTICAL pins the reduction schedule and flushes
# subnormal arithmetic. The 1-D unweighted call keeps its own kernel; weights,
# multioutput and 2-D targets go to the x_metrics binding
# (regression_error_options). The new error metrics had local Metal
# qualification; the cross-vendor leg was pending when this was written.
[docs]
def mean_squared_error(
y_true, y_pred, *, sample_weight=None, multioutput="uniform_average", numeric_mode=None
):
"""Mean squared error, computed in float32.
Follows scikit-learn's ``mean_squared_error``. The residuals, their
squares and the sum are float32, with no wider accumulator, so very
large residuals can overflow to ``inf``.
Parameters
----------
y_true, y_pred : array-like of shape (n_samples,) or (n_samples, n_outputs)
Finite, nonempty float32 arrays of the same shape. A float64 or other
non-float32 buffer raises ``TypeError``: cast it first. Python lists
are converted to float32. NaN or infinity raises ``ValueError``.
sample_weight : array-like of shape (n_samples,), default None
Per-sample weights, as in scikit-learn.
multioutput : {'uniform_average', 'raw_values'} or array-like, default 'uniform_average'
How to combine several outputs, as in scikit-learn.
numeric_mode : {'identical', 'deterministic', 'fast'} or None, default None
Numeric mode for this call (``'fast'`` is Apple only). None uses the
library default.
Returns
-------
loss : float or Array
A float, or one float64 value per output for ``'raw_values'``.
Examples
--------
>>> import numpy as np
>>> from mojolearn import metrics
>>> y = np.array([3.0, -0.5, 2.0, 7.0], dtype=np.float32)
>>> pred = np.array([2.5, 0.0, 2.0, 8.0], dtype=np.float32)
>>> metrics.mean_squared_error(y, pred)
0.375
"""
return _regression_error(
"mean_squared_error", y_true, y_pred, sample_weight, multioutput, numeric_mode
)
[docs]
def mean_absolute_error(
y_true, y_pred, *, sample_weight=None, multioutput="uniform_average", numeric_mode=None
):
"""Mean absolute error, computed in float32.
Follows scikit-learn's ``mean_absolute_error``, with the same input and
overflow behavior as ``mean_squared_error``.
Parameters
----------
y_true, y_pred : array-like of shape (n_samples,) or (n_samples, n_outputs)
Finite, nonempty float32 arrays of the same shape. A float64 or other
non-float32 buffer raises ``TypeError``: cast it first. Python lists
are converted to float32. NaN or infinity raises ``ValueError``.
sample_weight : array-like of shape (n_samples,), default None
Per-sample weights, as in scikit-learn.
multioutput : {'uniform_average', 'raw_values'} or array-like, default 'uniform_average'
How to combine several outputs, as in scikit-learn.
numeric_mode : {'identical', 'deterministic', 'fast'} or None, default None
Numeric mode for this call (``'fast'`` is Apple only). None uses the
library default.
Returns
-------
loss : float or Array
A float, or one float64 value per output for ``'raw_values'``.
Examples
--------
>>> import numpy as np
>>> from mojolearn import metrics
>>> y = np.array([3.0, -0.5, 2.0, 7.0], dtype=np.float32)
>>> pred = np.array([2.5, 0.0, 2.0, 8.0], dtype=np.float32)
>>> metrics.mean_absolute_error(y, pred)
0.5
"""
return _regression_error(
"mean_absolute_error", y_true, y_pred, sample_weight, multioutput, numeric_mode
)
[docs]
def root_mean_squared_error(
y_true, y_pred, *, sample_weight=None, multioutput="uniform_average", numeric_mode=None
):
"""Root mean squared error, computed in float32.
Follows scikit-learn's ``root_mean_squared_error``: the square root of
``mean_squared_error``, with the same input behavior. The squares can
overflow to ``inf`` even when the true RMSE would fit in float32.
Parameters
----------
y_true, y_pred : array-like of shape (n_samples,) or (n_samples, n_outputs)
Finite, nonempty float32 arrays of the same shape. A float64 or other
non-float32 buffer raises ``TypeError``: cast it first. Python lists
are converted to float32. NaN or infinity raises ``ValueError``.
sample_weight : array-like of shape (n_samples,), default None
Per-sample weights, as in scikit-learn.
multioutput : {'uniform_average', 'raw_values'} or array-like, default 'uniform_average'
How to combine several outputs, as in scikit-learn.
numeric_mode : {'identical', 'deterministic', 'fast'} or None, default None
Numeric mode for this call (``'fast'`` is Apple only). None uses the
library default.
Returns
-------
loss : float or Array
A float, or one float64 value per output for ``'raw_values'``.
Examples
--------
>>> import numpy as np
>>> from mojolearn import metrics
>>> y = np.array([3.0, -0.5, 2.0, 7.0], dtype=np.float32)
>>> pred = np.array([2.5, 0.0, 2.0, 8.0], dtype=np.float32)
>>> rmse = metrics.root_mean_squared_error(y, pred)
"""
return _regression_error(
"root_mean_squared_error", y_true, y_pred, sample_weight, multioutput, numeric_mode
)
# Maintainer notes: backed by `ML::Metrics::kl_divergence`, the float overload
# (DEVIATIONS 653 and 658), on the same pinned fold as r2_score. float64 is
# refused (no float64 on Apple GPUs). Negative entries are refused here, before
# the kernel, per `metrics/README.md` HAND-OFF ask 3. RAFT's kernel does not
# normalize either; p_i == 0 contributes zero (their branch).
[docs]
def kl_divergence(P, Q):
"""Kullback-Leibler divergence ``sum p_i * (log p_i - log q_i)``, in float32.
Follows cuML's ``cuml.metrics.kl_divergence``. ``P`` and ``Q`` are not
normalized, as in cuML; divide each by its sum first if you want the
divergence between distributions. A term with ``p_i == 0`` contributes
zero, and ``q_i == 0`` with ``p_i > 0`` gives ``inf``.
Parameters
----------
P, Q : array-like of shape (n,)
Finite, non-negative float32 arrays of the same length. float64
input raises ``TypeError`` (cast to float32 first); a negative,
NaN or infinite entry raises ``ValueError``.
Returns
-------
divergence : float
Examples
--------
>>> import numpy as np
>>> from mojolearn import metrics
>>> P = np.array([0.5, 0.3, 0.2], dtype=np.float32)
>>> Q = np.array([0.4, 0.4, 0.2], dtype=np.float32)
>>> kl = metrics.kl_divergence(P, Q)
"""
p, q = _pair_1d(P, Q, "P", "Q", _as_f32_1d)
for name, arr in (("P", p), ("Q", q)): # glue: validates the two named arguments
if arr.min() < 0:
raise ValueError(
f"mojolearn kl_divergence: {name} has a negative entry; "
"refused here (metrics/README.md HAND-OFF ask 3) rather "
"than letting log of a negative reach the fold"
)
# ORDER MATCHES bindings/_mojolearn_metrics.mojo::kl_divergence_binding.
# n
return float(
_get_binding().kl_divergence(
addr_ro(p, name="p"), addr_ro(q, name="q"), [int(p.shape[0])]
)
)
# ===========================================================================
# Group C: silhouette
# ===========================================================================
_SILHOUETTE_METRICS = ("euclidean", "l2")
def _silhouette(X, labels, metric, chunksize, caller):
if not isinstance(metric, str) or metric.lower() not in _SILHOUETTE_METRICS:
raise NotImplementedError(
f"mojolearn {caller}: metric={metric!r} is refused; only "
"'euclidean' and 'l2' are implemented (they are the same "
"DistanceType::L2SqrtUnexpanded, which is the arm cuML's "
"silhouette_score.pyx dispatches by default). 'cityblock', "
"'cosine', 'l1', 'manhattan' and 'sqeuclidean' are other "
"distance kernels and are not in metrics/ (NOT_IMPLEMENTED.tsv)"
)
x, _copied = as_f32_c(X, ndim=2, name="X")
if not all_finite(x):
raise ValueError(
f"mojolearn {caller}: X contains NaN or infinity; refused here "
"(metrics/README.md HAND-OFF ask 3), because a NaN distance "
"reaches sil_op as +0.0 (DEVIATION 656) rather than as an error"
)
lab = _as_i32_1d(labels, "labels")
if lab.shape[0] != x.shape[0]:
raise ValueError(
f"mojolearn {caller}: labels has {lab.shape[0]} entries and X "
f"has {x.shape[0]} rows"
)
# Same behavior as cuML's silhouette_score.pyx:99-101: monotonic labels via
# cp.unique(..., return_inverse=True), and n_labels is how many distinct
# labels there are. `_labels.encode_labels` gives both under the order
# rule (DEVIATION 2340), natively over the int32 buffer (lane py-shared
# retired DEVIATION 2377's O(rows) Python pass here; `sorted_classes`
# stays the definition, `tests/test_labels_native.py`).
unique_labels, mapped = encode_labels(lab)
n_labels = len(unique_labels)
chunk = 40000 if chunksize is None else int(chunksize)
if chunk < 1:
raise ValueError(
f"mojolearn {caller}: chunksize must be at least 1, got {chunk}"
)
scores = empty((x.shape[0],), "<f4")
# ORDER MATCHES bindings/_mojolearn_metrics.mojo::silhouette_binding.
# n_rows, n_cols, n_labels, chunksize
mean = float(
_get_binding().silhouette(
addr_ro(x, name="x"),
addr_ro(mapped, name="mapped"),
addr(scores, name="scores"),
[int(x.shape[0]), int(x.shape[1]), n_labels, chunk],
)
)
return mean, scores
# Maintainer notes: backed by `ML::Metrics::Batched::silhouette_score`, the
# float batched entry cuML's Python dispatches (DEVIATIONS 654 and 656). The
# per-cell distance runs one thread per cell through `identical_mul_add` /
# `ftz` / `identical_sqrt`, and the a/b accumulation is one fixed tree per
# (row, cluster); gated bitwise against a host model over 1031 samples and
# launch-invariant across eight launches. Labels are mapped as cuML's
# silhouette_score.pyx does; the kernel asserts 2 <= n_labels <= n_rows - 1.
# 'euclidean' and 'l2' are the same DistanceType::L2SqrtUnexpanded.
# chunksize is scheduling only: no distance tile is materialized, and chunk
# 1 / 7 / 40000 are gated to one byte pattern. sample_size / random_state are
# refused so no random draw happens where no check sees it.
[docs]
def silhouette_score(
X, labels, *, metric="euclidean", sample_size=None, random_state=None,
chunksize=None,
):
"""Mean silhouette coefficient over all samples.
Follows scikit-learn's and cuML's ``silhouette_score`` with Euclidean
distance. Values lie in ``[-1, 1]``; higher means better-separated
clusters.
Parameters
----------
X : array-like of shape (n_samples, n_features)
Finite data, converted to float32 row-major. NaN or infinity raises
``ValueError``.
labels : array-like of shape (n_samples,)
Integer cluster labels that fit in int32; they are mapped onto
``0..k-1``. There must be at least 2 and at most ``n_samples - 1``
distinct labels.
metric : {'euclidean', 'l2'}, default 'euclidean'
Both mean Euclidean distance. Any other metric raises
``NotImplementedError``.
sample_size, random_state : None, default None
scikit-learn's subsampling is not supported; any other value raises
``NotImplementedError``. Subsample X yourself if you need it.
chunksize : int or None, default None
Accepted for compatibility with cuML (default 40000, must be at
least 1). It does not change the result or the work done.
Returns
-------
score : float
Examples
--------
>>> import numpy as np
>>> from mojolearn import metrics
>>> rng = np.random.default_rng(0)
>>> X = rng.random((1000, 8), dtype=np.float32)
>>> labels = rng.integers(0, 4, 1000).astype(np.int32)
>>> s = metrics.silhouette_score(X, labels)
"""
if sample_size is not None or random_state is not None:
raise NotImplementedError(
"mojolearn silhouette_score: sample_size / random_state are "
"refused; subsample X yourself so the rows that were scored are "
"the rows you chose (cuML's surface has no subsampling either)"
)
mean, _scores = _silhouette(X, labels, metric, chunksize, "silhouette_score")
return mean
# Maintainer notes: the same kernel call as silhouette_score, which computes
# both. Values that come out +0.0: a singleton cluster, an exact tie a == b,
# a == b == 0 (all points identical), and a row whose distances were
# non-finite (DEVIATION 656). No fixture has produced a -0.0.
[docs]
def silhouette_samples(X, labels, *, metric="euclidean", chunksize=None):
"""Silhouette coefficient of every sample.
The per-sample values that ``silhouette_score`` averages; parameters,
input rules and errors are those of ``silhouette_score``. A sample in a
singleton cluster gets 0.0, as in scikit-learn.
Parameters
----------
X : array-like of shape (n_samples, n_features)
Finite data, converted to float32 row-major.
labels : array-like of shape (n_samples,)
Integer cluster labels that fit in int32.
metric : {'euclidean', 'l2'}, default 'euclidean'
Both mean Euclidean distance.
chunksize : int or None, default None
Accepted for compatibility with cuML; does not change the result.
Returns
-------
scores : Array of shape (n_samples,), float32
Examples
--------
>>> import numpy as np
>>> from mojolearn import metrics
>>> rng = np.random.default_rng(0)
>>> X = rng.random((1000, 8), dtype=np.float32)
>>> labels = rng.integers(0, 4, 1000).astype(np.int32)
>>> s = metrics.silhouette_samples(X, labels)
"""
_mean, scores = _silhouette(
X, labels, metric, chunksize, "silhouette_samples"
)
return scores
# ===========================================================================
# Group D: trustworthiness
# ===========================================================================
# Maintainer notes: backed by `ML::Metrics::trustworthiness_score<float,
# L2SqrtUnexpanded>` (DEVIATION 655). The rank of each embedded neighbor is
# COUNTED with the stable sort's tie-break instead of sorting every row of the
# n x n distance matrix, and the closed form runs in float64 on the host;
# measured equal to scikit-learn 1.9's value on the lane's fixture.
# n_neighbors refusals: < 1; 2 * n_neighbors >= n_samples (cuML's
# trustworthiness.pyx:114 refuses the same, keeping the closed form's
# denominator positive); n_neighbors + 1 > 256 (the k+1 distances and ranks
# live in a threadgroup slab). metric: cuML's trustworthiness.pyx:86 refuses
# other names too. batch_size sizes nothing (ranks are counted, not sorted).
# Known soft spot: the embedded k-NN goes through this repository's one k-NN
# entry, which uses the EXPANDED L2 distance, while RAFT's run_knn uses the
# unexpanded one, so near-tied embedded neighbors can order differently in
# the last bit (measured 0 rows differing against a float64 host k-NN on the
# lane's fixture); a second neighbors/ arm for the unexpanded metric is the
# neighbors lane's to add.
[docs]
def trustworthiness(
X, X_embedded, *, n_neighbors=5, metric="euclidean", batch_size=512
):
"""Trustworthiness: how well an embedding preserves local neighborhoods.
Follows scikit-learn's and cuML's ``trustworthiness`` with Euclidean
distance. Values lie in ``[0, 1]``; 1 means every embedded neighbor is
also a near neighbor in the original space.
Parameters
----------
X : array-like of shape (n_samples, n_features)
Finite original data, converted to float32 row-major.
X_embedded : array-like of shape (n_samples, n_components)
Finite embedding with the same number of rows, converted to float32.
n_neighbors : int, default 5
Neighbors considered, as in cuML. Must be at least 1, less than
``n_samples / 2``, and at most 255; otherwise an error names it.
metric : {'euclidean'}, default 'euclidean'
Any other metric raises ``NotImplementedError``, as in cuML.
batch_size : int, default 512
Accepted for compatibility with cuML (must be at least 1). It does
not change the result or the work done.
Returns
-------
score : float
Raises
------
ValueError
If X and X_embedded have different numbers of rows, contain NaN or
infinity, or ``batch_size < 1``.
Examples
--------
>>> import numpy as np
>>> from mojolearn import metrics
>>> rng = np.random.default_rng(0)
>>> X = rng.random((1000, 8), dtype=np.float32)
>>> t = metrics.trustworthiness(X, X[:, :2], n_neighbors=5)
"""
if not isinstance(metric, str) or metric.lower() != "euclidean":
raise NotImplementedError(
f"mojolearn trustworthiness: metric={metric!r} is refused; only "
"'euclidean' is implemented, and cuML's trustworthiness.pyx:86 "
"refuses every other name too"
)
x, _cx = as_f32_c(X, ndim=2, name="X")
emb, _ce = as_f32_c(X_embedded, ndim=2, name="X_embedded")
if x.shape[0] != emb.shape[0]:
raise ValueError(
f"mojolearn trustworthiness: X has {x.shape[0]} rows and "
f"X_embedded has {emb.shape[0]}"
)
for name, arr in (("X", x), ("X_embedded", emb)): # glue: validates the two named arguments
if not all_finite(arr):
raise ValueError(
f"mojolearn trustworthiness: {name} contains NaN or "
"infinity; refused here (metrics/README.md HAND-OFF ask 3)"
)
if int(batch_size) < 1:
raise ValueError(
"mojolearn trustworthiness: batch_size must be at least 1, got "
f"{batch_size!r}"
)
# ORDER MATCHES bindings/_mojolearn_metrics.mojo::trustworthiness_binding.
# n, m, d, n_neighbors, batch_size
return float(
_get_binding().trustworthiness(
addr_ro(x, name="x"),
addr_ro(emb, name="emb"),
[
int(x.shape[0]),
int(x.shape[1]),
int(emb.shape[1]),
int(n_neighbors),
int(batch_size),
],
)
)
# ---------------------------------------------------------------------------
# Unweighted single-label classification. Labels are encoded on the host;
# confusion counts, ratios and averaging are computed by the GPU binding.
# ---------------------------------------------------------------------------
def _classification_labels(values, name, *, allow_empty=False):
shape = _shape_of(values)
if len(shape) != 1:
raise ValueError(f"{name} must be one-dimensional single-label data")
labels = flatten_labels(values)
if not allow_empty and not labels:
raise ValueError(f"{name} must contain at least one label")
if all(isinstance(v, str) for v in labels): # glue: type validation of list labels
return [str(v) for v in labels], "string" # cpu-route: str labels, the explicit label input step
if all(isinstance(v, numbers.Integral) or is_bool(v) for v in labels): # glue: type validation of list labels
return [int(v) for v in labels], "integer" # cpu-route: Python int labels, the explicit label input step
raise TypeError(f"{name} must contain only strings or only integers; "
"floating labels, missing labels and mixed types are unsupported")
class _EncodedLabels:
"""Integer labels held as `classes` (sorted distinct Python ints) and
`codes` (one int32 index into `classes` per row): what
`_classification_labels` returns as a list of n Python ints, without the
n objects (DEVIATION 3107). `len()` and iteration answer as that list
does, so a consumer that walks labels still works; the metric functions
below never walk it, they go through `_label_set` and `_label_map`."""
__slots__ = ("classes", "codes")
def __init__(self, classes, codes):
self.classes = classes
self.codes = codes
def __len__(self):
return int(self.codes.size)
def __iter__(self):
return map(self.classes.__getitem__, self.codes._values())
def _classification_encoded(values, name, *, allow_empty=False):
"""`_classification_labels`, with an integer or bool BUFFER of at least
`_NATIVE_MIN_LABELS` labels answered as `_EncodedLabels` through the
native encoder. Everything else (lists, str labels, float buffers and
their refusal, empty input and its refusal, more distinct labels than
the encoder holds) is `_classification_labels` itself."""
fast = _native_classification_labels(values)
if fast is not None:
return fast, "integer"
return _classification_labels(values, name, allow_empty=allow_empty)
def _native_classification_labels(values):
from ._buffer import _has_buffer, hotpath_enabled
from ._labels import _encode_labels_native
if isinstance(values, (str, bytes)) or not hotpath_enabled():
return None
if type(values) in (list, tuple):
# lane apple-fast-py2mojo-core: a flat list of exact Python ints is
# packed into an int64 buffer in C and encoded like one (the list
# route's `[int(v) for v in labels]` and its dict per row are gone
# for it); str, bool, nested or mixed lists keep the list route
if not values or set(map(type, values)) != {int}:
return None
import array
try:
store = array.array("q", values)
except OverflowError:
return None
values = Array._owned(store, (len(store),), "<i8", "C")
if not isinstance(values, Array) and not _has_buffer(values):
return None
if len(_shape_of(values)) != 1:
return None
try:
arr = _materialize(values, "input")[0]
except (TypeError, ValueError, OverflowError):
return None # `_classification_labels` names the refusal
if arr.ndim != 1 or arr.size < _NATIVE_MIN_LABELS:
return None
if arr.dtype in ("<u2", "<i1"):
arr = arr.astype("<i4") # exact; the encoder has no 8- or 16-bit arm
if arr.dtype not in ("<i4", "<i8", "<u4", "<u1"):
return None # floats are refused by `_classification_labels`
try:
encoded = _encode_labels_native(arr)
except (ImportError, AttributeError):
return None
if encoded is None:
return None
classes, codes = encoded
# a bool buffer's classes come back as bools; the list routine's are ints
return _EncodedLabels([int(c) for c in classes], codes) # glue: reads the native class table (classes-sized: distinct classes)
def _label_set(labels):
"""`set(labels)`."""
if isinstance(labels, _EncodedLabels):
return set(labels.classes)
return set(labels)
def _label_map(labels, fn):
"""`Array.from_list([fn(v) for v in labels], '<i4')`, `fn` a pure
function of the label: evaluated once per CLASS and gathered per row for
`_EncodedLabels` (DEVIATION 3107), once per row for a list."""
if isinstance(labels, _EncodedLabels):
from ._buffer import _native
gather = _native("gather_i32")
table = [fn(c) for c in labels.classes] # glue: maps the class table through the label function
table = Array.from_list(table, "<i4")
out = empty((len(labels),), "<i4")
gather(_addr_ro(table), len(labels.classes), _addr_ro(labels.codes),
len(labels), _addr(out))
return out
return Array.from_list([fn(v) for v in labels], "<i4") # cpu-route: Python object labels mapped per row, the explicit label input step
def _classification_pair(y_true, y_pred, sample_weight):
if sample_weight is not None:
raise NotImplementedError("classification metrics do not yet support sample_weight")
true, kind = _classification_encoded(y_true, "y_true")
pred, pred_kind = _classification_encoded(y_pred, "y_pred")
if len(true) != len(pred):
raise ValueError("y_true and y_pred lengths differ")
if kind != pred_kind:
raise TypeError("y_true and y_pred must use the same label type")
if len(true) > 2147483647:
raise ValueError("classification counts require at most INT32_MAX rows")
return true, pred, kind, sorted(_label_set(true) | _label_set(pred)) # glue: sorts the distinct class labels
# Maintainer notes: the probability check (finite, in [0, 1], rows summing to
# one), the binary [1 - p, p] layout, the clipped -log p_true per row, its
# Float32 PairSum and the mean (divided by n in binary64) run in ONE x_metrics
# device program (x_metrics/tail.mojo proba_rows, ranking.mojo row_metric,
# rank_epi.mojo MEAN). sample_weight goes to log_loss_options.
[docs]
def log_loss(y_true, y_pred, *, normalize=True, sample_weight=None, labels=None,
numeric_mode=None):
"""Log loss (cross-entropy) of predicted probabilities.
Follows scikit-learn's ``log_loss`` for single-label targets. A 1-D (or
one-column) ``y_pred`` holds the probability of the positive class and
is expanded to ``[1 - p, p]``. Otherwise each column is a class: in the
order of ``labels`` when given, else the sorted labels seen in
``y_true``. Probabilities are checked, not renormalized: each must lie
in ``[0, 1]`` and each row must sum to one within ``sqrt(float32 eps)``.
The selected probabilities are clipped to ``[eps, 1 - eps]`` (float32
epsilon) before the logarithm.
Parameters
----------
y_true : array-like of shape (n_samples,)
String or integer class labels, nonempty.
y_pred : array-like of shape (n_samples,), (n_samples, 1) or (n_samples, n_classes)
float32 probabilities. Other dtypes raise ``TypeError``; Python lists
are converted to float32.
normalize : bool, default True
True returns the mean loss per sample, False the sum.
sample_weight : array-like of shape (n_samples,), default None
Per-sample weights, as in scikit-learn.
labels : array-like, default None
The class labels, unique, in column order. Needed when ``y_true``
holds a single class. At least two labels are required.
numeric_mode : {'identical', 'deterministic', 'fast'} or None, default None
Numeric mode for this call (``'fast'`` is Apple only). None uses the
library default.
Returns
-------
loss : float
Raises
------
ValueError
For fewer than two labels, a label in ``y_true`` missing from
``labels``, a column count that does not match the labels, or
probabilities that are not finite, outside ``[0, 1]`` or do not sum
to one.
Examples
--------
>>> import numpy as np
>>> from mojolearn import metrics
>>> y = np.array([0, 1, 1, 0], dtype=np.int32)
>>> p = np.array([0.1, 0.9, 0.8, 0.35], dtype=np.float32)
>>> loss = metrics.log_loss(y, p)
"""
if sample_weight is not None:
# lane/metrics: the weighted mean of the clipped -log p_true rows.
if not is_bool(normalize):
raise ValueError("normalize must be a bool")
from ._expansion_metrics import log_loss_options
return log_loss_options(y_true, y_pred, normalize, sample_weight, labels, numeric_mode)
if not is_bool(normalize):
raise ValueError("normalize must be a bool")
true, kind = _classification_encoded(y_true, "y_true")
selected = _selected_labels(labels, kind, sorted(_label_set(true))) # glue: sorts the distinct class labels
if len(selected) < 2:
raise ValueError("log_loss requires at least two labels; pass labels for one observed class")
mapping = {label: i for i, label in enumerate(selected)} # glue: class index table
if any(label not in mapping for label in _label_set(true)): # glue: checks the label set against the selected labels
raise ValueError("y_true contains a label missing from labels")
probabilities = materialize_f32_lists(y_pred, "input")[0]
if probabilities.dtype != "<f4":
raise TypeError("log_loss probabilities must have dtype float32")
if probabilities.ndim not in (1, 2):
raise ValueError("log_loss probabilities must have shape (n,), (n,1) or (n,k)")
if probabilities.shape[0] != len(true):
raise ValueError("y_true and probability row counts differ")
binary = probabilities.ndim == 1 or probabilities.shape[1] == 1
if binary:
if len(selected) != 2:
raise ValueError("one-column probabilities require exactly two labels")
elif probabilities.shape[1] < 2 or probabilities.shape[1] != len(selected):
raise ValueError("probability columns must match the label count (at least two)")
if len(true) > 2147483647 or probabilities.size > 2147483647 or (binary and 2 * len(true) > 2147483647):
raise ValueError("log_loss exceeds the native Int32 indexing bound")
probabilities = as_f32_c(probabilities, ndim=probabilities.ndim, name="probabilities")[0]
if not len(true):
raise ValueError("log_loss requires at least one sample")
# lane cpu2-l7-metrics: the probabilities' check (finite, in [0, 1], rows
# summing to one), the binary [1 - p, p] layout, the clipped -log p_true
# per row, its PairSum and the mean run in ONE x_metrics device program
# (x_metrics/tail.mojo proba_rows, ranking.mojo row_metric, rank_epi.mojo
# MEAN); no host probability_rows_f32 pass
from ._expansion_metrics import _Prog, _put_proba, _proba_code, _row_mean
encoded = _label_map(true, mapping.__getitem__)
n, k = len(true), len(selected)
prog = _Prog()
S, flags = _put_proba(prog, probabilities, n, k, binary)
Y = prog.put_i32(encoded)
def check(prog):
code = _proba_code(prog, flags)
if code:
reasons = {1: "must be finite", 2: "must lie in [0,1]",
3: "rows must sum to one within sqrt(float32 eps)"}
raise ValueError("log_loss probabilities " + reasons.get(code, "failed native validation"))
return float(_row_mean(S, k, Y, n, "logloss", None, numeric_mode, prog=prog, normalize=bool(normalize),
after=(check,)))
def _binary_ranking_inputs(y_true, y_score, sample_weight):
if sample_weight is not None:
raise NotImplementedError("binary ranking metrics do not yet support sample_weight")
true, kind = _classification_encoded(y_true, "y_true")
classes = sorted(_label_set(true)) # glue: sorts the distinct class labels
if len(classes) > 2:
raise ValueError("binary ranking metrics support at most two observed classes")
scores = materialize_f32_lists(y_score, "input")[0]
if scores.dtype != "<f4":
raise TypeError("binary ranking scores must have dtype float32")
if scores.ndim != 1 or len(scores) != len(true):
raise ValueError("binary ranking scores must be one-dimensional with the same length as y_true")
if not all_finite(scores):
raise ValueError("binary ranking scores must be finite")
if len(true) > 2147483647:
raise ValueError("binary ranking metrics exceed the native Int32 indexing bound")
return true, kind, classes, as_f32_c(scores, ndim=scores.ndim, name="scores")[0]
# Maintainer notes: the binary unweighted full-AUC call keeps its own kernel
# and returns the device Float32 scalar. sample_weight, partial AUC (max_fpr),
# multiclass one-vs-rest / one-vs-one, labels and averages go to the x_metrics
# device curve (roc_auc_options).
[docs]
def roc_auc_score(y_true, y_score, *, average="macro", sample_weight=None,
max_fpr=None, multi_class="raise", labels=None, numeric_mode=None):
"""Area under the ROC curve.
Follows scikit-learn's ``roc_auc_score``. In the binary case, ``y_true``
has two classes, the larger sorted label is the positive class, and
``y_score`` holds one finite score per sample (probabilities or any
decision scores); tied scores are grouped. Multiclass input (a 2-D
``y_score`` of class probabilities) needs ``multi_class='ovr'`` or
``'ovo'``, as in scikit-learn.
Parameters
----------
y_true : array-like of shape (n_samples,)
String or integer labels. Both classes must be present in the binary
case.
y_score : array-like of shape (n_samples,) or (n_samples, n_classes)
Finite float32 scores. Other dtypes raise ``TypeError``.
average : {'macro', 'weighted', 'micro', 'samples'} or None, default 'macro'
Averaging for multiclass input, as in scikit-learn.
sample_weight : array-like of shape (n_samples,), default None
Per-sample weights.
max_fpr : float or None, default None
In (0, 1]: return the standardized partial AUC up to this false
positive rate (binary only), as in scikit-learn.
multi_class : {'raise', 'ovr', 'ovo'}, default 'raise'
Multiclass strategy.
labels : array-like, default None
Class labels for multiclass input, as in scikit-learn.
numeric_mode : {'identical', 'deterministic', 'fast'} or None, default None
Numeric mode for this call (``'fast'`` is Apple only). None uses the
library default.
Returns
-------
auc : float
Examples
--------
>>> import numpy as np
>>> from mojolearn import metrics
>>> y = np.array([0, 0, 1, 1], dtype=np.int32)
>>> s = np.array([0.1, 0.4, 0.35, 0.8], dtype=np.float32)
>>> auc = metrics.roc_auc_score(y, s)
"""
if (average != "macro" or multi_class != "raise" or labels is not None or sample_weight is not None
or not (max_fpr is None or (not is_bool(max_fpr) and isinstance(max_fpr, numbers.Real)
and max_fpr == 1))
or len(_shape_of(y_score)) == 2):
# lane/metrics: sample_weight, partial AUC (max_fpr), multiclass
# one-vs-rest / one-vs-one, labels and averages, from the x_metrics
# device curve; the binary unweighted full-AUC call keeps its kernel.
from ._expansion_metrics import roc_auc_options
return roc_auc_options(y_true, y_score, average, sample_weight, max_fpr, multi_class, labels,
numeric_mode)
true, _, classes, scores = _binary_ranking_inputs(y_true, y_score, sample_weight)
if len(classes) != 2:
raise ValueError("roc_auc_score requires both positive and negative classes")
encoded = _label_map(true, lambda v: int(v == classes[1]))
result = empty(1, '<f4')
_get_binding(numeric_mode).roc_auc_score(
_addr_ro(encoded), _addr_ro(scores), _addr(result), [len(true)])
return float(result[0])
# Maintainer notes: the default call keeps its own kernel and Float32 outputs
# (no CPU sorting or reduction). sample_weight and drop_intermediate=True go to
# precision_recall_curve_options: Float64 curves from the x_metrics device sort
# and counts.
[docs]
def precision_recall_curve(y_true, y_score, *, pos_label=None, sample_weight=None,
drop_intermediate=False, numeric_mode=None):
"""Precision-recall pairs for each score threshold (binary).
Follows scikit-learn's ``precision_recall_curve``. Thresholds are the
distinct scores in ascending order; ``precision`` and ``recall`` have
one more element than ``thresholds`` and end at ``(1, 0)``.
Without ``pos_label``, ``y_true`` must be integers whose observed values
are a subset of ``{0, 1}`` or ``{-1, 1}``, and 1 is the positive class;
other labels need an explicit ``pos_label`` of the same type. If the
positive class is absent, recall is set to one for every threshold and
a ``UserWarning`` is issued.
Parameters
----------
y_true : array-like of shape (n_samples,)
String or integer labels with at most two classes.
y_score : array-like of shape (n_samples,)
Finite float32 scores (not necessarily probabilities). Other dtypes
raise ``TypeError``.
pos_label : int, str or None, default None
The positive class.
sample_weight : array-like of shape (n_samples,), default None
Per-sample weights.
drop_intermediate : bool, default False
Drop thresholds that do not change the curve's shape, as in
scikit-learn.
numeric_mode : {'identical', 'deterministic', 'fast'} or None, default None
Numeric mode for this call (``'fast'`` is Apple only). None uses the
library default.
Returns
-------
precision : Array of shape (n_thresholds + 1,)
recall : Array of shape (n_thresholds + 1,)
thresholds : Array of shape (n_thresholds,)
float32 by default; float64 when ``sample_weight`` is given or
``drop_intermediate=True``.
Examples
--------
>>> import numpy as np
>>> from mojolearn import metrics
>>> y = np.array([0, 0, 1, 1], dtype=np.int32)
>>> s = np.array([0.1, 0.4, 0.35, 0.8], dtype=np.float32)
>>> precision, recall, thresholds = metrics.precision_recall_curve(y, s)
"""
if not is_bool(drop_intermediate):
raise ValueError("drop_intermediate must be a bool")
if drop_intermediate or sample_weight is not None:
# lane/metrics: Float64 curves from the x_metrics device sort and
# counts; the default call keeps its kernel and Float32 outputs.
from ._expansion_metrics import precision_recall_curve_options
return precision_recall_curve_options(y_true, y_score, pos_label, sample_weight,
drop_intermediate, numeric_mode)
true, kind, classes, scores = _binary_ranking_inputs(y_true, y_score, sample_weight)
if pos_label is None:
if kind != "integer" or classes not in ([0], [1], [-1], [0, 1], [-1, 1]):
raise ValueError("pos_label must be specified for labels outside {0,1} or {-1,1}")
positive = 1
else:
values, positive_kind = _classification_labels([pos_label], "pos_label")
if kind != positive_kind:
raise ValueError("pos_label must use the same type as y_true")
positive = values[0]
encoded = _label_map(true, lambda v: int(v == positive))
n = len(true)
precision = empty(n + 1, '<f4')
recall = empty(n + 1, '<f4')
thresholds = empty(n, '<f4')
size = int(_get_binding(numeric_mode).precision_recall_curve(
_addr_ro(encoded), _addr_ro(scores), _addr(precision), _addr(recall),
_addr(thresholds), [n]))
if not 1 <= size <= n:
raise RuntimeError("precision_recall_curve returned an invalid threshold count")
if positive not in classes:
import warnings
warnings.warn("No positive class found in y_true; recall is set to one "
"for all thresholds.", UserWarning, stacklevel=2)
return precision[:size + 1].copy(), recall[:size + 1].copy(), thresholds[:size].copy()
def _selected_labels(labels, kind, observed):
if labels is None:
return observed.copy()
selected, selected_kind = _classification_labels(labels, "labels")
if selected_kind != kind:
raise TypeError("labels must use the same type as the targets")
if len(set(selected)) != len(selected):
raise ValueError("labels must be unique")
return selected
def _encode_classification(true, pred, labels):
mapping = {label: i for i, label in enumerate(labels)} # glue: class index table
return (_label_map(true, lambda v: mapping.get(v, -1)),
_label_map(pred, lambda v: mapping.get(v, -1)))
# Maintainer notes: unweighted counts come from the metrics binding (at most
# INT32_MAX input rows). sample_weight goes to confusion_matrix_weighted:
# Float64 cells, each the Float32 PairSum of its rows' weights, normalized in
# binary64.
[docs]
def confusion_matrix(y_true, y_pred, *, labels=None, sample_weight=None,
normalize=None, numeric_mode=None):
"""Confusion matrix of single-label predictions.
Follows scikit-learn's ``confusion_matrix``: row ``i`` is the true label
and column ``j`` the predicted label. By default the labels are the
sorted union of those seen in ``y_true`` and ``y_pred``. With ``labels``,
their order is kept and rows whose true or predicted label is not in
``labels`` are left out.
Parameters
----------
y_true, y_pred : array-like of shape (n_samples,)
String or integer labels of the same type and length, nonempty.
Float labels raise ``TypeError``.
labels : array-like, default None
Unique labels to index the matrix. At least one must occur in
``y_true``. Without ``sample_weight``, at most 4096 labels.
sample_weight : array-like of shape (n_samples,), default None
Per-sample weights; the cells are then float64 sums of weights.
normalize : {'true', 'pred', 'all'} or None, default None
Normalize over rows, columns or the whole matrix. Rows or columns
with no mass give zeros.
numeric_mode : {'identical', 'deterministic', 'fast'} or None, default None
Numeric mode for this call (``'fast'`` is Apple only). None uses the
library default.
Returns
-------
C : Array of shape (n_labels, n_labels)
int64 counts; float32 when normalized; float64 with
``sample_weight``.
Examples
--------
>>> import numpy as np
>>> from mojolearn import metrics
>>> C = metrics.confusion_matrix(np.array([0, 1, 2, 2], dtype=np.int32),
... np.array([0, 2, 2, 1], dtype=np.int32))
"""
if normalize is not None and (not isinstance(normalize, str) or
normalize not in ("true", "pred", "all")):
raise ValueError("normalize must be None, 'true', 'pred' or 'all'")
if sample_weight is not None:
# lane/metrics: Float64 cells, each the Float32 PairSum of its rows'
# weights (x_metrics binding); the unweighted call keeps its bits.
from ._expansion_metrics import confusion_matrix_weighted
return confusion_matrix_weighted(y_true, y_pred, labels, sample_weight, normalize, numeric_mode)
true, pred, kind, observed = _classification_pair(y_true, y_pred, sample_weight)
selected = _selected_labels(labels, kind, observed)
if not set(selected).intersection(_label_set(true)):
raise ValueError("At least one label specified must be in y_true")
if len(selected) > 4096:
raise ValueError("confusion_matrix supports at most 4096 output labels")
yt, yp = _encode_classification(true, pred, selected)
norm = {None: 0, "true": 1, "pred": 2, "all": 3}[normalize]
result = empty((len(selected), len(selected)), "<i8" if norm == 0 else "<f4")
_get_binding(numeric_mode).confusion_matrix(
_addr_ro(yt), _addr_ro(yp), _addr(result), [len(true), len(selected), norm])
return result
def _precision_recall_fscore(y_true, y_pred, *, labels, pos_label, average,
sample_weight, zero_division, numeric_mode, metric):
if sample_weight is not None:
# lane/metrics: the weighted sums come from the x_metrics binding;
# the unweighted call keeps its binding and its bits.
from ._expansion_metrics import _prf_weighted
return _prf_weighted(y_true, y_pred, labels, pos_label, average, sample_weight,
zero_division, numeric_mode, metric)
averages = {None: 0, "binary": 1, "micro": 2, "macro": 3, "weighted": 4}
if average is not None and (not isinstance(average, str) or average not in averages):
raise ValueError("average must be 'binary', 'micro', 'macro', 'weighted' or None")
warn = isinstance(zero_division, str) and zero_division == "warn"
if not warn and (is_bool(zero_division) or
not isinstance(zero_division, numbers.Real) or
zero_division not in (0, 1)):
raise ValueError("zero_division must be 'warn', 0 or 1")
true, pred, kind, observed = _classification_pair(y_true, y_pred, sample_weight)
positive = 0
if average == "binary":
if len(observed) > 2:
raise ValueError("average='binary' requires at most two observed classes")
positive_labels, positive_kind = _classification_labels([pos_label], "pos_label")
if positive_kind != kind:
raise ValueError("pos_label must use the same type as the targets")
positive_label = positive_labels[0]
if positive_label not in observed and len(observed) == 2:
raise ValueError("pos_label is not a valid observed label")
selected = observed.copy()
if positive_label not in selected:
selected.append(positive_label)
positive = selected.index(positive_label)
else:
selected = _selected_labels(labels, kind, observed)
selected_count = len(selected)
# Keep classes outside the requested output set for false-positive and
# false-negative accounting. Truncating confusion first would be wrong.
selected_set = set(selected)
all_labels = selected + [v for v in observed if v not in selected_set] # glue: appends the observed class labels
if len(all_labels) > 715827882:
raise ValueError("classification metrics exceed the native class-index bound")
yt, yp = _encode_classification(true, pred, all_labels)
width = selected_count if average is None else 1
output = empty(3 * width + 3, '<f4')
_get_binding(numeric_mode).precision_recall_fscore(
_addr_ro(yt), _addr_ro(yp), _addr(output),
[len(true), len(all_labels), averages[average], positive,
0 if warn else int(zero_division), selected_count])
if warn and output[3 * width + metric] != 0:
import warnings
from ._expansion_metrics import _undefined_category
name = ("Precision", "Recall", "F-score")[metric]
warnings.warn(f"{name} is ill-defined and being set to 0.0 due to zero "
"denominator; use zero_division to control this behavior.",
_undefined_category(), stacklevel=3)
values = output[metric * width:(metric + 1) * width]
return values.copy() if average is None else float(values[0])
[docs]
def precision_score(y_true, y_pred, *, labels=None, pos_label=1, average="binary",
sample_weight=None, zero_division="warn", numeric_mode=None):
"""Precision: ``tp / (tp + fp)``.
Follows scikit-learn's ``precision_score`` for single-label targets.
Parameters
----------
y_true, y_pred : array-like of shape (n_samples,)
String or integer labels of the same type and length, nonempty.
Float and multilabel input are refused.
labels : array-like, default None
Labels to report, in order. Labels left out still count as errors
for the reported ones. Ignored for ``average='binary'``.
pos_label : int or str, default 1
The positive class for ``average='binary'``.
average : {'binary', 'micro', 'macro', 'weighted'} or None, default 'binary'
How to combine per-label scores; None returns one score per label.
``'binary'`` needs at most two observed classes.
sample_weight : array-like of shape (n_samples,), default None
Per-sample weights.
zero_division : {'warn', 0, 1}, default 'warn'
The value for an undefined ratio; ``'warn'`` gives 0 and a warning.
numeric_mode : {'identical', 'deterministic', 'fast'} or None, default None
Numeric mode for this call (``'fast'`` is Apple only). None uses the
library default.
Returns
-------
score : float or Array of shape (n_labels,)
A float, or a float32 Array per label when ``average=None``.
Examples
--------
>>> import numpy as np
>>> from mojolearn import metrics
>>> p = metrics.precision_score(np.array([0, 1, 1, 0], dtype=np.int32),
... np.array([0, 1, 0, 1], dtype=np.int32))
"""
return _precision_recall_fscore(y_true, y_pred, labels=labels, pos_label=pos_label,
average=average, sample_weight=sample_weight, zero_division=zero_division,
numeric_mode=numeric_mode, metric=0)
[docs]
def recall_score(y_true, y_pred, *, labels=None, pos_label=1, average="binary",
sample_weight=None, zero_division="warn", numeric_mode=None):
"""Recall: ``tp / (tp + fn)``.
Follows scikit-learn's ``recall_score`` for single-label targets.
Labels, averaging and ``zero_division`` work as in ``precision_score``.
Parameters
----------
y_true, y_pred : array-like of shape (n_samples,)
String or integer labels of the same type and length, nonempty.
Float and multilabel input are refused.
labels : array-like, default None
Labels to report, in order. Labels left out still count as errors
for the reported ones. Ignored for ``average='binary'``.
pos_label : int or str, default 1
The positive class for ``average='binary'``.
average : {'binary', 'micro', 'macro', 'weighted'} or None, default 'binary'
How to combine per-label scores; None returns one score per label.
``'binary'`` needs at most two observed classes.
sample_weight : array-like of shape (n_samples,), default None
Per-sample weights.
zero_division : {'warn', 0, 1}, default 'warn'
The value for an undefined ratio; ``'warn'`` gives 0 and a warning.
numeric_mode : {'identical', 'deterministic', 'fast'} or None, default None
Numeric mode for this call (``'fast'`` is Apple only). None uses the
library default.
Returns
-------
score : float or Array of shape (n_labels,)
A float, or a float32 Array per label when ``average=None``.
Examples
--------
>>> import numpy as np
>>> from mojolearn import metrics
>>> r = metrics.recall_score(np.array([0, 1, 1, 0], dtype=np.int32),
... np.array([0, 1, 0, 1], dtype=np.int32))
"""
return _precision_recall_fscore(y_true, y_pred, labels=labels, pos_label=pos_label,
average=average, sample_weight=sample_weight, zero_division=zero_division,
numeric_mode=numeric_mode, metric=1)
[docs]
def f1_score(y_true, y_pred, *, labels=None, pos_label=1, average="binary",
sample_weight=None, zero_division="warn", numeric_mode=None):
"""F1 score: the harmonic mean of precision and recall.
Follows scikit-learn's ``f1_score`` for single-label targets. Labels,
averaging and ``zero_division`` work as in ``precision_score``.
Parameters
----------
y_true, y_pred : array-like of shape (n_samples,)
String or integer labels of the same type and length, nonempty.
Float and multilabel input are refused.
labels : array-like, default None
Labels to report, in order. Labels left out still count as errors
for the reported ones. Ignored for ``average='binary'``.
pos_label : int or str, default 1
The positive class for ``average='binary'``.
average : {'binary', 'micro', 'macro', 'weighted'} or None, default 'binary'
How to combine per-label scores; None returns one score per label.
``'binary'`` needs at most two observed classes.
sample_weight : array-like of shape (n_samples,), default None
Per-sample weights.
zero_division : {'warn', 0, 1}, default 'warn'
The value for an undefined ratio; ``'warn'`` gives 0 and a warning.
numeric_mode : {'identical', 'deterministic', 'fast'} or None, default None
Numeric mode for this call (``'fast'`` is Apple only). None uses the
library default.
Returns
-------
score : float or Array of shape (n_labels,)
A float, or a float32 Array per label when ``average=None``.
Examples
--------
>>> import numpy as np
>>> from mojolearn import metrics
>>> f1 = metrics.f1_score(np.array([0, 1, 1, 0], dtype=np.int32),
... np.array([0, 1, 0, 1], dtype=np.int32), average='macro')
"""
return _precision_recall_fscore(y_true, y_pred, labels=labels, pos_label=pos_label,
average=average, sample_weight=sample_weight, zero_division=zero_division,
numeric_mode=numeric_mode, metric=2)
# ===========================================================================
# NAMED ABSENCES. Everything a caller might reasonably reach for in
# `sklearn.metrics` or `cuml.metrics` that this module does NOT have, with
# the reason and where the thing that exists stops. Omitting them silently
# would leave a caller to discover it as an AttributeError.
# ===========================================================================
_UNSUPPORTED = {
"pairwise_distances": (
"cuML's pairwise_distance.cu is a front-end over cuVS distances; "
"neighbors/ owns distances in this repository "
"(neighbors/checks/pinned_distance_tile.mojo), and it is not a "
"metric of a model (metrics/NOT_IMPLEMENTED.tsv)"
),
}
def __getattr__(name):
if name in _UNSUPPORTED:
raise AttributeError(
f"mojolearn.metrics.{name} does not exist: {_UNSUPPORTED[name]}. "
"See SUPPORT_MATRIX.md for the supported metrics."
)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
# THE METRICS LANE'S FUNCTIONS (lane/metrics, 2026-09-27), computed by the
# x_metrics binding; see python/mojolearn/_expansion_metrics.py.
from ._expansion_metrics import ( # noqa: E402
ranking_report, regression_report, balanced_accuracy_score, class_likelihood_ratios, classification_report, cohen_kappa_score,
fbeta_score, hamming_loss, jaccard_score, matthews_corrcoef, multilabel_confusion_matrix,
precision_recall_fscore_support, zero_one_loss,
d2_absolute_error_score, d2_pinball_score, d2_tweedie_score, explained_variance_score, max_error, mean_absolute_percentage_error, mean_gamma_deviance, mean_pinball_loss, mean_poisson_deviance, mean_squared_log_error, mean_tweedie_deviance, median_absolute_error, root_mean_squared_log_error,
auc, average_precision_score, brier_score_loss, coverage_error, d2_brier_score, d2_log_loss_score, dcg_score, det_curve, hinge_loss, label_ranking_average_precision_score, label_ranking_loss, ndcg_score, roc_curve, top_k_accuracy_score,
adjusted_mutual_info_score, calinski_harabasz_score, contingency_matrix, davies_bouldin_score, normalized_mutual_info_score, pair_confusion_matrix,
)