# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 Andrew Hendel. Part of mojolearn, https://doi.org/10.5281/zenodo.22068632
"""THE METRICS LANE'S DOOR.
Owned by the `metrics` expansion lane. `mojolearn/__init__.py` imports this
module and makes every name in `__all__` public as `mojolearn.<name>`. The
lane's functions belong to `mojolearn.metrics` (and, for the splitters,
`mojolearn.model_selection`), the way scikit-learn places them, so `__all__`
here is EMPTY on purpose and `_metrics_impl.py` re-exports the functions
below under `mojolearn.metrics`.
HOW THE FUNCTIONS COMPUTE. Every O(n) quantity comes out of ONE binding
entry, `x_metrics_run`, which runs a PROGRAM of units (x_metrics/common.mojo)
over one float32 arena: the GPU binding (`_mojolearn_x_metrics`) launches a
thread per unit, the host binding (`_mojolearn_x_metrics_host`, what
`_backend.binding` returns on a CPU-only install) runs the same units in a
loop. Python lays out the arena, lists the stages and reads the per-class /
per-column Float32 results back.
THE EPILOGUE (DEVIATION 6106, narrowed 2026-09-28). What is left after the
O(n) folds is O(classes) arithmetic on those Float32 results: ratios,
averages, a square root. It runs here, in IEEE binary64 with ONLY + - * / and
sqrt, each correctly rounded on every host Python runs on, in a fixed
left-to-right order (and `_portable_math` for the rare logarithm), and only on
O(classes), O(outputs) or O(candidates x folds) scalars; `**`, `pow` and libm
transcendentals are not allowed. Any epilogue whose term count scales with
rows, curve points, contingency cells or k^2 * d runs in
x_metrics/epilogue.mojo under the same operation rules, the only route (lane
pyglue-sweep, 2026-10-03: the Python fallbacks and the
MOJOLEARN_METRICS_EPILOGUE=python reference arm are gone). That is bitwise the same on every machine by the IEEE standard, and it
is scikit-learn's own precision for the same step.
"""
import array
import ctypes
import itertools
from . import _portable_math as _math
import numbers
import operator
import warnings
from . import _backend
from . import _portable_math as pmath
from . import _arena_io
from ._array import Array
from ._buffer import as_f32_c, addr_ro, all_finite, empty
from ._labels import is_bool
__all__ = []
_BINDING = "_mojolearn_x_metrics"
#: op name -> id; x_metrics/units.mojo `run_unit` holds the same table.
_OPS = dict(group_sort=0, group_sum=1, pair_key=2, reg_term=3, col_sort=4, wpercentile=5, col_max=6, bin_curve=7, row_metric=8, row_centroid_dist=9, permute=10,
fold_rows=36, rows64=41, strat_codes=45, curve_fold=46, onehot=52, rep_rows=53, pair_cols=54,
# lane fam2-prep-metrics (x_metrics/cls_epi.mojo), both numeric modes
# since lane cpu2-l7-metrics
cls_epi=55,
# lane cpu2-l7-metrics: the metric tails and scans on the device
# (x_metrics/tail.mojo, cm_epi.mojo, reg_epi.mojo, rank_epi.mojo)
off_diff=56, flag_scan=57, proba_rows=58, cm_epi=59, reg_epi=60, rank_epi=61, cl_epi=62,
# lane cpu4-python: the curve arrays on the device (x_metrics/curve_out.mojo)
curve_out=63, auc_xy=67,
# lane cpu4-python: the contingency statistics, MI and expected MI
# on the device (x_metrics/contingency.mojo)
cont_stats=70,
# lane py-runtime-b: StratifiedShuffleSplit's per-class draw counts
# (x_metrics/split.mojo approx_mode_unit)
approx_mode=81)
_PARAMS = 14
_NONE = -1
def _sq(v):
"""v * v: one correctly rounded product. `v ** 2` calls the platform pow,
which DEVIATION 6106 does not allow (it is not correctly rounded on every
host). Lanes py-bugs and py-misc-metrics both made this change."""
return v * v
def _optional_metrics_entry(binding, name):
"""Optional optimizations may be absent on an otherwise complete CPU facade."""
binding.x_metrics_run # Do not conceal a missing mandatory family binding.
try:
return getattr(binding, name)
except (AttributeError, ImportError):
return None
def _binding(numeric_mode):
if numeric_mode is not None and (
not isinstance(numeric_mode, str)
or numeric_mode.strip().lower() not in ("fast", "identical")
):
raise ValueError("numeric_mode must be 'fast' or 'identical'")
mode = (numeric_mode or _backend.default_mode()).strip().lower()
return _backend.binding(_BINDING, mode)
def _arena_view(arena):
"""A float32 memoryview over a program's arena."""
return memoryview(arena)
def _new_arena(size):
return array.array("f", bytes(4 * max(size, 1)))
def _i32_c(codes):
"""int32 C-order codes as an `Array` (no copy when they already are)."""
if isinstance(codes, Array):
if codes.dtype == "<i4" and codes._has_order("C"):
return codes
return codes.astype("<i4")._as_c()
return Array.from_list(list(codes), "<i4")
class _OneHot:
"""0/1 flags the program forms itself from int32 `codes` (the onehot
unit): layout 0 class-major (word c*n + r), layout 1 row-major (word
r*k + c). Passed where `_curves` takes flags."""
__slots__ = ("codes", "n", "k", "layout")
def __init__(self, codes, n, k, layout):
self.codes, self.n, self.k, self.layout = _i32_c(codes), int(n), int(k), int(layout)
class _RepRows:
"""Float32 weights `w` (n) each repeated over k words (the rep_rows
unit). Passed where `_curves` takes weights."""
__slots__ = ("w", "n", "k")
def __init__(self, w, n, k):
self.w, self.n, self.k = w, int(n), int(k)
def _put_flags(prog, flags):
"""The flags' arena offset: an `_OneHot` is formed by a stage on the
device (or the host binding), anything else is copied in."""
if isinstance(flags, _OneHot):
C = prog.put_i32(flags.codes)
out = prog.scratch(flags.n * flags.k)
prog.stage("onehot", flags.n * flags.k, C, out, flags.n, flags.k, flags.layout)
return out
return prog.put_i32(flags)
def _put_weights(prog, w):
"""The weights' arena offset (_NONE for none): a `_RepRows` is repeated
by a stage, anything else is copied in."""
if w is None:
return _NONE
if isinstance(w, _RepRows):
W = prog.put(w.w)
out = prog.scratch(w.n * w.k)
prog.stage("rep_rows", w.n * w.k, W, out, w.k)
return out
return prog.put(w)
class _Prog:
"""One program: an arena layout, the inputs copied into it, and stages."""
def __init__(self):
self.size = 0
self._inputs = []
self._stages = []
self.arena = None
#: the declared output ranges (`want`); None = the whole arena
#: less the inputs and scratch (`_skip`), which never come back
self._outs = None
self._skip = []
#: (MATCH, W, n) of a weighted `_Sums` program, for a tail that sums
#: the rows itself (`_wsum_sf64`); None otherwise
self.rows = None
def alloc(self, n):
off = self.size
self.size += max(int(n), 0)
if self.size > 2147483647:
raise ValueError("mojolearn metrics: the problem exceeds the Int32 arena bound")
return off
def put(self, arr):
"""A float32 C-contiguous Array (or anything as_f32_c takes) -> offset.
A float32 `_arena_io.DeviceRows` (a fold's rows, lane cpu4-misc) is
kept as it is: `_execute` gathers it on the device, or copies its
host rows when the binding cannot."""
if isinstance(arr, _arena_io.DeviceRows) and arr.dtype != "<f4":
arr = arr.materialize()
if not (isinstance(arr, _arena_io.DeviceRows)
or (isinstance(arr, Array) and arr.dtype == "<f4" and arr._has_order("C"))):
arr = as_f32_c(arr, ndim=None, name="input")[0]
off = self.alloc(arr.size)
self._inputs.append((off, arr))
self._skip.append((off, off + arr.size))
return off
def scratch(self, n):
"""`alloc` for a slot only the device reads (a sort order, a key
column): it never comes back (lane metrics-apple2)."""
off = self.alloc(n)
if n > 0:
self._skip.append((off, off + int(n)))
return off
def put_i32(self, codes):
"""int32 values stored as their bits (read with ldi)."""
codes = _i32_c(codes)
off = self.alloc(codes.size)
self._inputs.append((off, codes))
self._skip.append((off, off + codes.size))
return off
def stage(self, op, total, *params):
if len(params) > _PARAMS:
raise ValueError("x_metrics: too many stage parameters")
self._stages.append([_OPS[op], int(total)] + [int(v) for v in params] # glue: one stage's parameter words
+ [0] * (_PARAMS - len(params)))
def run(self, numeric_mode):
return _execute(self, numeric_mode)
def want(self, off, n, count=None, mult=1):
"""Declare [off, off + n) an OUTPUT the caller reads (lane
metrics-apple2). A program that declares any output brings back
only its outputs from the device (the Apple GPU's download is its
slowest link), and every read below refuses a word outside them,
on every backend, so a missing declaration fails loudly. `count`
(an Int32 slot the program writes, itself declared) bounds the
range to its first mult * count words."""
if self._outs is None:
self._outs = []
if n > 0:
self._outs.append((int(off), int(off) + int(n), -1 if count is None else int(count), int(mult)))
return off
def _check(self, off, n):
if n <= 0:
return
if self._outs is None:
for lo, hi in self._skip: # glue: the program's input and scratch ranges
if off < hi and lo < off + n:
raise AssertionError(f"x_metrics: arena [{off}, {off + n}) was read but is an input "
"or scratch slot, which never comes back")
return
for lo, hi, cn, mult in self._outs: # glue: the program's declared output ranges
if cn >= 0 and self.arena is not None:
hi = lo + max(0, min(hi - lo, mult * self.ints(cn, 1)[0]))
if lo <= off and off + n <= hi:
return
raise AssertionError(f"x_metrics: arena [{off}, {off + n}) was read but never declared an output")
def _download(self):
"""The disjoint ascending [lo, hi) ranges the device brings back:
the declared outputs, else the arena less the inputs and scratch;
None = the whole arena."""
if self._outs is not None:
merged = []
for lo, hi, cn, mult in sorted(o for o in self._outs if o[2] < 0): # glue: merges the declared output ranges
if merged and lo <= merged[-1][1]:
merged[-1][1] = max(merged[-1][1], hi)
else:
merged.append([lo, hi, -1, 1])
return merged + [list(o) for o in self._outs if o[2] >= 0] # glue: the counted output ranges
if not self._skip:
return None
merged = []
at = 0
for lo, hi in sorted(self._skip): # glue: merges the input and scratch ranges
if lo > at:
merged.append([at, lo])
at = max(at, hi)
if at < self.size:
merged.append([at, self.size])
return [r + [-1, 1] for r in merged] # glue: marks the download ranges uncounted
def floats(self, off, n):
"""Python floats (exact images of the Float32 results)."""
self._check(off, n)
return list(self.arena[off:off + n])
def ints(self, off, n):
self._check(off, n)
store = array.array("i")
store.frombytes(self.arena[off:off + n].tobytes())
return list(store)
def words(self, off, n, code):
"""The 4 * n bytes of [off, off + n) as an array of `code` ("i",
or "q" for the Int64 rows of `fold_rows` and `permute` wide)."""
self._check(off, n)
store = array.array(code)
store.frombytes(_arena_view(self.arena)[off:off + n].cast("B"))
return store
def get(self, off, shape):
shape = tuple(shape) if isinstance(shape, (tuple, list)) else (int(shape),)
n = 1
for s in shape: # glue: the product of shape dims
n *= s
self._check(off, n)
return Array._owned(self.arena[off:off + n], shape, "<f4", "C")
def _execute(prog, numeric_mode):
"""Run a program on the x_metrics binding (GPU, or the host binding on
a CPU-only install). A module-level function, not only a method, so the
lane selector (tools/lane_select.py follows file-local functions, not
classes) sees every caller reach `x_metrics_run`."""
arena = _new_arena(prog.size)
base = arena.buffer_info()[0]
stages = array.array("i", [v for s in prog._stages for v in s] or [0]) # glue: packs the stage parameter words
b = _binding(numeric_mode)
merged = prog._download()
run_ranges = _optional_metrics_entry(b, "x_metrics_run_ranges") if _arena_io.ranges_enabled() else None
spans = []
direct, gathered = None, []
for off, arr in prog._inputs: # glue: copies each program input once
if not arr.size:
continue
if isinstance(arr, _arena_io.DeviceRows):
# device-rows input (lane cpu4-misc): gathered on the device out
# of the base put once into this binding's store
slot = None
if run_ranges is not None:
if direct is None and _arena_io.DeviceCache.supports(b, "x_metrics"):
direct = _arena_io.DeviceCache(b, "x_metrics")
slot = _arena_io.rows_slot(b, "x_metrics", arr, direct)
if slot is not None:
gathered.append(slot)
spans.append((off, off + arr.size, slot))
continue
arr = arr.materialize()
cache = _arena_io.active_cache(b, "x_metrics", arr.size) if run_ranges is not None else None
if cache is not None:
# resident (lane py-shared): the device copies it from the store;
# the host arena's words stay zero and are never read (`_check`)
spans.append((off, off + arr.size, cache.id_of(arr)))
continue
ctypes.memmove(base + 4 * off, addr_ro(arr, name="input"), 4 * arr.size)
spans.append((off, off + arr.size, -1))
run_out = _optional_metrics_entry(b, "x_metrics_run_out") if merged is not None else None
if run_ranges is not None:
# the shared ranges runner (lane py-shared, core/arena_io.mojo): only
# the inputs go up, only the outputs come back
ins = _arena_io.input_ranges(spans)
outs = [list(r) for r in merged] if merged is not None else [[0, prog.size, -1, 1]] # glue: the download range descriptors
ia, oa = _arena_io.pack_ins(ins), _arena_io.pack_outs(outs)
try:
run_ranges(base, stages.buffer_info()[0], (prog.size, len(prog._stages), len(ins), len(outs)),
ia.buffer_info()[0], oa.buffer_info()[0])
finally:
for slot in gathered: # glue: frees each fold-rows slot
b.x_metrics_dev_free(slot)
if direct is not None:
direct.close()
elif run_out is not None:
outs = array.array("i", [v for r in merged for v in r] or [0, 0, -1, 1]) # glue: packs the download range descriptors
run_out(base, prog.size, stages.buffer_info()[0], len(prog._stages), outs.buffer_info()[0], len(merged))
else:
b.x_metrics_run(base, prog.size, stages.buffer_info()[0], len(prog._stages))
prog.arena = arena
return prog
def _group(prog, key, n, m, *, values=_NONE, vstride=1, weights=_NONE, width=1):
"""Stable counting sort of rows by `key` (int32 offset, -1 = dropped),
then one PairSum per (group, column). Returns (OFF, OUT) offsets."""
off = prog.alloc(m + 1)
order = prog.scratch(n)
out = prog.alloc(m * width)
prog.stage("group_sort", 1, key, n, m, off, order)
if m * width:
prog.stage("group_sum", m * width, off, order, values, vstride, weights, out, width)
return off, out
def _counts(prog, off, m):
"""The m group sizes of a `group_sort` OFF table as an Int32 slot the
device fills (`off_diff`, x_metrics/tail.mojo); read with prog.ints."""
out = prog.alloc(m)
if m:
prog.stage("off_diff", m, off, m, out)
return out
#: flag_scan tests (x_metrics/tail.mojo)
_SCAN_LOG_DOMAIN, _SCAN_INDICATOR, _SCAN_NEGATIVE = 0, 1, 2
def _scan_flag(prog, src, n, mode):
"""One Int32 flag word the device sets to 1 when any of the n Float32
values at `src` fails the test (`flag_scan`); 0 otherwise."""
flag = prog.alloc(1)
if n:
prog.stage("flag_scan", n, src, n, mode, flag)
return flag
def _flag_set(prog, flag):
return prog.ints(flag, 1)[0] != 0
def _put_proba(prog, a, n, k, binary):
"""The probabilities' check and layout in the program (`proba_rows`,
x_metrics/tail.mojo): (S, FLAGS). S is the (n, k) Float32 matrix the
metric reads: `a` itself, or for a binary column the `[1 - p, p]` rows
the device packs. FLAGS: three Int32 words (non-finite, outside [0, 1],
a row sum off one), read by `_proba_check` after the run."""
A = prog.put(a)
flags = prog.alloc(3)
S = A
if binary:
S = prog.scratch(2 * n)
if n:
prog.stage("proba_rows", n, A, n, 1 if binary else k, 1 if binary else 0, S, flags)
return S, flags
#: probability_rows codes 1..3, in the order the check reports them
_PROBA_REASONS = ("must be finite", "must lie in [0, 1]", "rows must sum to one within sqrt(float32 eps)")
def _proba_code(prog, flags):
"""0, or the first failed check (1 non-finite, 2 outside [0, 1], 3 a row sum)."""
f = prog.ints(flags, 3)
return 1 if f[0] else (2 if f[1] else (3 if f[2] else 0))
def _proba_check(prog, flags, caller):
code = _proba_code(prog, flags)
if code:
raise ValueError(f"mojolearn {caller}: y_proba " + _PROBA_REASONS[code - 1])
# ---------------------------------------------------------------------------
# Shared validation
# ---------------------------------------------------------------------------
def _weights(sample_weight, n, caller):
from ._metrics_impl import _sample_weight_f32
return None if sample_weight is None else _sample_weight_f32(sample_weight, n, caller)
def _refuse_multilabel(y, name, caller):
from .linear_model import _shape_of
shape = _shape_of(y)
if len(shape) == 2 and shape[1] != 1:
raise NotImplementedError(
f"mojolearn {caller}: {name} is 2-D; multilabel-indicator targets are NOT "
"IMPLEMENTED (metrics/NOT_IMPLEMENTED.tsv), only binary and multiclass 1-D labels")
def _pair(y_true, y_pred, sample_weight, caller):
"""(true, pred, kind, present, w): single-label targets of one kind."""
from ._metrics_impl import _classification_encoded, _label_set
_refuse_multilabel(y_true, "y_true", caller)
_refuse_multilabel(y_pred, "y_pred", caller)
true, kind = _classification_encoded(y_true, "y_true")
pred, pred_kind = _classification_encoded(y_pred, "y_pred")
if len(true) != len(pred):
raise ValueError(f"mojolearn {caller}: y_true and y_pred lengths differ")
if kind != pred_kind:
raise TypeError(f"mojolearn {caller}: y_true and y_pred must use the same label type")
if len(true) > 2147483647:
raise ValueError(f"mojolearn {caller}: at most INT32_MAX rows")
w = _weights(sample_weight, len(true), caller)
return true, pred, kind, sorted(_label_set(true) | _label_set(pred)), w # glue: sorts the distinct class labels
def _codes(labels_seq, order):
from ._metrics_impl import _label_map
index = {c: i for i, c in enumerate(order)} # glue: class index table
return _label_map(labels_seq, lambda v: index.get(v, -1))
def _epi_codes(average, zero_division):
"""(AVG, ZD) of x_metrics/cls_epi.mojo: 0 per label, 1 micro, 3 weighted,
2 the plain mean (macro, binary); 0 / 1 / 2 = zero_division 0.0, 1.0, NaN."""
zv = _zero_division_value(zero_division)
return {None: 0, "micro": 1, "weighted": 3}.get(average, 2), (2 if zv != zv else int(zv))
class _Sums:
"""Per-label tp / pred / true sums and the total, over `order` (every
label that can occur; the caller selects a prefix). Unweighted sums are
exact integers (the group sizes, differenced on the device: `off_diff`);
weighted ones are the Float32 PairSum of each group's weights, read as
Python floats. `epi` (lane fam2-prep-metrics; kind, k, AVG, ZD, FMODE,
beta^2) stages the set-wise epilogue in the same program (`cls_epi`,
x_metrics/cls_epi.mojo: the ratios, micro sums and averages as binary64
on the device, both numeric modes since lane cpu2-l7-metrics): `self.epi`
= (flags, kept, scalars, per-label values), else None (a list of
`epi` tuples stages one epilogue each and `self.epi` is the list of
results). `self.prog` and
`self.groups` (the three (OFF, OUT) groupings, then the total's) let a
caller that staged its own tail (`tail`: a function of (prog, groups)
that stages it and returns what to keep) read it back as `self.tail`."""
def __init__(self, true, pred, w, order, numeric_mode, epi=None, tail=None):
n, L = len(true), len(order)
yt, yp = _codes(true, order), _codes(pred, order)
prog = _Prog()
a = prog.put_i32(yt)
b = prog.put_i32(yp)
match = prog.scratch(n)
prog.stage("pair_key", n, a, b, match, L, 1)
W = _NONE if w is None else prog.put(w)
groups = [_group(prog, k, n, L, weights=W) for k in (match, a, b)] # glue: the three count groupings
if w is not None:
zero = prog.scratch(n) # every row in group 0: the total weight
prog.stage("pair_key", n, a, a, zero, 1, 2)
groups.append(_group(prog, zero, n, 1, weights=W))
cnt = [_counts(prog, off, L) for off, _ in groups] if w is None else None # glue: the three count slots
eos = []
for kind, k, avg, zd, fmode, b2 in ([epi] if isinstance(epi, tuple) else (epi or [])): # glue: one cls_epi stage per requested average
col = 0 if w is None else 1
bo = 0
if fmode == 2:
bw = array.array("i")
bw.frombytes(array.array("d", [1 + b2, b2]).tobytes())
bo = prog.put_i32(list(bw))
eo = prog.alloc(8 + 6 * max(L, 1))
prog.stage("cls_epi", 1, groups[0][col], groups[1][col], groups[2][col], k, col, kind, avg, zd, eo,
fmode, bo)
eos.append(eo)
self.tail = None
prog.rows = None if w is None else (match, W, n)
keep = tail(prog, groups) if tail is not None else None
_execute(prog, numeric_mode)
self.prog, self.groups = prog, groups
res = []
for eo in eos: # glue: reads each staged epilogue's flags, scalars and values
head = prog.ints(eo, 2)
res.append((head[0], head[1], prog.words(eo + 2, 6, "d"), prog.words(eo + 8, 6 * max(L, 1), "d")))
self.epi = (res[0] if res else None) if (epi is None or isinstance(epi, tuple)) else res
if w is None:
self.tp, self.true, self.pred = (prog.ints(c, L) for c in cnt) # glue: reads the three count slots
self.total = n
else:
self.tp, self.true, self.pred = (prog.floats(out, L) for _, out in groups[:3]) # glue: reads the weighted sums
self.total = prog.floats(groups[3][1], 1)[0]
self.weighted = w is not None
if tail is not None:
self.tail = keep
class UndefinedMetricWarning(UserWarning):
"""scikit-learn's `UndefinedMetricWarning` (a UserWarning): a metric that
is ill-defined for the input and was set to a fallback value. Defined
here so the metrics do not import scikit-learn."""
def _undefined_category():
"""The warning class to raise: scikit-learn's own when the caller has
already imported `sklearn.exceptions` (so its filters and `pytest.warns`
match), else `UndefinedMetricWarning` above. Never imports scikit-learn."""
import sys
sk = sys.modules.get("sklearn.exceptions")
return getattr(sk, "UndefinedMetricWarning", UndefinedMetricWarning)
def _undefined_warning(msg):
warnings.warn(msg, _undefined_category(), stacklevel=3)
def _zero_division_value(zero_division):
if isinstance(zero_division, str):
if zero_division == "warn":
return 0.0
elif not is_bool(zero_division) and isinstance(zero_division, numbers.Real):
if zero_division in (0, 1):
return float(zero_division)
if pmath.isnan(float(zero_division)):
return float("nan")
raise ValueError("zero_division must be 'warn', 0.0, 1.0 or np.nan")
def _set_wise_labels(present, kind, average, labels, pos_label, caller):
from ._metrics_impl import _classification_labels, _selected_labels
averages = (None, "micro", "macro", "weighted", "samples", "binary")
if average not in averages:
raise ValueError("average has to be one of (None, 'micro', 'macro', 'weighted', 'samples', 'binary')")
if average == "samples":
raise NotImplementedError(
f"mojolearn {caller}: average='samples' applies to multilabel targets, which are "
"NOT IMPLEMENTED (metrics/NOT_IMPLEMENTED.tsv)")
if average == "binary":
if len(present) > 2:
raise ValueError("Target is multiclass but average='binary'. Please choose another "
"average setting, one of [None, 'micro', 'macro', 'weighted'].")
values, pkind = _classification_labels([pos_label], "pos_label")
if pkind != kind:
raise ValueError("pos_label must use the same type as the targets")
if values[0] not in present and len(present) >= 2:
raise ValueError(f"pos_label={pos_label} is not a valid label. It should be one of {present}")
return [values[0]]
if pos_label not in (None, 1):
warnings.warn(f"Note that pos_label (set to {pos_label!r}) is ignored when average != 'binary' "
f"(got {average!r}). You may use labels=[pos_label] to specify a single positive class.",
UserWarning, stacklevel=3)
return _selected_labels(labels, kind, present) if labels is not None else list(present)
def _label_order(labels, present):
"""`labels` then every present label not in it (sklearn's hstack with
setdiff1d): the extra labels are counted and not reported."""
chosen = set(labels)
return list(labels) + [v for v in present if v not in chosen] # glue: appends the present class labels
# ---------------------------------------------------------------------------
# Classification
# ---------------------------------------------------------------------------
#: cm_epi kinds (x_metrics/cm_epi.mojo, op 59; lane cpu2-l7-metrics)
_CM_MLCM, _CM_MCC, _CM_COUNT, _CM_NORM, _CM_KAPPA, _CM_CLR, _CM_TOTAL, _CM_WSUM_LEAF, _CM_WSUM_FOLD = range(9)
#: rows per `_wsum_leaf` unit (x_metrics/cm_epi.mojo WSUM_BLOCK)
_WSUM_BLOCK = 256
#: the NORM modes: the 'all' total, then 'all', 'true', 'pred', none
_NORM_MODES = {"all": 1, "true": 2, "pred": 3, None: 4}
#: the kappa weight modes
_KAPPA_W = {None: 0, "linear": 1, "quadratic": 2}
def _wsum_sf64(prog, k):
"""Lane box-run-2-fix (2026-10-05): stage the weighted COUNT's hit and
weight total as soft binary64 sums on the device (x_metrics/cm_epi.mojo
`_wsum_leaf`: one unit per WSUM_BLOCK rows, then `_wsum_fold`: one stage
per tree level, stride 1, 2, 4, ...), the same stages on every column.
Returns the slot whose first 4 words are [hit, total]. Before, both were
Float32 PairSums widened to binary64 (precision loss in score with
sample_weight)."""
match, W, n = prog.rows
nb = max(1, -(-n // _WSUM_BLOCK))
s = prog.scratch(4 * nb)
prog.stage("cm_epi", nb, _CM_WSUM_LEAF, match, W, n, s, k)
stride = 1
while stride < nb: # glue: one stage per fold level (log2 of the block count)
prog.stage("cm_epi", -(-(nb - stride) // (2 * stride)), _CM_WSUM_FOLD, s, stride, nb)
stride *= 2
return s
def _cm_stage(prog, groups, w, n, k, kind, size):
"""Stage a cm_epi tail over a `_Sums` program's groupings (`tail=`):
the match / true / pred sums' OFF tables (unweighted) or PairSums
(weighted), the first k labels; the total is the row count n or the
total grouping's PairSum. Returns the `size`-word output slot."""
col = 0 if w is None else 1
out = prog.alloc(max(size, 1))
tp, tr, pr = groups[0][col], groups[1][col], groups[2][col]
tot = n if w is None else groups[3][1]
if kind == _CM_COUNT and w is not None:
# lane box-run-2-fix: the weighted COUNT reads soft-f64 [hit, total]
# (`_wsum_sf64`), not the Float32 PairSums
tot = _wsum_sf64(prog, k)
if kind == _CM_MLCM:
if k:
prog.stage("cm_epi", k, kind, tp, tr, pr, k, col, tot, out)
elif kind == _CM_MCC:
prog.stage("cm_epi", 1, kind, tp, tr, pr, k, col, out)
else:
prog.stage("cm_epi", 1, kind, tp, k, col, tot, out)
return out
def _cm_count(true, pred, w, present, numeric_mode):
"""The COUNT tail (x_metrics/cm_epi.mojo) over the match sums: the
program and its 11-word output (see `_count` there)."""
s = _Sums(true, pred, w, present, numeric_mode,
tail=lambda prog, groups: _cm_stage(prog, groups, w, len(true), len(present), _CM_COUNT, 11))
return s.prog, s.tail, s.weighted
# Engineering notes (moved from the docstring):
# Per-label one-vs-rest 2x2 confusion matrices, `[[tn, fp], [fn, tp]]`.
#
# scikit-learn 1.9 `multilabel_confusion_matrix` for binary and multiclass
# 1-D targets (strings or integers). Unweighted counts are exact Int64;
# with sample_weight they are Float64 images of Float32 per-label PairSums
# (DEVIATION 6100). Multilabel-indicator input and samplewise=True are
# refused by name (metrics/NOT_IMPLEMENTED.tsv).
[docs]
def multilabel_confusion_matrix(y_true, y_pred, *, sample_weight=None, labels=None,
samplewise=False, numeric_mode=None):
"""Per-label one-vs-rest 2x2 confusion matrices, ``[[tn, fp], [fn, tp]]``.
Follows scikit-learn's `multilabel_confusion_matrix` for binary and multiclass 1-D
targets (strings or integers). Unweighted counts are exact integers; with
`sample_weight` they are float64. Multilabel-indicator input and ``samplewise=True``
raise an error.
"""
if samplewise:
raise NotImplementedError("mojolearn multilabel_confusion_matrix: samplewise=True applies to "
"multilabel targets, which are NOT IMPLEMENTED")
from ._metrics_impl import _selected_labels
true, pred, kind, present, w = _pair(y_true, y_pred, sample_weight, "multilabel_confusion_matrix")
chosen = list(present) if labels is None else _selected_labels(labels, kind, present)
k = len(chosen)
# the device tail (x_metrics/cm_epi.mojo, MLCM): one unit per label writes
# tn, fp, fn, tp, exact Int64 counts or binary64 (weighted)
s = _Sums(true, pred, w, _label_order(chosen, present), numeric_mode,
tail=lambda prog, groups: _cm_stage(prog, groups, w, len(true), k, _CM_MLCM, 8 * k))
if s.weighted:
return Array._owned(s.prog.words(s.tail, 8 * k, "d"), (k, 2, 2), "<f8")
return Array._owned(s.prog.words(s.tail, 8 * k, "q"), (k, 2, 2), "<i8")
import threading as _threading
#: classification_report's per-call memo (thread local; None outside it)
_REPORT = _threading.local()
# Engineering notes (moved from the docstring):
# scikit-learn 1.9 `precision_recall_fscore_support` for binary and
# multiclass 1-D targets: per-label sums on the device (exact integers, or
# Float32 PairSums of sample_weight), the ratios and averages in the
# binary64 epilogue (DEVIATION 6106). zero_division is 'warn', 0.0, 1.0 or
# np.nan; average='samples' (multilabel) is refused by name.
[docs]
def precision_recall_fscore_support(y_true, y_pred, *, beta=1.0, labels=None, pos_label=1,
average=None, warn_for=("precision", "recall", "f-score"),
sample_weight=None, zero_division="warn", numeric_mode=None):
"""Precision, recall, F-score and support for each label.
Follows scikit-learn's `precision_recall_fscore_support` for binary and multiclass
1-D targets. `zero_division` is ``'warn'``, 0.0, 1.0 or ``np.nan``.
``average='samples'`` (multilabel) raises an error.
"""
if is_bool(beta) or not isinstance(beta, numbers.Real) or not beta >= 0:
raise ValueError("beta should be >=0 in the F-beta score")
_zero_division_value(zero_division)
# classification_report asks for the same inputs four times: inside it
# the encoded pair and the per-label sums are made once (lane
# metrics-apple3; `_REPORT.memo` is None everywhere else)
memo = getattr(_REPORT, "memo", None)
same = (id(y_true), id(y_pred), id(sample_weight))
hit = None if memo is None else memo.get(("pair", same))
if hit is None:
hit = _pair(y_true, y_pred, sample_weight, "precision_recall_fscore_support")
if memo is not None:
memo[("pair", same)] = hit
true, pred, kind, present, w = hit
chosen = _set_wise_labels(present, kind, average, labels, pos_label, "precision_recall_fscore_support")
order = _label_order(chosen, present)
k = len(chosen)
fb = float(beta)
fmode = 0 if pmath.isinf(fb) else (1 if fb == 0 else 2)
if memo is None:
# the epilogue runs in the sums' program (x_metrics/cls_epi.mojo)
s = _Sums(true, pred, w, order, numeric_mode,
epi=(2, k) + _epi_codes(average, zero_division) + (fmode, fb * fb))
res = s.epi
else:
# classification_report asks for the four averages of one input: one
# program makes the sums and stages all four epilogues
key = ("sums", same, tuple(order), numeric_mode, zero_division if isinstance(zero_division, str)
else float(zero_division), fb)
hit = memo.get(key)
if hit is None:
s = _Sums(true, pred, w, order, numeric_mode,
epi=[(2, k) + _epi_codes(a, zero_division) + (fmode, fb * fb) for a in _REPORT_AVERAGES]) # glue: the report's four averages
hit = memo[key] = (s, dict(zip(_REPORT_AVERAGES, s.epi)))
s, res = hit[0], hit[1][average]
bad, _, scal, vals = res
if isinstance(zero_division, str):
for bit, what in ((1, "precision"), (2, "recall"), (4, "f-score")): # glue: the three metric names
if bad & bit and what in warn_for:
_undefined_warning(f"{what.capitalize()} is ill-defined and being set to 0.0 in labels "
"with no predicted/true samples. Use `zero_division` parameter to "
"control this behavior.")
if average is None:
ts = s.true[:k]
support = (Array.from_list([float(v) for v in ts], "<f8") if s.weighted # glue: the k label supports
else Array.from_list([int(v) for v in ts], "<i8")) # glue: the k label supports
return (Array.from_list(list(vals[:k]), "<f8"), Array.from_list(list(vals[k:2 * k]), "<f8"),
Array.from_list(list(vals[2 * k:3 * k]), "<f8"), support)
return (scal[0], scal[1], scal[2], None)
#: the averages classification_report asks precision_recall_fscore_support for
_REPORT_AVERAGES = (None, "micro", "macro", "weighted")
[docs]
def fbeta_score(y_true, y_pred, *, beta, labels=None, pos_label=1, average="binary",
sample_weight=None, zero_division="warn", numeric_mode=None):
"""scikit-learn 1.9 `fbeta_score` (binary / multiclass 1-D targets); see
precision_recall_fscore_support."""
_, _, f, _ = precision_recall_fscore_support(
y_true, y_pred, beta=beta, labels=labels, pos_label=pos_label, average=average,
warn_for=("f-score",), sample_weight=sample_weight, zero_division=zero_division,
numeric_mode=numeric_mode)
return f
def _prf_weighted(y_true, y_pred, labels, pos_label, average, sample_weight, zero_division,
numeric_mode, metric):
"""precision_score / recall_score / f1_score WITH sample_weight: the
unweighted call keeps the original binding and its bits."""
names = ("precision", "recall", "f-score")
out = precision_recall_fscore_support(
y_true, y_pred, beta=1.0, labels=labels, pos_label=pos_label, average=average,
warn_for=(names[metric],), sample_weight=sample_weight, zero_division=zero_division,
numeric_mode=numeric_mode)
return out[metric]
[docs]
def jaccard_score(y_true, y_pred, *, labels=None, pos_label=1, average="binary",
sample_weight=None, zero_division="warn", numeric_mode=None):
"""scikit-learn 1.9 `jaccard_score`, `tp / (tp + fp + fn)` per label,
for binary and multiclass 1-D targets; see precision_recall_fscore_support
for the sums and the epilogue."""
_zero_division_value(zero_division)
true, pred, kind, present, w = _pair(y_true, y_pred, sample_weight, "jaccard_score")
chosen = _set_wise_labels(present, kind, average, labels, pos_label, "jaccard_score")
k = len(chosen)
s = _Sums(true, pred, w, _label_order(chosen, present), numeric_mode,
epi=(0, k) + _epi_codes(average, zero_division) + (0, 0.0))
# the device epilogue (x_metrics/cls_epi.mojo): the binary64 ratios and averages
bad, _, scal, vals = s.epi
if bad and isinstance(zero_division, str):
_undefined_warning("Jaccard is ill-defined and being set to 0.0 in labels with no true or "
"predicted samples. Use `zero_division` parameter to control this behavior.")
if average is None:
return Array.from_list(list(vals[:k]), "<f8")
return scal[0]
[docs]
def balanced_accuracy_score(y_true, y_pred, *, sample_weight=None, adjusted=False, numeric_mode=None):
"""scikit-learn 1.9 `balanced_accuracy_score`: the mean per-class recall
over the classes present in y_true (a class with no true weight is
dropped with sklearn's warning), chance-adjusted when `adjusted`."""
true, pred, kind, present, w = _pair(y_true, y_pred, sample_weight, "balanced_accuracy_score")
s = _Sums(true, pred, w, present, numeric_mode, epi=(1, len(present), 0, 1 if adjusted else 0, 0, 0.0))
# the device epilogue (x_metrics/cls_epi.mojo): the mean recall over the kept
# classes and the chance adjustment, binary64
if s.epi[1] != len(present):
warnings.warn("y_pred contains classes not in y_true", stacklevel=2)
return float(s.epi[2][0])
[docs]
def matthews_corrcoef(y_true, y_pred, *, sample_weight=None, numeric_mode=None):
"""scikit-learn 1.9 `matthews_corrcoef` (binary and multiclass): the
covariance form over the per-class true / predicted sums and the trace."""
true, pred, kind, present, w = _pair(y_true, y_pred, sample_weight, "matthews_corrcoef")
# the device tail (x_metrics/cm_epi.mojo, MCC): the sums, dots and
# covariances in binary64, one unit; flag 1 = a negative product
s = _Sums(true, pred, w, present, numeric_mode,
tail=lambda prog, groups: _cm_stage(prog, groups, w, len(true), len(present), _CM_MCC, 4))
if s.prog.ints(s.tail, 1)[0]:
raise ValueError("math domain error")
return float(s.prog.words(s.tail + 2, 2, "d")[0])
def _confusion(true, pred, w, order, numeric_mode, tail):
"""The k x k (weighted) confusion matrix over `order`, rows true, made on
the device and finished by `tail(prog, cells, col)` in the same program
(a cm_epi stage, x_metrics/cm_epi.mojo): `cells` is the k*k groups' OFF
table (col 0: exact counts, read by the unit as group sizes) or their
Float32 PairSums (col 1). Returns (prog, what the tail returned)."""
n, k = len(true), len(order)
if k > 4096:
raise ValueError("mojolearn metrics: at most 4096 labels in a confusion matrix")
prog = _Prog()
a = prog.put_i32(_codes(true, order))
b = prog.put_i32(_codes(pred, order))
key = prog.scratch(n)
prog.stage("pair_key", n, a, b, key, k, 0)
W = _NONE if w is None else prog.put(w)
off, out = _group(prog, key, n, k * k, weights=W)
keep = tail(prog, off if w is None else out, 0 if w is None else 1)
_execute(prog, numeric_mode)
return prog, keep
def confusion_matrix_weighted(y_true, y_pred, labels, sample_weight, normalize, numeric_mode):
"""confusion_matrix with sample_weight (scikit-learn 1.9): Float64 cells,
each the Float32 PairSum of its rows' weights; normalize in binary64."""
from ._metrics_impl import _selected_labels, _label_set
true, pred, kind, present, w = _pair(y_true, y_pred, sample_weight, "confusion_matrix")
chosen = _selected_labels(labels, kind, present)
if not set(chosen).intersection(_label_set(true)):
raise ValueError("At least one label specified must be in y_true")
k = len(chosen)
prog, out = _confusion(true, pred, w, chosen, numeric_mode,
lambda prog, cells, col: _normalize(prog, cells, col, k, normalize))
return Array._owned(prog.words(out, 2 * k * k, "d"), (k, k), "<f8")
def _normalize(prog, cells, col, k, normalize):
"""Stage the normalization of the k x k cells (x_metrics/cm_epi.mojo,
NORM: one unit per row ('true', 'all', none) or column ('pred'), each sum
binary64 from 0.0 in ascending index, a zero sum giving 0.0 cells; 'all'
first sums every cell in one unit). Returns the binary64 output slot."""
out = prog.want(prog.alloc(2 * k * k), 2 * k * k)
ts = prog.scratch(2)
if normalize == "all":
prog.stage("cm_epi", 1, _CM_NORM, cells, k, col, 0, out, ts)
if k:
prog.stage("cm_epi", k, _CM_NORM, cells, k, col, _NORM_MODES[normalize], out, ts)
return out
[docs]
def cohen_kappa_score(y1, y2, *, labels=None, weights=None, sample_weight=None,
replace_undefined_by=float("nan"), numeric_mode=None):
"""scikit-learn 1.9 `cohen_kappa_score`: the (weighted) confusion matrix
over `labels` on the device, the kappa in the binary64 epilogue;
weights None, 'linear' or 'quadratic'."""
from ._metrics_impl import _selected_labels, _label_set
if weights not in (None, "linear", "quadratic"):
raise ValueError("weights must be None, 'linear' or 'quadratic'")
true, pred, kind, present, w = _pair(y1, y2, sample_weight, "cohen_kappa_score")
chosen = _selected_labels(labels, kind, present)
if not set(chosen).intersection(_label_set(true)):
raise ValueError("At least one label in `labels` must be present in `y1` (even though "
"`cohen_kappa_score` is otherwise agnostic to the order of `y1` and `y2`).")
k = len(chosen)
def tail(prog, cells, col):
# x_metrics/cm_epi.mojo, KAPPA: row / column sums (one unit per
# class), den (one unit), the weighted row partials (one unit per
# row), then num_k / den_k summed by row and the kappa (one unit)
out = prog.want(prog.alloc(4), 4)
sc = prog.scratch(8 * k + 2)
wm = _KAPPA_W[weights]
for phase, units in ((0, k), (1, 1), (2, k), (3, 1)): # glue: the four kappa stages
if units:
prog.stage("cm_epi", units, _CM_KAPPA, cells, k, col, phase, wm, out, sc)
return out
prog, out = _confusion(true, pred, w, chosen, numeric_mode, tail)
flags = prog.ints(out, 1)[0]
if flags & 1:
_undefined_warning("`y2` contains no labels that are present in both `y1` and `labels`.")
return replace_undefined_by
if flags & 2:
_undefined_warning("`y1`, `y2` and `labels` have only one label in common.")
return replace_undefined_by
return float(prog.words(out + 2, 2, "d")[0])
[docs]
def hamming_loss(y_true, y_pred, *, sample_weight=None, numeric_mode=None):
"""scikit-learn 1.9 `hamming_loss` for binary / multiclass 1-D targets:
the (weighted) fraction of mismatched labels."""
true, pred, kind, present, w = _pair(y_true, y_pred, sample_weight, "hamming_loss")
# the device tail (x_metrics/cm_epi.mojo, COUNT): (total - hit) / total
prog, out, _ = _cm_count(true, pred, w, present, numeric_mode)
if prog.ints(out, 1)[0]:
raise ZeroDivisionError("float division by zero")
return float(prog.words(out + 6, 2, "d")[0])
[docs]
def zero_one_loss(y_true, y_pred, *, normalize=True, sample_weight=None, numeric_mode=None):
"""scikit-learn 1.9 `zero_one_loss` for binary / multiclass 1-D targets."""
if not is_bool(normalize):
raise ValueError("normalize must be a bool")
true, pred, kind, present, w = _pair(y_true, y_pred, sample_weight, "zero_one_loss")
# the device tail (x_metrics/cm_epi.mojo, COUNT): total - hit and its ratio
prog, out, weighted = _cm_count(true, pred, w, present, numeric_mode)
if normalize:
if prog.ints(out, 1)[0]:
raise ZeroDivisionError("float division by zero" if weighted else "division by zero")
return float(prog.words(out + 6, 2, "d")[0])
return float(prog.words(out + 4, 2, "d")[0]) if weighted else prog.ints(out + 10, 1)[0]
def _accuracy_sums(y_true, y_pred, sample_weight, numeric_mode):
"""(the (weighted) number of matches, their fraction of the row count
(at least 1) or the weight total), both from the device tail
(x_metrics/cm_epi.mojo, COUNT). A zero weight total raises as the
division did."""
true, pred, kind, present, w = _pair(y_true, y_pred, sample_weight, "accuracy_score")
prog, out, weighted = _cm_count(true, pred, w, present, numeric_mode)
if not weighted:
return prog.ints(out + 1, 1)[0], float(prog.words(out + 8, 2, "d")[0])
hit = float(prog.words(out + 2, 2, "d")[0])
if prog.ints(out, 1)[0]:
return hit, None
return hit, float(prog.words(out + 8, 2, "d")[0])
def accuracy_count(y_true, y_pred, sample_weight, numeric_mode):
"""accuracy_score(normalize=False): the (weighted) number of matches."""
return _accuracy_sums(y_true, y_pred, sample_weight, numeric_mode)[0]
def accuracy_fraction(y_true, y_pred, sample_weight=None, numeric_mode=None):
"""sklearn's ClassifierMixin.score / accuracy_score over labels of any
kind: the (weighted) match count over the row count or the weight total,
both from the x_metrics binding's grouped sums (lane pyglue-sweep: the
weight total is the program's PairSum, not a host sum; lane
box-run-2-fix: the weighted hit and total are soft-f64 sums on the
device, `_wsum_sf64`, no longer Float32 PairSums). Lane
cgr4-py-compute: the estimators' `score` methods called this instead of
a per-row Python comparison."""
frac = _accuracy_sums(y_true, y_pred, sample_weight, numeric_mode)[1]
if frac is None:
raise ZeroDivisionError("float division by zero")
return frac
[docs]
def class_likelihood_ratios(y_true, y_pred, *, labels=None, sample_weight=None,
replace_undefined_by=float("nan"), numeric_mode=None):
"""scikit-learn 1.9 `class_likelihood_ratios` (binary targets): LR+ and
LR- from the 2 x 2 (weighted) confusion matrix; `replace_undefined_by`
np.nan, 1.0 or {'LR+': v, 'LR-': v}."""
from ._metrics_impl import _selected_labels
true, pred, kind, present, w = _pair(y_true, y_pred, sample_weight, "class_likelihood_ratios")
if len(present) > 2:
raise ValueError("class_likelihood_ratios only supports binary classification problems, "
"got targets of type: multiclass")
nan = float("nan")
rub = replace_undefined_by
if isinstance(rub, numbers.Real) and not is_bool(rub) and rub == 1.0:
rub = {"LR+": 1.0, "LR-": 1.0}
if isinstance(rub, dict):
pos, neg = rub.get("LR+"), rub.get("LR-")
if (set(rub) != {"LR+", "LR-"} or not isinstance(pos, numbers.Real)
or not isinstance(neg, numbers.Real)
or not (pos >= 1.0 or pmath.isnan(pos)) or not (0.0 <= neg <= 1.0 or pmath.isnan(neg))):
raise ValueError("The dictionary passed as `replace_undefined_by` needs to be in the form "
"`{'LR+': `value_1`, 'LR-': `value_2`}`")
elif not (isinstance(rub, float) and pmath.isnan(rub)):
raise ValueError("replace_undefined_by must be np.nan, 1.0 or a dict")
chosen = _selected_labels(labels, kind, present) if labels is not None else list(present)
if len(chosen) == 1:
chosen = chosen + [v for v in present if v not in chosen] # glue: appends the present class labels
k = len(chosen)
if k > 4096:
raise ValueError("mojolearn metrics: at most 4096 labels in a confusion matrix")
if k != 2:
raise ValueError("class_likelihood_ratios needs a 2 x 2 confusion matrix")
def tail(prog, cells, col):
# x_metrics/cm_epi.mojo, CLR: the supports and both ratios, binary64
out = prog.want(prog.alloc(6), 6)
prog.stage("cm_epi", 1, _CM_CLR, cells, col, out)
return out
prog, out = _confusion(true, pred, w, chosen, numeric_mode, tail)
flags = prog.ints(out, 1)[0]
if flags & 1:
_undefined_warning("No samples of the positive class are present in `y_true`.")
return nan, nan
lr = prog.words(out + 2, 4, "d")
lr_pos, lr_neg = lr[0], lr[1]
if flags & 2:
_undefined_warning("`positive_likelihood_ratio` is ill-defined and set to `np.nan`.")
lr_pos = rub if not isinstance(rub, dict) else rub["LR+"]
if flags & 4:
_undefined_warning("`negative_likelihood_ratio` is ill-defined and set to `np.nan`.")
lr_neg = rub if not isinstance(rub, dict) else rub["LR-"]
return float(lr_pos), float(lr_neg)
[docs]
def classification_report(y_true, y_pred, *, labels=None, target_names=None, sample_weight=None,
digits=2, output_dict=False, zero_division="warn", numeric_mode=None):
"""scikit-learn 1.9 `classification_report` for binary / multiclass 1-D
targets, from precision_recall_fscore_support."""
from ._metrics_impl import _classification_encoded, _label_set
true, _ = _classification_encoded(y_true, "y_true")
pred, _ = _classification_encoded(y_pred, "y_pred")
present = sorted(_label_set(true) | _label_set(pred)) # glue: sorts the distinct class labels
labels_given = labels is not None
chosen = list(labels) if labels_given else present
micro_is_accuracy = not labels_given or set(chosen) >= set(present)
if target_names is not None and len(target_names) != len(chosen):
raise ValueError(f"Number of classes, {len(chosen)}, does not match size of target_names, "
f"{len(target_names)}. Try specifying the labels parameter")
names = [str(t) for t in target_names] if target_names is not None else [str(c) for c in chosen] # glue: formats the report's row names
headers = ["precision", "recall", "f1-score", "support"]
if getattr(_REPORT, "memo", None) is None:
_REPORT.memo = {}
try:
return _classification_report(y_true, y_pred, labels_given, chosen, present, names, headers,
micro_is_accuracy, sample_weight, digits, output_dict,
zero_division, numeric_mode)
finally:
_REPORT.memo = None
return _classification_report(y_true, y_pred, labels_given, chosen, present, names, headers,
micro_is_accuracy, sample_weight, digits, output_dict, zero_division,
numeric_mode)
def _support_total(support, numeric_mode):
"""The report's support total on the device (x_metrics/cm_epi.mojo,
TOTAL: one unit, from 0 in label order): an exact Int64 of the Int64
supports (each below 2^31), or the binary64 sum of the weighted ones
(Float32 PairSum images, so the Float32 copy is exact)."""
k = support.size
weighted = support.dtype == "<f8"
prog = _Prog()
src = prog.put(support) if weighted else prog.put_i32(support)
out = prog.want(prog.alloc(2), 2)
prog.stage("cm_epi", 1, _CM_TOTAL, src, k, 1 if weighted else 0, out)
_execute(prog, numeric_mode)
return prog.words(out, 2, "d" if weighted else "q")[0]
def _classification_report(y_true, y_pred, labels_given, chosen, present, names, headers, micro_is_accuracy,
sample_weight, digits, output_dict, zero_division, numeric_mode):
p, r, f, s = precision_recall_fscore_support(y_true, y_pred, labels=chosen, average=None,
sample_weight=sample_weight,
zero_division=zero_division, numeric_mode=numeric_mode)
rows = list(zip(names, p.tolist(), r.tolist(), f.tolist(), s.tolist()))
averages = (["micro avg"] if not micro_is_accuracy else []) + ["macro avg", "weighted avg"]
report = {}
for name, a, b, c, d in rows: # glue: formats the text report rows
report[name] = dict(zip(headers, (a, b, c, d)))
total = _support_total(s, numeric_mode)
avg_rows = []
if micro_is_accuracy:
acc = precision_recall_fscore_support(y_true, y_pred, labels=chosen, average="micro",
sample_weight=sample_weight, zero_division=zero_division,
numeric_mode=numeric_mode)[0]
report["accuracy"] = acc
for avg in averages: # glue: formats the report's average rows
a, b, c, _ = precision_recall_fscore_support(y_true, y_pred, labels=chosen,
average=avg.split()[0],
sample_weight=sample_weight,
zero_division=zero_division, numeric_mode=numeric_mode)
report[avg] = dict(zip(headers, (a, b, c, total)))
avg_rows.append((avg, a, b, c, total))
if output_dict:
return report
width = max([len(n) for n in names] + [len("weighted avg"), digits]) # glue: the report's text column width
head_fmt = "{:>{width}s} " + " {:>9}" * len(headers)
out = head_fmt.format("", *headers, width=width) + "\n\n"
row_fmt = "{:>{width}s} " + " {:>9.{digits}f}" * 3 + " {:>9}\n"
for name, a, b, c, d in rows: # glue: formats the text report rows
out += row_fmt.format(name, a, b, c, d, width=width, digits=digits)
out += "\n"
if micro_is_accuracy:
acc_fmt = "{:>{width}s} " + " {:>9.{digits}}" * 2 + " {:>9.{digits}f}" + " {:>9}\n"
out += acc_fmt.format("accuracy", "", "", report["accuracy"], total, width=width, digits=digits)
for avg, a, b, c, d in avg_rows: # glue: formats the report's average rows
out += row_fmt.format(avg, a, b, c, d, width=width, digits=digits)
return out
# ---------------------------------------------------------------------------
# Regression (scikit-learn 1.9 sklearn/metrics/_regression.py)
# ---------------------------------------------------------------------------
_TERM = dict(sq=0, abs=1, sqlog=2, ape=3, pinball=4, tweedie=5, diff=6)
def _f32(x):
"""The Float32 nearest a binary64 value (round to nearest even)."""
import struct
return struct.unpack("<f", struct.pack("<f", float(x)))[0]
#: reg_epi (op 60, x_metrics/reg_epi.mojo): kinds, MEAN modes, AVG modes
_RE_MEAN, _RE_PCT, _RE_ASM, _RE_AVG, _RE_SIGN = 0, 1, 2, 3, 4
_RE_DIV, _RE_ROOT = 1, 2
_RE_UNIFORM, _RE_CUSTOM, _RE_VARIANCE = 1, 2, 3
class _Reg:
"""Validated regression targets: (n, D) Float32, finite, same shape."""
def __init__(self, y_true, y_pred, sample_weight, multioutput, caller, *, variance_ok=False):
from ._metrics_impl import _shape_of, _is_float64
from ._buffer import materialize_f32_lists
arrays = []
for name, v in (("y_true", y_true), ("y_pred", y_pred)): # glue: validates the two named arguments
a = materialize_f32_lists(v, name)[0]
if a.dtype != "<f4":
raise TypeError(f"mojolearn {caller}: {name} must have dtype float32; cast explicitly before scoring")
if a.ndim == 1:
a = a.reshape((a.shape[0], 1))
if a.ndim != 2 or a.shape[0] == 0 or a.shape[1] == 0:
raise ValueError(f"mojolearn {caller}: {name} must be a nonempty (n,) or (n, n_outputs) array")
a = as_f32_c(a, ndim=2, name=name)[0]
if not all_finite(a):
raise ValueError(f"mojolearn {caller}: {name} contains NaN or infinity")
arrays.append(a)
self.y, self.p = arrays
if self.y.shape != self.p.shape:
raise ValueError(f"mojolearn {caller}: y_true and y_pred have different shapes "
f"{self.y.shape} and {self.p.shape}")
self.n, self.D = self.y.shape
self.w = _weights(sample_weight, self.n, caller)
allowed = ("raw_values", "uniform_average") + (("variance_weighted",) if variance_ok else ())
if isinstance(multioutput, str):
if multioutput not in allowed:
raise ValueError(f"Allowed 'multioutput' string values are {allowed}. "
f"You provided multioutput={multioutput!r}")
self.mo = multioutput
elif multioutput is None:
self.mo = "uniform_average"
else:
vals = [float(v) for v in flatten_mo(multioutput)] # glue: converts the multioutput weights argument
if self.D == 1:
raise ValueError("Custom weights are useful only in multi-output cases.")
if len(vals) != self.D:
raise ValueError("There must be equally many custom weights "
f"({len(vals)}) as outputs ({self.D}).")
self.mo = vals
self.caller = caller
# Lane cpu2-l7-metrics (2026-10-04): every regression metric is ONE
# program. The per-output tails (the means, roots, percentile flags, the
# Float32 broadcast columns, the r2 / explained variance / d2 assembly,
# the multioutput averages) are `reg_epi` stages (op 60,
# x_metrics/reg_epi.mojo) in soft binary64 next to the folds that make
# their inputs; only the final value(s) come back. DEVIATION 6106: the
# same binary64 operations, in the same order, as the Python they replace.
def program(self, scalar=None):
"""Start this metric's program: y, p, the kind's scalar (alpha or
power, rounded to Float32), the weights, the one-group key and the
weight total."""
prog = self.prog = _Prog()
self.Y, self.P = prog.put(self.y), prog.put(self.p)
self.S = prog.put(Array.from_list([_f32(0.0 if scalar is None else scalar)], "<f4"))
self.W = _NONE if self.w is None else prog.put(self.w)
self.key = prog.scratch(self.n)
prog.stage("pair_key", self.n, 0, 0, self.key, 1, 2)
self.SW = _NONE if self.w is None else _group(prog, self.key, self.n, 1, weights=self.W)[1]
return prog
def term(self, kind, V=None, P=None, broadcast=False):
"""The n x D Float32 terms of `kind` (reg_term) of V (default y)
against P (default p; D per-column values when broadcast)."""
out = self.prog.scratch(self.n * self.D)
self.prog.stage("reg_term", self.n * self.D, self.Y if V is None else V, self.P if P is None else P, out,
self.D, _TERM[kind], self.S, 1 if broadcast else 0)
return out
def sums(self, V):
"""The D per-column (weighted) Float32 PairSums of the n x D values at V."""
return _group(self.prog, self.key, self.n, 1, values=V, vstride=self.D, weights=self.W, width=self.D)[1]
def fin(self, sums, mode=_RE_DIV, *, f32=False):
"""reg_epi MEAN: D binary64 values from the Float32 sums, widened,
over the weight total or n (_RE_DIV), rooted (_RE_ROOT); with f32,
also (dst, d32) where d32 holds the Float32 nearest each, the
broadcast column a later reg_term reads."""
prog, D = self.prog, self.D
dst = prog.alloc(2 * D)
d32 = prog.scratch(D) if f32 else _NONE
prog.stage("reg_epi", D, _RE_MEAN, D, sums, self.SW, self.n, dst, d32, mode)
return (dst, d32) if f32 else dst
def stage_mean(self, kind, mode=_RE_DIV, **kw):
"""The per-column (weighted) mean of the term, binary64 (DEVIATION 6106)."""
return self.fin(self.sums(self.term(kind, **kw)), mode)
def percentile(self, V, rank, *, average=True, f32=False):
"""Per-column weighted percentile of the n x D Float32 values at V
(col_sort, wpercentile) as binary64, NaN for an all-zero weight
column (reg_epi PCT); f32 as in `fin`."""
prog, n, D = self.prog, self.n, self.D
order = prog.scratch(n * D)
prog.stage("col_sort", D, V, n, D, order)
R = prog.put(Array.from_list([_f32(rank)], "<f4"))
out = prog.scratch(D)
cdf = prog.scratch(n * D)
prog.stage("wpercentile", D, V, n, D, order, self.W, R, 1 if average else 0, out, cdf)
dst = prog.alloc(2 * D)
d32 = prog.scratch(D) if f32 else _NONE
prog.stage("reg_epi", D, _RE_PCT, D, out, cdf, n, dst, d32)
return (dst, d32) if f32 else dst
def scan_sign(self, V):
"""Two flag words the device sets: a value < 0, a value <= 0 (reg_epi SIGN)."""
flags = self.prog.alloc(2)
self.prog.stage("reg_epi", self.n * self.D, _RE_SIGN, self.n * self.D, V, flags)
return self.prog.want(flags, 2)
def scan_log_domain(self):
"""One flag word: a y or p value <= -1 (flag_scan)."""
N = self.n * self.D
flag = _scan_flag(self.prog, self.Y, N, _SCAN_LOG_DOMAIN)
self.prog.stage("flag_scan", N, self.P, N, _SCAN_LOG_DOMAIN, flag)
return self.prog.want(flag, 1)
def average(self, vals, *, nan_rule=False, variance=_NONE):
"""The multioutput answer of the D binary64 values at `vals`: the
values themselves (raw_values), else one reg_epi AVG unit."""
prog, D, mo = self.prog, self.D, self.mo
if mo == "raw_values":
return prog.want(vals, 2 * D)
if mo == "uniform_average":
mode, w = _RE_UNIFORM, _NONE
elif mo == "variance_weighted":
mode, w = _RE_VARIANCE, variance
else:
mode, w = _RE_CUSTOM, prog.put_i32(_words64(mo))
dst = prog.alloc(2)
prog.stage("reg_epi", 1, _RE_AVG, D, vals, mode, w, dst, 1 if nan_rule else 0)
return prog.want(dst, 2)
def result(self, out, numeric_mode, check=None):
"""Run the program, let `check(prog)` raise on its flags, then the
answer: a float, or the per-output Float64 Array for raw_values."""
_execute(self.prog, numeric_mode)
if check is not None:
check(self.prog)
if self.mo == "raw_values":
return Array.from_list(list(self.prog.words(out, 2 * self.D, "d")), "<f8")
return float(self.prog.words(out, 2, "d")[0])
def scalar(self, out, numeric_mode, check=None):
"""Run the program; the one binary64 word pair at `out` (single output)."""
self.prog.want(out, 2)
_execute(self.prog, numeric_mode)
if check is not None:
check(self.prog)
return float(self.prog.words(out, 2, "d")[0])
def _words64(values):
"""binary64 values as their Int32 word pairs, low first (reg_epi ld64)."""
store = array.array("i")
store.frombytes(array.array("d", values).tobytes())
return store
def flatten_mo(values):
from ._labels import flatten_labels
return flatten_labels(values)
def _mean_error(kind, y_true, y_pred, sample_weight, multioutput, numeric_mode, caller, *, root=False,
scalar=None, log_domain=None):
"""The per-output (root) mean of the term, averaged, in one program;
`log_domain` names the metric whose log-domain refusal scans y and p
in the same program (raised before any result is returned)."""
r = _Reg(y_true, y_pred, sample_weight, multioutput, caller)
r.program(scalar)
flag = r.scan_log_domain() if log_domain else None
out = r.average(r.stage_mean(kind, _RE_DIV | (_RE_ROOT if root else 0)))
return r.result(out, numeric_mode, None if flag is None else lambda prog: _refuse_log_domain(prog, flag, log_domain))
def regression_error_options(name, y_true, y_pred, sample_weight, multioutput, numeric_mode):
"""mean_squared_error / mean_absolute_error / root_mean_squared_error
with sample_weight, multioutput or 2-D targets (lane/metrics); the 1-D
unweighted call keeps its original kernel and bits."""
kind = {"mean_squared_error": "sq", "mean_absolute_error": "abs", "root_mean_squared_error": "sq"}[name]
return _mean_error(kind, y_true, y_pred, sample_weight, multioutput, numeric_mode, name,
root=name == "root_mean_squared_error")
[docs]
def mean_squared_log_error(y_true, y_pred, *, sample_weight=None, multioutput="uniform_average",
numeric_mode=None):
"""scikit-learn 1.9 `mean_squared_log_error`: the mean of
`(log1p(y) - log1p(p))^2` (portable log1p, IDENTITY_PATHS row 51);
values <= -1 are refused as scikit-learn refuses them."""
return _mean_error("sqlog", y_true, y_pred, sample_weight, multioutput, numeric_mode,
"mean_squared_log_error", log_domain="Mean Squared Logarithmic Error")
[docs]
def root_mean_squared_log_error(y_true, y_pred, *, sample_weight=None, multioutput="uniform_average",
numeric_mode=None):
"""scikit-learn 1.9 `root_mean_squared_log_error` (per-output root, then averaged)."""
return _mean_error("sqlog", y_true, y_pred, sample_weight, multioutput, numeric_mode,
"root_mean_squared_log_error", root=True, log_domain="Root Mean Squared Logarithmic Error")
def _refuse_log_domain(prog, flag, what):
"""The log-domain refusal from the program's flag_scan word (lane
cpu2-l7-metrics: the O(n) host min left the GPU route)."""
if _flag_set(prog, flag):
raise ValueError(f"{what} cannot be used when targets contain values less than or equal to -1.")
[docs]
def mean_absolute_percentage_error(y_true, y_pred, *, sample_weight=None, multioutput="uniform_average",
numeric_mode=None):
"""scikit-learn 1.9 `mean_absolute_percentage_error`: `|p - y| / max(|y|,
eps)` with numpy's float64 epsilon, averaged (not multiplied by 100)."""
return _mean_error("ape", y_true, y_pred, sample_weight, multioutput, numeric_mode,
"mean_absolute_percentage_error")
def _check_alpha(alpha):
if is_bool(alpha) or not isinstance(alpha, numbers.Real) or not 0 <= alpha <= 1:
raise ValueError("alpha must be a real number in [0, 1]")
return float(alpha)
[docs]
def mean_pinball_loss(y_true, y_pred, *, sample_weight=None, alpha=0.5, multioutput="uniform_average",
numeric_mode=None):
"""scikit-learn 1.9 `mean_pinball_loss` (alpha is rounded to Float32)."""
return _mean_error("pinball", y_true, y_pred, sample_weight, multioutput, numeric_mode,
"mean_pinball_loss", scalar=_check_alpha(alpha))
[docs]
def max_error(y_true, y_pred, *, numeric_mode=None):
"""scikit-learn 1.9 `max_error` (single output)."""
r = _Reg(y_true, y_pred, None, "uniform_average", "max_error")
if r.D != 1:
raise ValueError("Multioutput not supported in max_error")
prog = _Prog()
Y, P = prog.put(r.y), prog.put(r.p)
t = prog.scratch(r.n)
prog.stage("reg_term", r.n, Y, P, t, 1, _TERM["abs"], Y, 0)
out = prog.want(prog.alloc(1), 1)
prog.stage("col_max", 1, t, r.n, 1, out)
_execute(prog, numeric_mode)
return float(prog.floats(out, 1)[0])
def _assemble(r, num, den, force_finite, *, average=True):
"""Stage the per-output scores `1 - num / den` (reg_epi ASM, the
force_finite convention) and their multioutput average (reg_epi AVG
with the NaN rule: a NaN score, or an infinite one with weight zero,
answers NaN; variance_weighted weighs by `den` unless it is all zero)."""
prog, D = r.prog, r.D
scores = prog.alloc(2 * D)
prog.stage("reg_epi", D, _RE_ASM, D, num, den, 1 if force_finite else 0, scores)
return r.average(scores, nan_rule=True, variance=den) if average else scores
[docs]
def explained_variance_score(y_true, y_pred, *, sample_weight=None, multioutput="uniform_average",
force_finite=True, numeric_mode=None):
"""scikit-learn 1.9 `explained_variance_score`: `1 - Var(y - p) / Var(y)`
with (weighted) means, one program (the means, then the centered
squares); force_finite=False returns scikit-learn's NaN / -inf by value."""
r = _Reg(y_true, y_pred, sample_weight, multioutput, "explained_variance_score", variance_ok=True)
r.program()
diff, diff_mean, y_mean = _diff_and_y_means(r)
num = _centered(r, diff, diff_mean)
den = _centered(r, r.Y, y_mean)
return r.result(_assemble(r, num, den, force_finite), numeric_mode)
def _diff_and_y_means(r):
"""Stage y - p and the per-column (weighted) means of y - p and of y,
each as the Float32 broadcast column the centered terms read:
(diff, diff_mean32, y_mean32)."""
diff = r.term("diff")
return diff, r.fin(r.sums(diff), f32=True)[1], r.fin(r.sums(r.Y), f32=True)[1]
def _centered(r, V, M, *, mean=True):
"""Stage the per-column (weighted) mean (or the sum, widened) of
(v - m_c)^2 over the n x D values at V and the Float32 column M:
D binary64 values."""
return r.fin(r.sums(r.term("sq", V=V, P=M, broadcast=True)), _RE_DIV if mean else 0)
def r2_score_options(y_true, y_pred, sample_weight, multioutput, force_finite, numeric_mode):
"""r2_score with multioutput, 2-D targets or force_finite=False
(lane/metrics): `sum w (y - p)^2` over `sum w (y - avg)^2` per output;
the 1-D default call keeps its original kernel and bits."""
r = _Reg(y_true, y_pred, sample_weight, multioutput, "r2_score", variance_ok=True)
if r.n < 2:
_undefined_warning("R^2 score is not well-defined with less than two samples.")
return float("nan")
r.program()
num, den = _r2_parts(r)
return r.result(_assemble(r, num, den, force_finite), numeric_mode)
def _r2_parts(r):
"""Stage SS_res and SS_tot per output (binary64 widenings of the Float32
sums; the y mean rounded to Float32 in the program)."""
y_mean = r.fin(r.sums(r.Y), f32=True)[1]
return _centered_sse(r), _centered(r, r.Y, y_mean, mean=False)
def _centered_sse(r):
return r.fin(r.sums(r.term("sq")), 0)
def _r2_sums_of(r, numeric_mode):
"""(SS_res, SS_tot) of a single-output `_Reg` from one program
(linear_model._r2_sums)."""
r.program()
num, den = _r2_parts(r)
words = r.prog.want(num, 2), r.prog.want(den, 2)
_execute(r.prog, numeric_mode)
return float(r.prog.words(words[0], 2, "d")[0]), float(r.prog.words(words[1], 2, "d")[0])
def _tweedie_domain(r, power, caller):
"""The power refusal now; the y and p sign scans staged in r's program
(reg_epi SIGN, lane cpu2-l7-metrics: the O(n) host mins left the GPU
route). Returns the check that raises after the run, before a result."""
msg = f"Mean Tweedie deviance error with power={power} can only be used on "
if not (power <= 0 or power >= 1):
raise ValueError(f"mojolearn {caller}: power in (0, 1) is not a Tweedie distribution")
if power == 0:
return None
fy, fp = r.scan_sign(r.Y), r.scan_sign(r.P)
def check(prog):
y_neg, y_nonpos = prog.ints(fy, 2)
p_nonpos = prog.ints(fp, 2)[1]
if power < 0:
if p_nonpos:
raise ValueError(msg + "strictly positive y_pred.")
elif 1 <= power < 2:
if y_neg or p_nonpos:
raise ValueError(msg + "non-negative y and strictly positive y_pred.")
elif power >= 2:
if y_nonpos or p_nonpos:
raise ValueError(msg + "strictly positive y and y_pred.")
return check
[docs]
def mean_tweedie_deviance(y_true, y_pred, *, sample_weight=None, power=0, numeric_mode=None):
"""scikit-learn 1.9 `mean_tweedie_deviance` (single output; the power is
rounded to Float32; pow/log are the portable row-12 functions)."""
if is_bool(power) or not isinstance(power, numbers.Real):
raise ValueError("power must be a real number")
r = _Reg(y_true, y_pred, sample_weight, "uniform_average", "mean_tweedie_deviance")
if r.D != 1:
raise ValueError("Multioutput not supported in mean_tweedie_deviance")
r.program(float(power))
check = _tweedie_domain(r, power, "mean_tweedie_deviance")
return r.scalar(r.stage_mean("tweedie"), numeric_mode, check)
[docs]
def mean_poisson_deviance(y_true, y_pred, *, sample_weight=None, numeric_mode=None):
"""scikit-learn 1.9 `mean_poisson_deviance` (Tweedie power 1)."""
return mean_tweedie_deviance(y_true, y_pred, sample_weight=sample_weight, power=1, numeric_mode=numeric_mode)
[docs]
def mean_gamma_deviance(y_true, y_pred, *, sample_weight=None, numeric_mode=None):
"""scikit-learn 1.9 `mean_gamma_deviance` (Tweedie power 2)."""
return mean_tweedie_deviance(y_true, y_pred, sample_weight=sample_weight, power=2, numeric_mode=numeric_mode)
[docs]
def d2_tweedie_score(y_true, y_pred, *, sample_weight=None, power=0, numeric_mode=None):
"""scikit-learn 1.9 `d2_tweedie_score`: `1 - dev(y, p) / dev(y, avg y)`
in one program; a zero null deviance answers NaN (0/0) or -inf as
numpy's float division does."""
if is_bool(power) or not isinstance(power, numbers.Real):
raise ValueError("power must be a real number")
r = _Reg(y_true, y_pred, sample_weight, "uniform_average", "d2_tweedie_score")
if r.D != 1:
raise ValueError("Multioutput not supported in d2_tweedie_score")
if r.n < 2:
_undefined_warning("D^2 score is not well-defined with less than two samples.")
return float("nan")
r.program(float(power))
check = _tweedie_domain(r, power, "d2_tweedie_score")
num = r.stage_mean("tweedie")
y_avg = r.fin(r.sums(r.Y), f32=True)[1]
den = r.stage_mean("tweedie", P=y_avg, broadcast=True)
return r.scalar(_assemble(r, num, den, False, average=False), numeric_mode, check)
[docs]
def d2_pinball_score(y_true, y_pred, *, sample_weight=None, alpha=0.5, multioutput="uniform_average",
numeric_mode=None):
"""scikit-learn 1.9 `d2_pinball_score`: the pinball loss against the
(weighted, averaged) alpha-quantile of y_true per output, one program."""
alpha = _check_alpha(alpha)
r = _Reg(y_true, y_pred, sample_weight, multioutput, "d2_pinball_score")
if r.n < 2:
_undefined_warning("D^2 score is not well-defined with less than two samples.")
return float("nan")
r.program(alpha)
num = r.stage_mean("pinball")
quant = r.percentile(r.Y, alpha * 100, f32=True)[1]
den = r.stage_mean("pinball", P=quant, broadcast=True)
return r.result(_assemble(r, num, den, True), numeric_mode)
[docs]
def d2_absolute_error_score(y_true, y_pred, *, sample_weight=None, multioutput="uniform_average",
numeric_mode=None):
"""scikit-learn 1.9 `d2_absolute_error_score` (d2_pinball_score at alpha 0.5)."""
return d2_pinball_score(y_true, y_pred, sample_weight=sample_weight, alpha=0.5,
multioutput=multioutput, numeric_mode=numeric_mode)
# ---------------------------------------------------------------------------
# Ranking and probabilistic scores (scikit-learn 1.9 _ranking.py, _classification.py)
# ---------------------------------------------------------------------------
_ROW = dict(topk=0, brier=1, logloss=2, hinge_bin=3, hinge_mc=4, dcg=5, coverage=6, lrap=7, rankloss=8,
dcg_ignore_ties=9)
def _scores(y_score, n, caller, *, ndim=None, name="y_score"):
from ._buffer import materialize_f32_lists
a = materialize_f32_lists(y_score, name)[0]
if a.dtype != "<f4":
raise TypeError(f"mojolearn {caller}: {name} must have dtype float32; cast explicitly")
if a.ndim == 2 and a.shape[1] == 1 and ndim == 1:
a = a.reshape((a.shape[0],))
if ndim is not None and a.ndim != ndim:
raise ValueError(f"mojolearn {caller}: {name} must be {ndim}-D, got shape {a.shape}")
if a.shape[0] != n:
raise ValueError(f"mojolearn {caller}: {name} has {a.shape[0]} rows for {n} samples")
a = as_f32_c(a, ndim=a.ndim, name=name)[0]
if not all_finite(a):
raise ValueError(f"mojolearn {caller}: {name} must be finite")
return a
def _targets(y_true, caller):
from ._metrics_impl import _classification_encoded, _label_set
_refuse_multilabel(y_true, "y_true", caller)
true, kind = _classification_encoded(y_true, "y_true")
return true, kind, sorted(_label_set(true)) # glue: sorts the distinct class labels
def _pos_label(pos_label, kind, classes, caller):
from ._metrics_impl import _classification_labels
if pos_label is None:
if kind != "integer" or classes not in ([0], [1], [-1], [0, 1], [-1, 1]):
raise ValueError(f"y_true takes value in {classes} and pos_label is not specified: either make "
"y_true take value in {0, 1} or {-1, 1} or pass pos_label explicitly.")
return 1
values, pk = _classification_labels([pos_label], "pos_label")
if pk != kind:
raise ValueError("pos_label must use the same type as y_true")
return values[0]
class _Curve(tuple):
"""(fps, tps, thresholds); `keep` = the collinear-drop flags the device
computed for an unweighted curve (bytes of 0/1 per slot), else None."""
keep = None
class _DevCurve:
"""A curve left in its program's arena (lane metrics-apple2): the
offsets of its fps, tps, thresholds and keep words (-1 = none; keep -2 =
the device dropped the collinear points, every point is kept) and its
point count `c`. `lists()` is the `_Curve` `_curves` returns; the
epilogue helpers (`_auc_of`, `_ap_of`, `roc_curve`) read the words in
place instead."""
def __init__(self, prog, fps, tps, thr, keep, c, numeric_mode):
self.prog, self.fps, self.tps, self.thr, self.keep, self.c = prog, fps, tps, thr, keep, c
self.numeric_mode = numeric_mode
def last(self):
"""(fps[c-1], tps[c-1]) as Python floats (c > 0)."""
p = self.prog
return p.floats(self.fps + self.c - 1, 1)[0], p.floats(self.tps + self.c - 1, 1)[0]
def lists(self):
p, c = self.prog, self.c
cur = _Curve((p.floats(self.fps, c), p.floats(self.tps, c),
p.floats(self.thr, c) if self.thr >= 0 else None))
if self.keep >= 0:
p._check(self.keep, c)
lo = 4 * self.keep
cur.keep = bytes(_arena_view(p.arena).cast("B")[lo + (0 if _LITTLE else 3):lo + 4 * c:4])
elif self.keep == -2:
cur.keep = b"\x01" * c # the device dropped the collinear points already
return cur
def _f32_weights_addr(w):
if w is None:
return 0, None
if isinstance(w, Array) and w.dtype == "<f4" and w._has_order("C"):
return addr_ro(w, name="sample_weight"), w
return None, None
def _auc_of(cur, max_fpr):
"""The ROC AUC of a curve
when both classes are present, else (or on any doubt) the Python.
A `_FoldCurve` (the device fold, lane cgr2-metrics-shap) scores itself."""
if isinstance(cur, _FoldCurve):
return cur.auc(max_fpr)
raise TypeError("mojolearn metrics: an AUC is scored from the device fold (`_curves(..., fold='auc')`)")
def _ap_of(cur):
"""The average precision of a curve: a `_FoldCurve` (the device fold, lane
cgr2-metrics-shap) scores itself."""
if isinstance(cur, _FoldCurve):
return cur.ap()
raise TypeError("mojolearn metrics: an average precision is scored from the device fold (`_curves(..., fold='ap')`)")
def _curves(scores, flags, w, n, problems, numeric_mode, *, stride=1, thresholds=True, keep_flags=True,
lazy=False, compact=False, fold=None, max_fpr=None, tail=None):
"""[(fps, tps, thresholds)] per problem, from the device sort and the
cumulative counts (Python floats; unweighted counts are exact). An
unweighted curve also carries `.keep` (lane metrics-apple). Only the
curves come back from the device, each only up to its point count, and
thresholds=False (the AUCs) leaves the thresholds on it: the third list
is then None; keep_flags=False (the average precisions) computes no
`.keep`. compact=True (lazy, unweighted, keep_flags) has the device drop
the collinear points itself (bin_curve params 12, 13: the kept fps, tps,
thresholds, and their count), so only the kept points come back; the
`_DevCurve`s then carry keep = -2, every point kept (lane
metrics-apple2). Weighted curves are flagged and compacted too (lane
cgr2-metrics-shap: exact two-sum steps on the device).
fold="auc" (compact) or "ap" (lane cgr2-metrics-shap) adds the device
curve fold (x_metrics/par.mojo curve_fold_unit) and brings back only its
CF_OUT words per problem, no curve: the list holds `_FoldCurve`s.
max_fpr (fold="auc") folds the partial AUC instead. tail (fold only):
see `_fold_curves`."""
if fold is not None:
return _fold_curves(scores, flags, w, n, problems, numeric_mode, stride=stride, fold=fold,
max_fpr=max_fpr, tail=tail)
if tail is not None:
raise ValueError("mojolearn metrics: a curve tail needs the device fold")
prog = _Prog()
S = prog.put(scores)
POS = _put_flags(prog, flags)
W = _put_weights(prog, w)
N = n * problems
order = prog.scratch(N)
flagged = bool(keep_flags)
compact = compact and flagged and lazy
cnt = prog.want(prog.alloc(problems), problems)
fps = prog.alloc(N)
tps = prog.alloc(N)
thr = prog.alloc(N)
keep = _NONE
CF = CM = 0
if compact:
keep = prog.scratch(N)
CF = prog.alloc(3 * N)
CM = prog.want(prog.alloc(problems), problems)
for t in range(problems): # glue: declares each problem's output ranges
for b in range(3 if thresholds else 2): # glue: declares each curve output range
prog.want(CF + b * N + t * n, n, count=CM + t)
else:
if flagged:
keep = prog.alloc(N)
for t in range(problems): # glue: declares each problem's output ranges
for b in (fps, tps) + ((thr,) if thresholds else ()) + ((keep,) if flagged else ()): # glue: declares each curve output range
prog.want(b + t * n, n, count=cnt + t)
prog.stage("bin_curve", problems, S, stride, POS, W, n, order, fps, tps, thr, cnt,
keep, 1 if flagged else 0, CF, CM)
_execute(prog, numeric_mode)
counts = prog.ints(CM if compact else cnt, problems)
if compact:
return [_DevCurve(prog, CF + t * n, CF + N + t * n, CF + 2 * N + t * n if thresholds else _NONE,
-2, counts[t], numeric_mode) for t in range(problems)] # glue: one result handle per problem
# every caller is lazy (lane pyglue-sweep: the curve lists never come
# back to Python whole; the epilogues read the arena words)
return [_DevCurve(prog, fps + t * n, tps + t * n, thr + t * n if thresholds else _NONE,
keep + t * n if flagged else _NONE, counts[t], numeric_mode)
for t in range(problems)] # glue: one result handle per problem
#: words per problem of the curve fold (x_metrics/par.mojo CF_OUT) and its
#: points per chunk (x_metrics/plan.mojo CF_CHUNK)
_CF_OUT = 10
_CF_CHUNK = 1024
def _f32_split(x):
"""(hi, lo) Int32 bit words of the float-float x = hi + lo (hi the
nearest float32, lo the nearest float32 of the remainder)."""
import struct
hi = struct.unpack("<f", struct.pack("<f", float(x)))[0]
lo = float(x) - hi
bits = struct.unpack("<ii", struct.pack("<ff", hi, lo))
return bits[0], bits[1]
def _fold_stages(prog, S, stride, POS, W, n, problems, auc, max_fpr=None, out=None):
"""The curve (bin_curve) and its fold (curve_fold) as stages of `prog`
over scores at S, flags at POS, weights at W (_NONE: none): the fold's
CF_OUT words per problem at `out` (allocated and declared when None),
returned. Shared by `_fold_curves` and the one-vs-one pairs."""
N = n * problems
order = prog.scratch(N)
cnt = prog.scratch(problems)
fps = prog.scratch(N)
tps = prog.scratch(N)
thr = prog.scratch(N)
keep = _NONE
CF = CM = 0
if auc:
keep = prog.scratch(N)
CF = prog.scratch(3 * N)
CM = prog.scratch(problems)
prog.stage("bin_curve", problems, S, stride, POS, W, n, order, fps, tps, thr, cnt,
keep, 1 if auc else 0, CF, CM)
if out is None:
out = prog.want(prog.alloc(_CF_OUT * problems), _CF_OUT * problems)
mode, mh, ml = (1, 0, 0) if not auc else ((0, 0, 0) if max_fpr is None else (2,) + _f32_split(max_fpr))
if auc:
prog.stage("curve_fold", problems, n, CF, CF + N, CM, out, mode, mh, ml, _CF_CHUNK)
else:
prog.stage("curve_fold", problems, n, fps, tps, cnt, out, mode, mh, ml, _CF_CHUNK)
return out
def _fold_curves(scores, flags, w, n, problems, numeric_mode, *, stride=1, fold="auc", max_fpr=None, tail=None):
"""`_curves` with the device curve fold: the curve stays on the device
(compacted for the AUCs) and only the fold's words come back.
tail (lane cpu2-l7-metrics S3b): a function `(prog, S, W, C, out, n,
problems)` that stages more units in this program before it runs (S the
scores, W the weights or _NONE, C the int32 codes of an `_OneHot` or
_NONE, out the fold words); it keeps its own offsets."""
prog = _Prog()
S = prog.put(scores)
C = _NONE
if tail is not None and isinstance(flags, _OneHot):
# `_put_flags`'s onehot stage, with the codes' offset kept for the tail
C = prog.put_i32(flags.codes)
POS = prog.scratch(flags.n * flags.k)
prog.stage("onehot", flags.n * flags.k, C, POS, flags.n, flags.k, flags.layout)
else:
POS = _put_flags(prog, flags)
W = _put_weights(prog, w)
auc = fold == "auc"
out = _fold_stages(prog, S, stride, POS, W, n, problems, auc, max_fpr)
if tail is not None:
tail(prog, S, W, C, out, n, problems)
_execute(prog, numeric_mode)
holder = {}
def redo():
if "c" not in holder:
holder["c"] = _curves(scores, flags, w, n, problems, numeric_mode, stride=stride, thresholds=False,
keep_flags=auc, lazy=True, compact=auc)
return holder["c"]
return [_FoldCurve(prog, out + _CF_OUT * t, max_fpr, redo, t) for t in range(problems)] # glue: one result handle per problem
class _FoldCurve:
"""A curve's device fold (lane cgr2-metrics-shap): the float-float sum
and the few curve words x_metrics/par.mojo `_cf_finish` writes. `auc()`
and `ap()` turn them into the score with a handful of binary64 scalar
operations; an edge case (an empty class, a zero precision denominator,
max_fpr past the last point) recomputes the curve for the Python rule."""
def __init__(self, prog, off, max_fpr, redo, t):
self.prog, self.off, self.max_fpr, self._redo, self.t = prog, off, max_fpr, redo, t
def _words(self):
f = self.prog.floats(self.off, _CF_OUT)
i = self.prog.ints(self.off, _CF_OUT)
return f[0] + f[1], f[2], f[3], i[4], f[5], f[6], f[7], f[8], i[9]
def lists(self):
return self._redo()[self.t].lists()
def auc(self, max_fpr):
s, F, T, k, a, b, u, v, c = self._words()
if c <= 0 or not (F > 0 and T > 0):
# the empty-class answer, without bringing the curve back
_undefined_warning("Only one class is present in y_true. ROC AUC score is not defined in that case.")
return float("nan")
if max_fpr is None:
return float(s / (2.0 * F * T))
if k >= c:
# max_fpr at or past the last point (max_fpr = 1: the last fpr is
# F / F): the whole area, the device fold's word (lane py-runtime
# round 3; the Python fsum rule over the curve is gone, new bits
# for max_fpr = 1 only, the same on every column)
return float(s / (2.0 * F * T))
x0, x1, y0, y1 = a / F, b / F, u / T, v / T
yi = y0 if x1 == x0 else y0 + (max_fpr - x0) * (y1 - y0) / (x1 - x0)
part = s / (2.0 * F * T) + (max_fpr - x0) * (yi + y0) / 2.0
min_area = 0.5 * max_fpr * max_fpr
return float(0.5 * (1 + (part - min_area) / (max_fpr - min_area)))
def ap(self):
s, F, T, k, a, b, u, v, c = self._words()
if c <= 0 or T == 0:
return 0.0
if k > 0:
# a point with tps + fps == 0: the Python rule's t / (t + f) is a
# float division by zero (lane py-runtime round 3: raised from the
# fold's count word, no curve walk)
raise ZeroDivisionError("float division by zero")
return float(max(0.0, s / T))
#: x_metrics/curve_out.mojo kinds and statuses
_CO_ROC, _CO_PR, _CO_DET = 0, 1, 2
_CO_EMPTY, _CO_ZERO_CLASS = 1, 2
def _curve_out(y_true, y_score, pos_label, sample_weight, numeric_mode, caller, kind, drop):
"""One binary curve's three Float64 output arrays, formed on the device
(lane cpu4-python: x_metrics/curve_out.mojo, the units the host column
runs too) in the program that sorts and counts: bin_curve (compacted by
its collinear drop for a dropping ROC), then curve_out. Only the arrays
(up to their length) and six head words come back. Returns (arrays,
length, F, T, classes): F, T the last fps and tps."""
from ._metrics_impl import _label_map
true, kind_y, classes = _targets(y_true, caller)
if len(classes) > 2 and pos_label is None:
raise ValueError("multiclass format is not supported")
pos = _pos_label(pos_label, kind_y, classes, caller)
n = len(true)
s = _scores(y_score, n, caller, ndim=1)
w = _weights(sample_weight, n, caller)
flags = _label_map(true, lambda v: int(v == pos))
if n <= 0:
raise ValueError("mojolearn metrics: an empty curve has no thresholds")
prog = _Prog()
S = prog.put(s)
POS = _put_flags(prog, flags)
W = _put_weights(prog, w)
order = prog.scratch(n)
cnt = prog.scratch(1)
fps = prog.scratch(n)
tps = prog.scratch(n)
thr = prog.scratch(n)
roc_drop = kind == _CO_ROC and bool(drop)
keep = _NONE
CF = CM = 0
if roc_drop:
keep = prog.scratch(n)
CF = prog.scratch(3 * n)
CM = prog.scratch(1)
prog.stage("bin_curve", 1, S, 1, POS, W, n, order, fps, tps, thr, cnt, keep, 1 if roc_drop else 0, CF, CM)
LEN = prog.want(prog.alloc(6), 6)
OUT = prog.alloc(6 * (n + 1))
for b in range(3): # glue: declares the three output arrays, each up to its length
prog.want(OUT + 2 * b * (n + 1), 2 * (n + 1), count=LEN, mult=2)
src = (CF, CF + n, CF + 2 * n, CM) if roc_drop else (fps, tps, thr, cnt)
prog.stage("curve_out", 1, kind, n, *src, 1 if (drop and kind != _CO_ROC) else 0, OUT, LEN)
_execute(prog, numeric_mode)
m, status = prog.ints(LEN, 2)
if status == _CO_EMPTY:
raise ValueError("mojolearn metrics: an empty curve has no thresholds")
F, T = prog.words(LEN + 2, 4, "d")
if status == _CO_ZERO_CLASS:
return None, 0, F, T, classes
# precision-recall's thresholds hold one word fewer than its precision and recall
lens = (m, m, m - 1) if kind == _CO_PR else (m, m, m)
arrays = tuple(Array._owned(prog.words(OUT + 2 * b * (n + 1), 2 * lens[b], "d"), (lens[b],), "<f8", "C")
for b in range(3)) # glue: wraps the three downloaded arrays
return arrays, m, F, T, classes
# Engineering notes (moved from the docstring):
# scikit-learn 1.9 `roc_curve` for binary targets: fpr, tpr, thresholds
# (Float64; the first threshold is +inf). The sort, the cumulative
# counts, the collinear drop and the rates run on the device (lane
# cpu4-python: x_metrics/curve_out.mojo; an empty class's NaN rates
# there too).
[docs]
def roc_curve(y_true, y_score, *, pos_label=None, sample_weight=None, drop_intermediate=True,
numeric_mode=None):
"""Receiver operating characteristic curve for binary targets.
Follows scikit-learn's `roc_curve`. Returns ``fpr``, ``tpr`` and ``thresholds`` as
float64 arrays; the first threshold is ``+inf``. Computed on the GPU.
"""
arrays, _, F, T, _ = _curve_out(y_true, y_score, pos_label, sample_weight, numeric_mode, "roc_curve",
_CO_ROC, drop_intermediate)
if F <= 0:
_undefined_warning("No negative samples in y_true, false positive value should be meaningless")
if T <= 0:
_undefined_warning("No positive samples in y_true, true positive value should be meaningless")
return arrays
def precision_recall_curve_options(y_true, y_score, pos_label, sample_weight, drop_intermediate, numeric_mode):
"""precision_recall_curve with sample_weight or drop_intermediate=True
(lane/metrics): Float64 outputs, formed on the device (lane cpu4-python:
x_metrics/curve_out.mojo; no positives' recall of one there too); the
default call keeps its kernel."""
arrays, _, _, T, _ = _curve_out(y_true, y_score, pos_label, sample_weight, numeric_mode,
"precision_recall_curve", _CO_PR, drop_intermediate)
if T == 0:
warnings.warn("No positive class found in y_true, recall is set to one for all thresholds.",
UserWarning, stacklevel=3)
return arrays
# Engineering notes (moved from the docstring):
# scikit-learn 1.9 `det_curve`: fpr, fnr, thresholds, formed on the
# device (lane cpu4-python: x_metrics/curve_out.mojo); a zero class
# weight raises as the Python division by zero did.
[docs]
def det_curve(y_true, y_score, *, pos_label=None, sample_weight=None, drop_intermediate=False,
numeric_mode=None):
"""Detection error tradeoff curve for binary targets.
Follows scikit-learn's `det_curve`. Returns ``fpr``, ``fnr`` and ``thresholds``,
computed on the GPU. A zero class weight raises ``ZeroDivisionError``.
"""
arrays, _, _, _, classes = _curve_out(y_true, y_score, pos_label, sample_weight, numeric_mode, "det_curve",
_CO_DET, drop_intermediate)
if len(classes) != 2:
raise ValueError("Only one class is present in y_true. Detection error tradeoff curve is not "
"defined in that case.")
if arrays is None:
raise ZeroDivisionError("float division by zero")
return arrays
#: terms per chunk of the device trapezoid sum (x_metrics/curve_out.mojo
#: ax_chunk); the fold order is a function of n and this constant only.
#: Restored 2026-10-09: b9675438d removed it with the array helper and the
#: step flags while auc(x, y) still used all three (NameError on both GPUs).
_AX_CHUNK = 1024
_AX_NEG, _AX_POS = 1, 2
def _f64_array_words(a):
"""A C-order Float64 Array's values as their Int32 word pairs (low
first), an Int32 Array over a copy of the bytes (no per-value Python).
Its own name: the scalar `_f64_words(prog, v)` below shadowed the
original `_f64_words(a)` and broke auc(x, y) (TypeError, found by the
2026-10-09 reference re-record, x-metrics-ranking)."""
store = array.array("i")
store.frombytes(a.tobytes())
return Array._owned(store, (len(store),), "<i4", "C")
# Engineering notes (moved from the docstring):
# scikit-learn 1.9 `auc`: the trapezoid rule over a monotonic x, on
# the device (lane cpu4-python: x_metrics/curve_out.mojo auc_xy; the host
# column runs the same units): binary64 terms (x[i] - x[i-1]) * (y[i] +
# y[i-1]) / 2, summed as a float-float in fixed chunks (the host fsum
# left the route); a non-finite term's IEEE answer. A list or an integer
# buffer is converted to Float64 first.
[docs]
def auc(x, y):
"""Area under a curve by the trapezoid rule.
Follows scikit-learn's `auc`. `x` must be monotonic. Lists and integer arrays are
converted to float64 first. Computed on the GPU.
"""
xa, ya = _auc_f64(x), _auc_f64(y)
if xa is None:
xa = Array.from_list(flatten_mo(x), "<f8")
if ya is None:
ya = Array.from_list(flatten_mo(y), "<f8")
if xa.size != ya.size:
raise ValueError("x and y must have the same length")
if xa.size < 2:
raise ValueError(f"At least 2 points are needed to compute area under curve, but x.shape = ({xa.size},)")
n = xa.size
prog = _Prog()
X = prog.put_i32(_f64_array_words(xa))
Y = prog.put_i32(_f64_array_words(ya))
OUT = prog.want(prog.alloc(3), 3)
prog.stage("auc_xy", 1, n, X, Y, OUT, _AX_CHUNK)
_execute(prog, None)
flags = prog.ints(OUT + 2, 1)[0]
if (flags & _AX_NEG) and (flags & _AX_POS):
raise ValueError(f"x is neither increasing nor decreasing : {xa.tolist()}.")
return float(prog.words(OUT, 2, "d")[0])
def _auc_f64(v):
"""A 1-D float32 or float64 buffer as a C-order Float64 Array (float32
widened exactly, as float() does), or None for anything else (lists,
integers, n-D): those keep the Python conversion."""
from ._buffer import view, typestr_of, _has_buffer, as_f64_c
if isinstance(v, (list, tuple)) or not (isinstance(v, Array) or _has_buffer(v)):
return None
try:
b = view(v, name="x")
try:
ok = b.ndim == 1 and typestr_of(b) in ("<f4", "<f8")
finally:
b.release()
return as_f64_c(v, ndim=1, name="x")[0] if ok else None
except Exception:
return None
#: rank_epi kinds of the ranking averages (x_metrics/rank_epi.mojo, lane cpu2-l7-metrics S3b)
_RANK_ROW_SUM, _RANK_CURVE_SCORE, _RANK_AVERAGE, _RANK_OVO_MASK, _RANK_OVO_PAIR = 5, 6, 7, 8, 9
_ROC_ONE_CLASS = "Only one class is present in y_true. ROC AUC score is not defined in that case."
def _f64_bits(prog, vals):
"""Binary64 values as Int32 arena words (low first), an input."""
bw = array.array("i")
bw.frombytes(array.array("d", vals).tobytes())
return prog.put_i32(bw)
def _stage_average(prog, SC, SUP, m, average):
"""`rank_epi` AVERAGE over m binary64 scores at SC (weights at SUP):
the declared binary64 result's offset."""
res = prog.want(prog.alloc(2), 2)
prog.stage("rank_epi", 1, _RANK_AVERAGE, SC, SUP, m, 1 if average == "weighted" else 0, res)
return res
class _OvrTail:
"""The one-vs-rest curve program's tail (`_fold_curves` tail=): the
multiclass row-sum check (`rank_epi` ROW_SUM), the per-class supports
(group sums over the codes: exact counts, or the Float32 PairSums of
the weights), the per-class scores from the fold words (CURVE_SCORE) and
their average (AVERAGE), all in the program that folds the curves. The
host class_sums / row_sum_range walks and the Python averages are gone
(lane cpu2-l7-metrics S3b)."""
def __init__(self, fold, check_rows, average, numeric_mode):
"""average: None (per-class scores), "macro", "weighted", or
"micro" (no per-class work: the row check only)."""
self.fold, self.check_rows, self.average = fold, check_rows, average
self.score = average != "micro"
self.numeric_mode = numeric_mode
self.prog = None
def __call__(self, prog, S, W, C, out, n, k):
self.n, self.k = n, k
self.prog = prog
if self.check_rows:
self.rows = prog.want(prog.alloc(1), 1)
if n:
prog.stage("rank_epi", n, _RANK_ROW_SUM, S, n, k, self.rows)
if not self.score:
return
weighted = W != _NONE
off, sums = _group(prog, C, n, k, weights=W)
self.sc = prog.want(prog.alloc(2 * k), 2 * k)
self.fl = prog.want(prog.alloc(k), k)
self.sup = prog.want(prog.alloc(2 * k), 2 * k)
prog.stage("rank_epi", k, _RANK_CURVE_SCORE, out, k, 0 if self.fold == "auc" else 1, self.sc, self.fl,
sums if weighted else off, 1 if weighted else 0, self.sup)
self.res = _stage_average(prog, self.sc, self.sup, k, self.average) if self.average is not None else _NONE
def rows_ok(self):
return not _flag_set(self.prog, self.rows)
def result(self, curves):
"""The averaged score (binary64) or, average=None, the per-class
Float64 array. A class whose AP needs the curve rule (a zero
precision denominator) takes `_FoldCurve.ap`, and the average then
runs in a second small program over the device words."""
prog, k = self.prog, self.k
fl = prog.ints(self.fl, k)
if self.fold == "auc":
for c in range(k): # glue: one warning per class without both labels
if fl[c] == 1:
_undefined_warning(_ROC_ONE_CLASS)
edge = [c for c in range(k) if fl[c] == 2] # glue: the classes whose AP takes the curve rule
if not edge:
if self.average is None:
return Array.from_list(list(prog.words(self.sc, 2 * k, "d")), "<f8")
return prog.words(self.res, 2, "d")[0]
sc = list(prog.words(self.sc, 2 * k, "d"))
for c in edge: # glue: the curve rule's per-class scores
sc[c] = curves[c].ap()
if self.average is None:
return Array.from_list(sc, "<f8")
p2 = _Prog()
SC = _f64_bits(p2, sc)
SUP = p2.put_i32(prog.words(self.sup, 2 * k, "i"))
res = _stage_average(p2, SC, SUP, k, self.average)
_execute(p2, self.numeric_mode)
return p2.words(res, 2, "d")[0]
def _ovr(y_true, y_score, sample_weight, labels, caller, numeric_mode, keep_flags=True, fold=None, tail=None):
"""Binarized one-vs-rest problems over the class columns of y_score.
`tail` (an `_OvrTail`, fold only) rides in the curve program."""
from ._metrics_impl import _label_map, _selected_labels
true, kind, present = _targets(y_true, caller)
n = len(true)
s = _scores(y_score, n, caller, ndim=2)
k = s.shape[1]
classes = present if labels is None else _selected_labels(labels, kind, present)
if labels is not None:
if classes != sorted(classes): # glue: checks the class labels are sorted
raise ValueError("Parameter 'labels' must be ordered")
if set(present) - set(classes):
raise ValueError("'y_true' contains labels not in parameter 'labels'")
if len(classes) != k:
raise ValueError("Number of classes in y_true not equal to the number of columns in 'y_score'")
w = _weights(sample_weight, n, caller)
index = {c: i for i, c in enumerate(classes)} # glue: class index table
codes = _label_map(true, lambda v: index[v])
# the class-major flags formed by the onehot unit inside the curve
# program, the supports, scores and average by the tail's units in the
# same program; no n*k host walk (lane pyglue-sweep: the Python byte
# layouts, the -D MOJOLEARN_PY2MOJO_core_OFF arm, are gone)
codes = _i32_c(codes)
curves = _curves(s, _OneHot(codes, n, k, 0), w, n, k, numeric_mode, stride=k, thresholds=False,
keep_flags=keep_flags, lazy=True, compact=True, fold=fold, tail=tail)
return curves, tail, s, codes, classes, w
_LITTLE = array.array("i", [1]).tobytes()[0] == 1
def _micro_inputs(codes, w, n, k, numeric_mode=None):
"""The micro average's row-major one-hot flags (an `_OneHot`, word
r * k + c is 1 exactly when row r's code is c) and its weights (a
`_RepRows`, each of the n repeated k times, or None), both formed by
stages of the curve program (x_metrics onehot and rep_rows units)."""
codes = _i32_c(codes)
if codes.size != n:
raise ValueError("mojolearn metrics: micro-average codes do not match the scores")
wm = None
if w is not None:
wa = w if isinstance(w, Array) and w.dtype == "<f4" else as_f32_c(w, ndim=1, name="sample_weight")[0]
if wa.size != n:
raise ValueError("mojolearn metrics: micro-average weights do not match the scores")
wm = _RepRows(wa, n, k)
return _OneHot(codes, n, k, 1), wm
#: arena words one one-vs-one program may hold (its pairs' curve slots);
#: more pairs go to further programs
_OVO_WORDS = 1 << 26
_ROWS_MSG = ("Target scores need to be probabilities for multiclass roc_auc, i.e. they "
"should sum up to 1.0 over classes")
def _ovo_native(true, index, s, n, k, numeric_mode, average):
"""One-vs-one ROC AUC (scikit-learn _average_multiclass_ovo_score), on
the device (lane cpu2-l7-metrics S3b): per program, the scores and codes
go up once, the onehot unit forms every class's flags, `rank_epi`
OVO_MASK marks each pair's rows as 0/1 curve weights (the curve drops
the others: the host `x_metrics_ovo_pair` selection is gone), two curve
folds per pair (column a with class a positive, column b with class b),
CURVE_SCORE their AUCs, OVO_PAIR the pair score (mean of the two) and
prevalence ((count_a + count_b) / n, the counts from a group over the
codes: the host class_sums walk is gone), AVERAGE the macro or weighted
mean. The first program also runs the multiclass row-sum check
(ROW_SUM). Pairs whose curve slots exceed `_OVO_WORDS` go to further
programs; their pair words then meet in one small AVERAGE program."""
from ._metrics_impl import _label_map
codes = _i32_c(_label_map(true, lambda v: index[v]))
if codes.size != n:
raise ValueError("mojolearn roc_auc_score: y_true and y_score lengths differ")
P = k * (k - 1) // 2
per_pair = 17 * n + 4 + 4 * _CF_OUT
chunk = max(1, min(P, _OVO_WORDS // max(per_pair, 1)))
pairs = [(a, b) for a in range(k) for b in range(a + 1, k)] # glue: the stage parameters of each pair
ps_words, prev_words = [], []
g0 = 0
while g0 < P: # glue: one program per chunk of pairs
m = min(chunk, P - g0)
last_only = g0 == 0 and m == P
prog = _Prog()
S = prog.put(s)
C = prog.put_i32(codes)
OH = prog.scratch(n * k)
if n:
prog.stage("onehot", n * k, C, OH, n, k, 0)
rows = _NONE
if g0 == 0:
rows = prog.want(prog.alloc(1), 1)
if n:
prog.stage("rank_epi", n, _RANK_ROW_SUM, S, n, k, rows)
off, _ = _group(prog, C, n, k)
prog.want(off + k, 1)
CF = prog.scratch(2 * m * _CF_OUT)
for t in range(m): # glue: stages each pair's mask and its two curve folds
a, b = pairs[g0 + t]
W = prog.scratch(n)
if n:
prog.stage("rank_epi", n, _RANK_OVO_MASK, C, n, a, b, W)
_fold_stages(prog, S + a, k, OH + a * n, W, n, 1, True, out=CF + 2 * t * _CF_OUT)
_fold_stages(prog, S + b, k, OH + b * n, W, n, 1, True, out=CF + (2 * t + 1) * _CF_OUT)
SC = prog.scratch(4 * m)
FL = prog.want(prog.alloc(2 * m), 2 * m)
prog.stage("rank_epi", 2 * m, _RANK_CURVE_SCORE, CF, 2 * m, 0, SC, FL, _NONE, 0, 0)
if last_only:
PS, PREV = prog.scratch(2 * m), prog.scratch(2 * m)
else:
PS, PREV = prog.want(prog.alloc(2 * m), 2 * m), prog.want(prog.alloc(2 * m), 2 * m)
prog.stage("rank_epi", m, _RANK_OVO_PAIR, SC, off, k, n, g0, m, PS, PREV)
res = _stage_average(prog, PS, PREV, P, average) if last_only else _NONE
_execute(prog, numeric_mode)
if g0 == 0:
if _flag_set(prog, rows):
raise ValueError(_ROWS_MSG)
if prog.ints(off + k, 1)[0] != n:
raise ValueError("mojolearn roc_auc_score: a y_true label is not among the classes")
for v in prog.ints(FL, 2 * m): # glue: one warning per direction without both labels
if v == 1:
_undefined_warning(_ROC_ONE_CLASS)
if last_only:
return prog.words(res, 2, "d")[0]
ps_words.extend(prog.words(PS, 2 * m, "i"))
prev_words.extend(prog.words(PREV, 2 * m, "i"))
g0 += m
p2 = _Prog()
res = _stage_average(p2, p2.put_i32(ps_words), p2.put_i32(prev_words), P, average)
_execute(p2, numeric_mode)
return p2.words(res, 2, "d")[0]
def roc_auc_options(y_true, y_score, average, sample_weight, max_fpr, multi_class, labels, numeric_mode):
"""roc_auc_score with sample_weight, max_fpr < 1 or multiclass input
(lane/metrics); the binary unweighted full-AUC call keeps its kernel.
AUCs are the trapezoid rule over the device curve (host `fsum`)."""
from ._metrics_impl import _label_map
from ._buffer import materialize_f32_lists
shape = materialize_f32_lists(y_score, "y_score")[0].shape
true, kind, present = _targets(y_true, "roc_auc_score")
multiclass = len(present) > 2 or (len(shape) == 2 and shape[1] > 2)
if max_fpr is not None and (is_bool(max_fpr) or not isinstance(max_fpr, numbers.Real)
or not 0 < max_fpr <= 1):
raise ValueError(f"Expected max_fpr in range (0, 1], got: {max_fpr!r}")
if not multiclass:
if average not in (None, "micro", "macro", "samples", "weighted"):
raise ValueError("average has to be one of (None, 'micro', 'macro', 'weighted', 'samples')")
if len(present) != 2:
_undefined_warning("Only one class is present in y_true. ROC AUC score is not defined in that case.")
return float("nan")
n = len(true)
s = _scores(y_score, n, "roc_auc_score", ndim=1)
w = _weights(sample_weight, n, "roc_auc_score")
flags = _label_map(true, lambda v: int(v == present[1]))
mf = None if max_fpr is None or max_fpr == 1 else max_fpr
cur = _curves(s, flags, w, n, 1, numeric_mode, fold="auc", max_fpr=mf)[0]
return _auc_of(cur, mf)
if max_fpr is not None and max_fpr != 1:
raise ValueError("Partial AUC computation not available in multiclass setting, 'max_fpr' must be "
f"set to `None`, received `max_fpr={max_fpr}` instead")
if multi_class not in ("ovo", "ovr"):
raise ValueError("multi_class must be in ('ovo', 'ovr')")
options = ("micro", "macro", "weighted", None) if multi_class == "ovr" else ("macro", "weighted", None)
if average not in options:
raise ValueError(f"average must be one of {options} for multiclass problems")
if average is None and multi_class == "ovo":
raise NotImplementedError("average=None is not implemented for multi_class='ovo'.")
if multi_class == "ovo" and sample_weight is not None:
raise ValueError("sample_weight is not supported for multiclass one-vs-one ROC AUC, "
"'sample_weight' must be None in this case.")
s_check = _scores(y_score, len(true), "roc_auc_score", ndim=2)
k = s_check.shape[1]
# the multiclass row-sum check runs in the curve program that follows
# (`rank_epi` ROW_SUM; the host row_sum_range walk is gone)
if multi_class == "ovr":
tail = _OvrTail("auc", True, average, numeric_mode)
curves, tail, s, codes, classes, w = _ovr(y_true, y_score, sample_weight, labels, "roc_auc_score",
numeric_mode, fold="auc", tail=tail)
if not tail.rows_ok():
raise ValueError(_ROWS_MSG)
if average == "micro":
n = len(codes)
flags, wm = _micro_inputs(codes, w, n, k, numeric_mode)
cur = _curves(s.reshape((n * k,)), flags, wm, n * k, 1, numeric_mode, fold="auc")[0]
return _auc_of(cur, None)
return tail.result(curves)
# one-vs-one (scikit-learn _average_multiclass_ovo_score)
from ._metrics_impl import _selected_labels
classes = present if labels is None else _selected_labels(labels, kind, present)
if len(classes) != k:
raise ValueError("Number of classes in y_true not equal to the number of columns in 'y_score'")
index = {c: i for i, c in enumerate(classes)} # glue: class index table
return _ovo_native(true, index, s_check, len(true), k, numeric_mode, average)
[docs]
def average_precision_score(y_true, y_score, *, average="macro", pos_label=1, sample_weight=None,
numeric_mode=None):
"""scikit-learn 1.9 `average_precision_score` (binary and multiclass
one-vs-rest; multilabel-indicator targets are refused by name)."""
from ._metrics_impl import _label_map
if average not in (None, "micro", "macro", "weighted", "samples"):
raise ValueError("average has to be one of (None, 'micro', 'macro', 'weighted', 'samples')")
true, kind, present = _targets(y_true, "average_precision_score")
if len(present) <= 2:
if len(present) == 2 and pos_label not in present:
raise ValueError(f"pos_label={pos_label} is not a valid label. It should be one of {present}")
n = len(true)
s = _scores(y_score, n, "average_precision_score", ndim=1)
w = _weights(sample_weight, n, "average_precision_score")
flags = _label_map(true, lambda v: int(v == pos_label))
return _ap_of(_curves(s, flags, w, n, 1, numeric_mode, fold="ap")[0])
if pos_label != 1:
raise ValueError("Parameter pos_label is fixed to 1 for multiclass y_true. Do not set pos_label "
"or set pos_label to 1.")
if average == "samples":
raise NotImplementedError("mojolearn average_precision_score: average='samples' applies to "
"multilabel targets, which are NOT IMPLEMENTED")
curves, tail, s, codes, classes, w = _ovr(y_true, y_score, sample_weight, None,
"average_precision_score", numeric_mode, keep_flags=False,
fold="ap", tail=_OvrTail("ap", False, average, numeric_mode))
k = len(classes)
if average == "micro":
n = len(codes)
flags, wm = _micro_inputs(codes, w, n, k, numeric_mode)
return _ap_of(_curves(s.reshape((n * k,)), flags, wm, n * k, 1, numeric_mode, fold="ap")[0])
return tail.result(curves)
[docs]
def top_k_accuracy_score(y_true, y_score, *, k=2, normalize=True, sample_weight=None, labels=None,
numeric_mode=None):
"""scikit-learn 1.9 `top_k_accuracy_score`: a hit when the true class is
among the k largest scores, ties ordered as numpy's stable argsort
reversed (the larger class index first)."""
from ._metrics_impl import _label_map, _selected_labels
if is_bool(k) or not isinstance(k, numbers.Integral) or k < 1:
raise ValueError("k must be a positive integer")
true, kind, present = _targets(y_true, "top_k_accuracy_score")
n = len(true)
s = _scores(y_score, n, "top_k_accuracy_score")
binary = len(present) <= 2 and not (labels is not None and len(labels) > 2)
classes = present if labels is None else _selected_labels(labels, kind, present)
if labels is not None:
if classes != sorted(classes): # glue: checks the class labels are sorted
raise ValueError("Parameter 'labels' must be ordered.")
if set(present) - set(classes):
raise ValueError("'y_true' contains labels not in parameter 'labels'.")
n_score = s.shape[1] if s.ndim == 2 else 2
if binary and s.ndim == 2 and s.shape[1] != 1:
raise ValueError(f"`y_true` is binary while y_score is 2d with {s.shape[1]} classes. If `y_true` "
"does not contain all the labels, `labels` must be provided.")
if len(classes) != n_score:
raise ValueError(f"Number of classes in 'y_true' ({len(classes)}) not equal to the number of "
f"classes in 'y_score' ({n_score}).")
if k >= len(classes):
_undefined_warning(f"'k' ({k}) greater than or equal to 'n_classes' ({len(classes)}) will result "
"in a perfect score and is therefore meaningless.")
w = _weights(sample_weight, n, "top_k_accuracy_score")
index = {c: i for i, c in enumerate(classes)} # glue: class index table
codes = _label_map(true, lambda v: index[v])
prog = _Prog()
if binary:
flat = s.reshape((n,)) if s.ndim == 2 else s
if k == 1:
thr = 0.5 if flat.min() >= 0 and flat.max() <= 1 else 0.0
# two score columns [thr, s] make "s > thr" a top-1 hit of class 1
# with the tie going to class 0 (scikit-learn: y_pred = s > thr)
# the columns laid out by the pair_cols unit (thr's bits, then s
# bit for bit)
import struct
S1 = prog.put(flat)
S = prog.scratch(2 * n)
prog.stage("pair_cols", n, S1, S, struct.unpack("<i", struct.pack("<f", thr))[0])
kk, cols = 1, 2
else:
# zero words: a scratch slot starts zero on every backend
S = prog.scratch(2 * n)
kk, cols = 2, 2
else:
S = prog.put(s)
kk, cols = k, s.shape[1]
Y = prog.put_i32(codes)
hit = prog.scratch(n)
prog.stage("row_metric", n, S, cols, Y, hit, _ROW["topk"], kk, 0)
zero = prog.scratch(n)
prog.stage("pair_key", n, 0, 0, zero, 1, 2)
W = _NONE if w is None else prog.put(w)
tot = _group(prog, zero, n, 1, values=hit, weights=W)[1]
sw = _group(prog, zero, n, 1, weights=W)[1] if w is not None else None
_execute(prog, numeric_mode)
hits = prog.floats(tot, 1)[0]
if not normalize:
return float(hits)
return float(hits / (n if w is None else prog.floats(sw, 1)[0]))
#: rank_epi kinds (x_metrics/rank_epi.mojo)
_RANK_MEAN, _RANK_NDCG_ROW, _RANK_D2_LOG, _RANK_D2_BRIER, _RANK_DCG_DISC = 0, 1, 2, 3, 4
def _f64_words(prog, v):
"""A binary64 scalar as two Int32 arena words (low first), an input."""
bw = array.array("i")
bw.frombytes(array.array("d", [float(v)]).tobytes())
return prog.put_i32(list(bw))
def _row_fold(prog, S, cols, Y, n, kind, w, *, K=0, Dt=_NONE):
"""The row metric and its folds: (TOT, SW, W) offsets: the Float32
PairSum of the n row values (weighted when w), the weight total (None
unweighted) and the weights' slot."""
out = prog.scratch(n)
prog.stage("row_metric", n, S, cols, Y, out, _ROW[kind], K, Dt)
zero = prog.scratch(n)
prog.stage("pair_key", n, 0, 0, zero, 1, 2)
W = _NONE if w is None else prog.put(w)
tot = _group(prog, zero, n, 1, values=out, weights=W)[1]
sw = _group(prog, zero, n, 1, weights=W)[1] if w is not None else None
return tot, sw, W
def _row_mean(S, cols, Y, n, kind, w, numeric_mode, *, K=0, Dt=_NONE, prog=None, normalize=True, half=False,
after=()):
"""The (weighted) mean of a row metric, binary64: the folds and the
division (`rank_epi` MEAN) in one program. `after`: checks of the
program's flag words, each a function of the program that raises."""
prog = prog or _Prog()
tot, sw, _ = _row_fold(prog, S, cols, Y, n, kind, w, K=K, Dt=Dt)
res = prog.alloc(2)
prog.stage("rank_epi", 1, _RANK_MEAN, tot, n, _NONE if sw is None else sw, int(bool(normalize)),
int(bool(half)), res)
_execute(prog, numeric_mode)
for check in after: # glue: the program's flag checks
check(prog)
return prog.words(res, 2, "d")[0]
def _proba_after(flags, caller):
return lambda prog: _proba_check(prog, flags, caller)
def _proba(y_true, y_proba, labels, pos_label, caller):
"""(codes, a, k, binary): scikit-learn 1.9's validation of the shapes and
labels of probabilistic predictions for a binary vector (`a` (n,)) or a
multiclass matrix (`a` (n, k)); stage `_put_proba(prog, a, n, k,
binary)` for the values' check and the (n, 2 or k) matrix."""
from ._metrics_impl import _label_map, _selected_labels
from ._buffer import materialize_f32_lists
true, kind, present = _targets(y_true, caller)
n = len(true)
a = materialize_f32_lists(y_proba, "y_proba")[0]
if a.dtype != "<f4":
raise TypeError(f"mojolearn {caller}: y_proba must have dtype float32; cast explicitly")
binary = a.ndim == 1 or (a.ndim == 2 and a.shape[1] == 1)
if binary:
a = a.reshape((a.shape[0],))
if len(present) > 2:
raise ValueError("The type of the target inferred from y_true is multiclass but should be binary "
"according to the shape of y_prob.")
if pos_label is None:
try:
pos = _pos_label(None, kind, present, caller)
except ValueError:
pos = present[-1]
else:
pos = _pos_label(pos_label, kind, present, caller)
codes = _label_map(true, lambda v: int(v == pos))
k = 2
else:
classes = present if labels is None else _selected_labels(labels, kind, present)
if labels is not None and set(present) - set(classes):
raise ValueError("y_true contains values not belonging to the passed labels")
classes = sorted(classes) # glue: sorts the distinct class labels
k = a.shape[1]
if len(classes) != k:
raise ValueError(f"y_true and y_proba contain different number of classes: {len(classes)} vs {k}")
index = {c: i for i, c in enumerate(classes)} # glue: class index table
codes = _label_map(true, lambda v: index[v])
if a.shape[0] != n:
raise ValueError("y_true and y_proba have different numbers of rows")
a = as_f32_c(a, ndim=a.ndim, name="y_proba")[0]
# the values' check (finite, in [0, 1], rows summing to one) and the
# binary [1 - p, p] layout run in the caller's program (`_put_proba`)
return codes, a, k, binary
def log_loss_options(y_true, y_pred, normalize, sample_weight, labels, numeric_mode, caller="log_loss"):
"""log_loss with sample_weight (lane/metrics), and without it since lane
cpu2-l7-metrics: the probabilities' check, the clipped `-log p_true` per
row, its (weighted) PairSum and the division in one device program."""
codes, a, k, binary = _proba(y_true, y_pred, labels, None, caller)
n = len(codes)
w = _weights(sample_weight, n, caller)
prog = _Prog()
S, flags = _put_proba(prog, a, n, k, binary)
Y = prog.put_i32(codes)
return float(_row_mean(S, k, Y, n, "logloss", w, numeric_mode, prog=prog, normalize=normalize,
after=(_proba_after(flags, caller),)))
[docs]
def brier_score_loss(y_true, y_proba, *, sample_weight=None, pos_label=None, labels=None,
scale_by_half="auto", numeric_mode=None):
"""scikit-learn 1.9 `brier_score_loss` (binary vector or multiclass
matrix): the mean of `sum_c (onehot - p)^2`, halved by default for the
binary case."""
codes, a, k, binary = _proba(y_true, y_proba, labels, pos_label, "brier_score_loss")
n = len(codes)
w = _weights(sample_weight, n, "brier_score_loss")
if scale_by_half == "auto":
scale_by_half = binary or k < 3
prog = _Prog()
S, flags = _put_proba(prog, a, n, k, binary)
Y = prog.put_i32(codes)
return float(_row_mean(S, k, Y, n, "brier", w, numeric_mode, prog=prog, half=bool(scale_by_half),
after=(_proba_after(flags, "brier_score_loss"),)))
def _d2_proba(y_true, y_proba, sample_weight, labels, pos_label, numeric_mode, caller, kind):
"""d2_log_loss_score / d2_brier_score: the row metric's folds, the
(weighted) class sums and `1 - num / den` (`rank_epi` D2_LOG / D2_BRIER:
the class-frequency denominator in binary64) in one device program."""
codes, a, k, binary = _proba(y_true, y_proba, labels, pos_label, caller)
n = len(codes)
if n < 2:
_undefined_warning("D^2 score is not well-defined with less than two samples.")
return float("nan")
w = _weights(sample_weight, n, caller)
prog = _Prog()
S, flags = _put_proba(prog, a, n, k, binary)
Y = prog.put_i32(codes)
tot, sw, W = _row_fold(prog, S, k, Y, n, kind, w)
off, per = _group(prog, Y, n, k, weights=W)
PER, weighted = (off, 0) if w is None else (per, 1)
res = prog.alloc(2)
if kind == "logloss":
prog.stage("rank_epi", 1, _RANK_D2_LOG, tot, PER, weighted, k, res)
else:
prog.stage("rank_epi", 1, _RANK_D2_BRIER, tot, n, _NONE if sw is None else sw, PER, weighted, k, res)
_execute(prog, numeric_mode)
_proba_check(prog, flags, caller)
return float(prog.words(res, 2, "d")[0])
[docs]
def d2_log_loss_score(y_true, y_proba=None, *, sample_weight=None, labels=None, numeric_mode=None):
"""scikit-learn 1.9 `d2_log_loss_score`: one minus the log loss over the
log loss of the (weighted) class frequencies."""
return _d2_proba(y_true, y_proba, sample_weight, labels, None, numeric_mode, "d2_log_loss_score", "logloss")
[docs]
def d2_brier_score(y_true, y_proba, *, sample_weight=None, pos_label=None, labels=None, numeric_mode=None):
"""scikit-learn 1.9 `d2_brier_score`: one minus the Brier score over the
Brier score of the (weighted) class frequencies."""
return _d2_proba(y_true, y_proba, sample_weight, labels, pos_label, numeric_mode, "d2_brier_score", "brier")
[docs]
def hinge_loss(y_true, pred_decision, *, labels=None, sample_weight=None, numeric_mode=None):
"""scikit-learn 1.9 `hinge_loss`: binary (labels mapped to -1 / +1, the
larger class positive) or Crammer-Singer multiclass margins."""
from ._metrics_impl import _label_map, _selected_labels
true, kind, present = _targets(y_true, "hinge_loss")
n = len(true)
uniq = present if labels is None else sorted(_selected_labels(labels, kind, present)) # glue: sorts the selected class labels
s = _scores(pred_decision, n, "hinge_loss", name="pred_decision")
w = _weights(sample_weight, n, "hinge_loss")
prog = _Prog()
if len(uniq) > 2:
if s.ndim != 2:
raise ValueError("The shape of pred_decision cannot be 1d array with a multiclass target.")
if s.shape[1] != len(uniq):
raise ValueError("Please include all labels in y_true or pass labels as third argument"
if labels is None else
"The shape of pred_decision is not consistent with the number of classes.")
index = {c: i for i, c in enumerate(uniq)} # glue: class index table
codes = _label_map(true, lambda v: index[v])
S, Y = prog.put(s), prog.put_i32(codes)
return float(_row_mean(S, s.shape[1], Y, n, "hinge_mc", w, numeric_mode, prog=prog))
if s.ndim == 2:
if s.shape[1] != 1:
raise ValueError("pred_decision must be 1-D for a binary target")
s = s.reshape((n,))
pos = uniq[-1]
codes = _label_map(true, lambda v: int(v == pos and len(uniq) == 2))
S, Y = prog.put(s), prog.put_i32(codes)
return float(_row_mean(S, 1, Y, n, "hinge_bin", w, numeric_mode, prog=prog))
def _relevance(y_true, y_score, caller, *, indicator):
from ._buffer import materialize_f32_lists
y = materialize_f32_lists(y_true, "y_true")[0]
if y.dtype != "<f4":
y = as_f32_c(y, ndim=y.ndim, name="y_true")[0]
if y.ndim != 2:
raise ValueError(f"mojolearn {caller}: y_true must be a 2-D (n_samples, n_labels) array")
y = as_f32_c(y, ndim=2, name="y_true")[0]
if not all_finite(y):
raise ValueError(f"mojolearn {caller}: y_true must be finite")
s = _scores(y_score, y.shape[0], caller, ndim=2)
if s.shape != y.shape:
raise ValueError("y_true and y_score have different shape")
# `indicator`: the 0/1 test of y_true runs in the caller's program
# (`_relevance_put`: flag_scan, x_metrics/tail.mojo)
return y, s
def _relevance_put(prog, y, s, caller, indicator):
"""(S, Y, after): y_score and y_true in the program and, for an
indicator y_true, the device 0/1 test and its check."""
S, Y = prog.put(s), prog.put(y)
if not indicator:
return S, Y, ()
flag = _scan_flag(prog, Y, y.size, _SCAN_INDICATOR)
def check(prog):
if _flag_set(prog, flag):
raise ValueError(f"{caller} requires a binary label indicator y_true")
return S, Y, (check,)
def _dcg_table(prog, c, log_base):
"""The c-word Float32 DCG discount table `1 / log_b(i + 2)` (binary64,
narrowed), formed by the device (`rank_epi` DCG_DISC) in the program."""
b = float(log_base)
if not b > 0.0:
raise ValueError("math domain error")
if b == 1.0:
raise ZeroDivisionError("float division by zero")
B = _f64_words(prog, b)
D = prog.scratch(c)
if c:
prog.stage("rank_epi", c, _RANK_DCG_DISC, c, B, D)
return D
[docs]
def dcg_score(y_true, y_score, *, k=None, log_base=2, sample_weight=None, ignore_ties=False,
numeric_mode=None):
"""scikit-learn 1.9 `dcg_score`: tie-averaged gains by default;
ignore_ties=True ranks tied scores by DESCENDING column index (numpy's
`argsort(...)[::-1]` there, whose tie order its unstable sort leaves
unspecified; this one is fixed)."""
y, s = _relevance(y_true, y_score, "dcg_score", indicator=False)
n, c = y.shape
w = _weights(sample_weight, n, "dcg_score")
prog = _Prog()
S, Y, _ = _relevance_put(prog, y, s, "dcg_score", False)
Dt = _dcg_table(prog, c, log_base)
return float(_row_mean(S, c, Y, n, "dcg_ignore_ties" if ignore_ties else "dcg", w, numeric_mode,
K=0 if k is None else int(k), Dt=Dt, prog=prog))
# Engineering notes (moved from the docstring):
# scikit-learn 1.9 `ndcg_score`: each row's DCG over its ideal DCG
# (0 when the row has no relevant item), (weighted) averaged. The gains,
# the per-row ratios (`rank_epi` NDCG_ROW: the binary64 quotient narrowed
# to Float32), their (weighted) PairSum and the mean run in one device
# program (lane cpu2-l7-metrics: no host epilogue over the rows).
[docs]
def ndcg_score(y_true, y_score, *, k=None, sample_weight=None, ignore_ties=False, numeric_mode=None):
"""Normalized discounted cumulative gain.
Follows scikit-learn's `ndcg_score`: each row's DCG divided by its ideal DCG (0 when
the row has no relevant item), averaged over rows, weighted when `sample_weight` is
given. Computed on the GPU.
"""
y, s = _relevance(y_true, y_score, "ndcg_score", indicator=False)
n, c = y.shape
if c <= 1:
raise ValueError(f"Computing NDCG is only meaningful when there is more than 1 document. Got {c} instead.")
w = _weights(sample_weight, n, "ndcg_score")
K = 0 if k is None else int(k)
prog = _Prog()
S, Y, _ = _relevance_put(prog, y, s, "ndcg_score", False)
neg = _scan_flag(prog, Y, y.size, _SCAN_NEGATIVE)
Dt = _dcg_table(prog, c, 2)
gain = prog.scratch(n)
ideal = prog.scratch(n)
ratio = prog.scratch(n)
prog.stage("row_metric", n, S, c, Y, gain, _ROW["dcg_ignore_ties" if ignore_ties else "dcg"], K, Dt)
prog.stage("row_metric", n, Y, c, Y, ideal, _ROW["dcg"], K, Dt)
prog.stage("rank_epi", n, _RANK_NDCG_ROW, gain, ideal, n, ratio)
zero = prog.scratch(n)
prog.stage("pair_key", n, 0, 0, zero, 1, 2)
W = _NONE if w is None else prog.put(w)
tot = _group(prog, zero, n, 1, values=ratio, weights=W)[1]
sw = _group(prog, zero, n, 1, weights=W)[1] if w is not None else None
res = prog.alloc(2)
prog.stage("rank_epi", 1, _RANK_MEAN, tot, n, _NONE if sw is None else sw, 1, 0, res)
_execute(prog, numeric_mode)
if _flag_set(prog, neg):
raise ValueError("ndcg_score should not be used on negative y_true values.")
return float(prog.words(res, 2, "d")[0])
def _label_ranking(kind, y_true, y_score, sample_weight, numeric_mode, caller):
y, s = _relevance(y_true, y_score, caller, indicator=True)
n, c = y.shape
w = _weights(sample_weight, n, caller)
prog = _Prog()
S, Y, after = _relevance_put(prog, y, s, caller, True)
return float(_row_mean(S, c, Y, n, kind, w, numeric_mode, prog=prog, after=after))
[docs]
def coverage_error(y_true, y_score, *, sample_weight=None, numeric_mode=None):
"""scikit-learn 1.9 `coverage_error` (multilabel indicator y_true)."""
return _label_ranking("coverage", y_true, y_score, sample_weight, numeric_mode, "coverage_error")
[docs]
def label_ranking_average_precision_score(y_true, y_score, *, sample_weight=None, numeric_mode=None):
"""scikit-learn 1.9 `label_ranking_average_precision_score`."""
return _label_ranking("lrap", y_true, y_score, sample_weight, numeric_mode,
"label_ranking_average_precision_score")
[docs]
def label_ranking_loss(y_true, y_score, *, sample_weight=None, numeric_mode=None):
"""scikit-learn 1.9 `label_ranking_loss` (tied scores count as misordered)."""
return _label_ranking("rankloss", y_true, y_score, sample_weight, numeric_mode, "label_ranking_loss")
# ---------------------------------------------------------------------------
# Clustering (scikit-learn 1.9 sklearn/metrics/cluster/)
# ---------------------------------------------------------------------------
def _clusterings(labels_true, labels_pred, caller):
from ._metrics_impl import _classification_encoded, _label_set
for v, name in ((labels_true, "labels_true"), (labels_pred, "labels_pred")): # glue: validates the two named arguments
_refuse_multilabel(v, name, caller)
a, ka = _classification_encoded(labels_true, "labels_true")
b, kb = _classification_encoded(labels_pred, "labels_pred")
if len(a) != len(b):
raise ValueError(f"mojolearn {caller}: labels_true and labels_pred must have the same length")
return a, b, sorted(_label_set(a)), sorted(_label_set(b)) # glue: sorts the distinct cluster labels
class _Contingency:
"""The exact contingency counts of two clusterings and what the
clustering metrics read from them, all formed on the device (lane
cpu4-python: x_metrics/contingency.mojo cont_stats, units the host
column runs too): `C` (ka x kb Int64, rows = classes of `a`, columns =
classes of `b`), `F` (C + eps as Float64, when eps is given), `rows` and
`cols` (Int64 sums), `pairs` (the 2 x 2 pair confusion counts, Int64),
`ent` (the row and column entropies, Float64), and, when asked, `mi`
(the MI in nats) and `emi` (the expected MI)."""
__slots__ = ("ka", "kb", "C", "F", "rows", "cols", "pairs", "ent", "mi", "emi")
def _contingency(a, b, ca, cb, numeric_mode, eps=None, mi=False, emi=False):
n, ka, kb = len(a), len(ca), len(cb)
if ka * kb > 16777216:
raise ValueError("mojolearn metrics: the contingency matrix exceeds 2^24 cells")
prog = _Prog()
A = prog.put_i32(_codes(a, ca))
B = prog.put_i32(_codes(b, cb))
key = prog.scratch(n)
kk = max(ka, kb)
prog.stage("pair_key", n, A, B, key, kk, 0)
m = kk * kk
off = prog.scratch(m + 1)
prog.stage("group_sort", 1, key, n, m, off, prog.scratch(n))
cells = ka * kb
EPS = prog.put_i32(_words64([float(eps)])) if eps is not None else _NONE
C = prog.want(prog.alloc(2 * cells), 2 * cells)
F = prog.want(prog.alloc(2 * cells), 2 * cells) if eps is not None else _NONE
R = prog.want(prog.alloc(2 * ka), 2 * ka)
K = prog.want(prog.alloc(2 * kb), 2 * kb)
P = prog.want(prog.alloc(8), 8)
E = prog.want(prog.alloc(4), 4)
MI = prog.want(prog.alloc(2), 2) if mi else _NONE
EMI = prog.want(prog.alloc(2), 2) if emi else _NONE
prog.stage("cont_stats", 1, off, ka, kb, kk, EPS, C, F, R, K, P, E, MI, EMI, n)
_execute(prog, numeric_mode)
r = _Contingency()
r.ka, r.kb = ka, kb
r.C = Array._owned(prog.words(C, 2 * cells, "q"), (ka, kb), "<i8", "C")
r.F = Array._owned(prog.words(F, 2 * cells, "d"), (ka, kb), "<f8", "C") if eps is not None else None
r.rows = Array._owned(prog.words(R, 2 * ka, "q"), (ka,), "<i8", "C")
r.cols = Array._owned(prog.words(K, 2 * kb, "q"), (kb,), "<i8", "C")
r.pairs = Array._owned(prog.words(P, 8, "q"), (2, 2), "<i8", "C")
r.ent = Array._owned(prog.words(E, 4, "d"), (2,), "<f8", "C")
r.mi = float(prog.words(MI, 2, "d")[0]) if mi else None
r.emi = float(prog.words(EMI, 2, "d")[0]) if emi else None
return r
[docs]
def contingency_matrix(labels_true, labels_pred, *, eps=None, sparse=False, dtype="int64", numeric_mode=None):
"""scikit-learn 1.9 `contingency_matrix` (dense): exact counts from the
device grouping, rows the sorted classes, columns the sorted clusters;
`eps` adds a constant and makes the result Float64. sparse=True is
NOT IMPLEMENTED (no sparse container in this package)."""
if eps is not None and sparse:
raise ValueError("Cannot set 'eps' when sparse=True")
if sparse:
raise NotImplementedError("mojolearn contingency_matrix: sparse=True is NOT IMPLEMENTED; this "
"package has no sparse matrix type (metrics/NOT_IMPLEMENTED.tsv)")
a, b, ca, cb = _clusterings(labels_true, labels_pred, "contingency_matrix")
r = _contingency(a, b, ca, cb, numeric_mode, eps=eps)
if eps is not None:
return r.F
code = {"int64": "<i8", "int32": "<i4", "float64": "<f8", "float32": "<f4"}.get(
dtype if isinstance(dtype, str) else getattr(dtype, "__name__", str(dtype)), "<i8")
return r.C if code == "<i8" else r.C.astype(code)
[docs]
def pair_confusion_matrix(labels_true, labels_pred, *, numeric_mode=None):
"""scikit-learn 1.9 `pair_confusion_matrix`: the 2 x 2 Int64 pair counts
from the exact contingency matrix (exact Int64 in the binding: every
pair count is below 2^62)."""
a, b, ca, cb = _clusterings(labels_true, labels_pred, "pair_confusion_matrix")
return _contingency(a, b, ca, cb, numeric_mode).pairs
def _generalized_average(U, V, method):
if method == "min":
return min(U, V)
if method == "geometric":
return pmath.sqrt(U * V)
if method == "arithmetic":
return (U + V) / 2
if method == "max":
return max(U, V)
raise ValueError("'average_method' must be 'min', 'geometric', 'arithmetic', or 'max'")
# Engineering notes (moved from the docstring):
# scikit-learn 1.9 `normalized_mutual_info_score`, in nats as scikit-learn
# (exact device contingency counts; the logarithms are the portable binary64
# ones of `_portable_math`, DEVIATION 6106).
[docs]
def normalized_mutual_info_score(labels_true, labels_pred, *, average_method="arithmetic", numeric_mode=None):
"""Normalized mutual information between two clusterings.
Follows scikit-learn's `normalized_mutual_info_score`, in nats.
"""
_generalized_average(1.0, 1.0, average_method)
a, b, ca, cb = _clusterings(labels_true, labels_pred, "normalized_mutual_info_score")
if len(ca) == len(cb) == 1 or len(ca) == len(cb) == 0:
return 1.0
r = _contingency(a, b, ca, cb, numeric_mode, mi=True)
mi = r.mi
if mi == 0:
return 0.0
ht, hp = r.ent[0], r.ent[1]
return float(mi / _generalized_average(ht, hp, average_method))
[docs]
def adjusted_mutual_info_score(labels_true, labels_pred, *, average_method="arithmetic", numeric_mode=None):
"""scikit-learn 1.9 `adjusted_mutual_info_score`: (MI - E[MI]) /
(mean(H) - E[MI]) with scikit-learn's epsilon guards."""
_generalized_average(1.0, 1.0, average_method)
a, b, ca, cb = _clusterings(labels_true, labels_pred, "adjusted_mutual_info_score")
if len(ca) == len(cb) == 1 or len(ca) == len(cb) == 0:
return 1.0
if len(ca) == 1 or len(cb) == 1:
return 0.0
# MI and E[MI] under the permutation model (Vinh, Epps and Bailey 2010):
# each hypergeometric pmf built by its ratio recurrence from the mode and
# normalized by its own sum, portable binary64 log only, on the device
# (lane cpu4-python: x_metrics/contingency.mojo mi_cell, emi_cell)
r = _contingency(a, b, ca, cb, numeric_mode, mi=True, emi=True)
mi, emi = r.mi, r.emi
norm = _generalized_average(r.ent[0], r.ent[1], average_method)
eps = 2.220446049250313e-16
den = norm - emi
den = min(den, -eps) if den < 0 else max(den, eps)
num = mi - emi
num = min(num, -eps) if num < 0 else max(num, eps)
return float(num / den)
def _cluster_inputs(X, labels, caller):
from ._metrics_impl import _classification_encoded, _label_set
Xa = as_f32_c(X, ndim=2, name="X")[0]
if not all_finite(Xa):
raise ValueError(f"mojolearn {caller}: X contains NaN or infinity")
lab, _ = _classification_encoded(labels, "labels")
if len(lab) != Xa.shape[0]:
raise ValueError(f"mojolearn {caller}: X and labels have different numbers of rows")
classes = sorted(_label_set(lab)) # glue: sorts the distinct class labels
if not 1 < len(classes) < Xa.shape[0]:
raise ValueError(f"Number of labels is {len(classes)}. Valid values are 2 to n_samples - 1 (inclusive)")
return Xa, _codes(lab, classes), len(classes)
#: cl_epi kinds (x_metrics/rank_epi.mojo, lane cpu2-l7-metrics S3b)
_CL_CENT, _CL_CH_ROW, _CL_CH_FIN, _CL_DB_DIST, _CL_DB_FIN = 0, 1, 2, 3, 4
def _cluster_score(X, labels, numeric_mode, caller, db):
"""calinski_harabasz_score (db False) or davies_bouldin_score (db True)
as ONE device program (lane cpu2-l7-metrics S3b): the per-cluster and
global column sums (group sums), the centroids (`cl_epi` CENT: binary64
sums / counts, and their Float32 words), the row distances to the
Float32 centroids (row_centroid_dist, PairSum, sqrt for DB) and their
per-cluster sums, then CH_ROW + CH_FIN or DB_DIST + DB_FIN in binary64.
The host centroid / ch_extra / db_score epilogues, the Python count
differences and the second upload of X are gone; one binary64 word
comes back."""
Xa, codes, k = _cluster_inputs(X, labels, caller)
n, d = Xa.shape
prog = _Prog()
X0 = prog.put(Xa)
L = prog.put_i32(codes)
off, sums = _group(prog, L, n, k, values=X0, vstride=d, width=d)
C32 = prog.scratch(k * d)
CB = prog.scratch(2 * k * d)
prog.stage("cl_epi", k * d, _CL_CENT, off, sums, k, d, C32, CB)
dist = prog.scratch(n)
prog.stage("row_centroid_dist", n, X0, d, L, C32, dist, 1 if db else 0)
_, per = _group(prog, L, n, k, values=dist)
res = prog.want(prog.alloc(2), 2)
if db:
D = prog.scratch(2 * k * k)
prog.stage("cl_epi", k * k, _CL_DB_DIST, CB, k, d, D)
prog.stage("cl_epi", 1, _CL_DB_FIN, off, per, D, k, res)
else:
zero = prog.scratch(n)
prog.stage("pair_key", n, 0, 0, zero, 1, 2)
_, gsum = _group(prog, zero, n, 1, values=X0, vstride=d, width=d)
inner = prog.scratch(2 * k)
prog.stage("cl_epi", k, _CL_CH_ROW, CB, gsum, n, d, k, inner)
prog.stage("cl_epi", 1, _CL_CH_FIN, off, inner, per, n, k, res)
_execute(prog, numeric_mode)
return float(prog.words(res, 2, "d")[0])
[docs]
def calinski_harabasz_score(X, labels, *, numeric_mode=None):
"""scikit-learn 1.9 `calinski_harabasz_score`: the between- over the
within-cluster dispersion, scaled by (n - k) / (k - 1), in one device
program (`_cluster_score`)."""
return _cluster_score(X, labels, numeric_mode, "calinski_harabasz_score", False)
[docs]
def davies_bouldin_score(X, labels, *, numeric_mode=None):
"""scikit-learn 1.9 `davies_bouldin_score`: the mean over clusters of the
worst (s_i + s_j) / d(c_i, c_j), in one device program (`_cluster_score`)."""
return _cluster_score(X, labels, numeric_mode, "davies_bouldin_score", True)
# ---------------------------------------------------------------------------
# The splitters' random draws (DEVIATION 6108; x_metrics/split.mojo)
# ---------------------------------------------------------------------------
_M64 = (1 << 64) - 1
def _mix64(x):
"""splitmix64's finalizer in exact Python integers."""
x &= _M64
x = ((x ^ (x >> 30)) * 0xBF58476D1CE4E5B9) & _M64
x = ((x ^ (x >> 27)) * 0x94D049BB133111EB) & _M64
return x ^ (x >> 31)
class CounterRng:
"""The splitters' random source: draw k of seed s is a device
permutation keyed by `_mix64(s * GOLDEN + k)`. An int seed always
gives the same sequence of draws; numpy RandomState / Generator objects
are refused by name (their stream is not ours to reproduce)."""
def __init__(self, random_state):
import os
if random_state is None:
random_state = int.from_bytes(os.urandom(8), "little")
if is_bool(random_state) or not isinstance(random_state, numbers.Integral) or random_state < 0:
raise ValueError("mojolearn model_selection: random_state must be None or a non-negative int; "
"numpy RandomState / Generator objects are NOT IMPLEMENTED (their stream "
"is numpy's, x_metrics/split.mojo DEVIATION 6108)")
self.seed = int(random_state) & _M64
self.draws = 0
self.binding = _BINDING
def _salt(self):
salt = _mix64(self.seed * 0x9E3779B97F4A7C15 + self.draws + 1)
self.draws += 1
return salt
def permute_stage(self, prog, n):
"""The next draw as a `permute` stage of `prog`; returns its slot."""
salt = self._salt()
out = prog.alloc(max(n, 1))
lo = salt & 0xFFFFFFFF
hi = salt >> 32
prog.stage("permute", 1, n, out, lo - (1 << 32) if lo >= 1 << 31 else lo,
hi - (1 << 32) if hi >= 1 << 31 else hi)
return out
def approximate_modes(self, counts, n_train, n_test, numeric_mode=None):
"""StratifiedShuffleSplit's per-class train and test draw counts
(lane py-runtime-b; it was Python's `model_selection._approximate_mode`
twice): scikit-learn's `_approximate_mode` in exact integers, the
train counts n_i over `counts`, then the test counts over counts -
n_i, each in one `approx_mode` stage (x_metrics/split.mojo) of one
device program. A tie group the remainders cut is broken by the
next counter draw, so each call consumes the draws the stages report
(0 or 1 each), the same draws in the same order as before. Returns
(n_i, t_i) as lists of ints."""
k = len(counts)
prog = _Prog()
C = prog.put_i32(counts)
out = prog.want(prog.alloc(2 * k), 2 * k)
used = prog.want(prog.alloc(2), 2)
words = []
for v in (self.seed & 0xFFFFFFFF, self.seed >> 32, self.draws & 0xFFFFFFFF, (self.draws >> 32) & 0xFFFFFFFF): # glue: four parameter words
words.append(v - (1 << 32) if v >= 1 << 31 else v)
prog.stage("approx_mode", k, k, C, _NONE, n_train, out, _NONE, used, *words)
prog.stage("approx_mode", k, k, C, out, n_test, out + k, used, used + 1, *words)
_execute(prog, numeric_mode)
drew = prog.ints(used, 2)
self.draws += drew[0] + drew[1]
both = prog.ints(out, 2 * k)
return both[:k], both[k:]
def permutations(self, sizes, numeric_mode=None):
"""One permutation per size, drawn in order, in one device program."""
prog = _Prog()
outs = []
for n in sizes: # glue: one output slot per requested draw
outs.append((prog.want(self.permute_stage(prog, n), n), n))
if not outs:
return []
_execute(prog, numeric_mode)
return [prog.ints(o, n) for o, n in outs] # glue: reads each requested draw's words
def permutation(self, n, numeric_mode=None):
return self.permutations([n], numeric_mode)[0]
def permutation_rows(self, sizes, numeric_mode=None):
"""`permutations` (the same draws) as array('q') rows: the device
widens each to Int64 words (`rows64`), so no Python int is made
per row (lane metrics-apple2)."""
prog = _Prog()
outs = []
for n in sizes: # glue: one output slot per requested draw
o = self.permute_stage(prog, n)
w = prog.want(prog.alloc(2 * n), 2 * n)
if n:
prog.stage("rows64", n, o, w)
outs.append((w, n))
if not outs:
return []
_execute(prog, numeric_mode)
return [prog.words(w, 2 * n, "q") for w, n in outs] # glue: reads each requested draw's words
#: the largest K-fold row table (2 words per row per fold) `fold_rows` builds
_FOLD_ROWS_BOUND = 1 << 27
def fold_rows(n, k, *, codes=None, rng=None, numeric_mode=None):
"""[(train, test)] Int64 row Arrays of a K-fold split, ascending, built
by the `fold_rows` unit (lane metrics-apple2): `codes` = each row's
test fold (bytes, values < 256), or `rng` = KFold's shuffle (the rows
of fold f are the positions of fold f in the rng's next permutation).
The rows come back as Int64 words, so no Python int is made per row.
Returns None when the table would exceed `_FOLD_ROWS_BOUND` words (the
caller keeps its own path)."""
if n < 2 or k < 1 or 2 * n * k > _FOLD_ROWS_BOUND:
return None
prog = _Prog()
if codes is None:
order = rng.permute_stage(prog, n)
code = prog.scratch(n)
else:
words = bytearray(4 * n)
words[0 if _LITTLE else 3::4] = codes
store = array.array("i")
store.frombytes(words)
code = prog.put_i32(Array._owned(store, (n,), "<i4", "C"))
order = _NONE
return _fold_rows_run(prog, n, k, code, order, numeric_mode)
def _fold_rows_run(prog, n, k, code, order, numeric_mode):
out = prog.want(prog.alloc(2 * n * k), 2 * n * k)
sz = prog.want(prog.alloc(k), k)
prog.stage("fold_rows", 1, n, k, code, order, out, sz)
_execute(prog, numeric_mode)
sizes = prog.ints(sz, k)
res = []
for f in range(k): # glue: wraps each fold's result views
c = sizes[f]
base = out + 2 * n * f
test = prog.words(base, 2 * c, "q")
train = prog.words(base + 2 * c, 2 * (n - c), "q")
res.append((Array._owned(train, (n - c,), "<i8", "C"), Array._owned(test, (c,), "<i8", "C")))
return res
def stratified_fold_rows(enc, counts, alloc, k, rng, numeric_mode=None):
"""StratifiedKFold's [(train, test)] in ONE device program (lane
metrics-apple2): a stable group_sort of the first-seen class codes
`enc`, each class's permutation (the rng's next draws, in class order,
the draws `_fold_of_rows` makes) laid out at PB + the class's offset,
`strat_codes` (each row's fold, x_metrics/split.mojo), then `fold_rows`.
None when the table would exceed `_FOLD_ROWS_BOUND` words."""
n, m = len(enc), len(counts)
if n < 2 or k < 1 or 2 * n * k > _FOLD_ROWS_BOUND:
return None
prog = _Prog()
# an int32 Array of codes goes in as it is (lane metrics-apple3)
ENC = prog.put_i32(enc if isinstance(enc, Array) and enc.dtype == "<i4" and enc.ndim == 1
else Array._owned(array.array("i", enc), (n,), "<i4", "C"))
OFF = prog.alloc(m + 1)
ORD = prog.scratch(n)
prog.stage("group_sort", 1, ENC, n, m, OFF, ORD)
PB = _NONE
if rng is not None:
at = 0
for c in range(m): # glue: one permutation stage per class
o = rng.permute_stage(prog, counts[c])
if c == 0:
PB = o
if o != PB + at:
raise AssertionError("stratified_fold_rows: the class permutations are not contiguous")
at += counts[c]
cum = []
for c in range(m): # glue: fold by class offsets for the device code stage (m-sized: class count)
acc = 0
cum.append(0)
for f in range(k): # glue: fold by class offsets for the device code stage (k-sized: fold count)
acc += alloc[f][c]
cum.append(acc)
CUM = prog.put_i32(Array._owned(array.array("i", cum), (len(cum),), "<i4", "C"))
code = prog.scratch(n)
prog.stage("strat_codes", n, ENC, ORD, OFF, PB, CUM, k, code)
return _fold_rows_run(prog, n, k, code, _NONE, numeric_mode)
def regression_report(y_true, y_pred, *, sample_weight=None,
multioutput="uniform_average", force_finite=True,
numeric_mode="identical"):
"""One IDENTICAL report of MSE, MAE and R² using expansion metric contracts.
C09 A shares target/prediction loads; B retains independent term stages.
All three requested outputs, validation and synchronization are included.
NOT COMPILED — NOT TESTED — IDENTITY NOT VERIFIED — QUALITY NOT VERIFIED — NOT MEASURED.
"""
if numeric_mode != "identical":
raise ValueError("regression_report requires numeric_mode='identical'")
r = _Reg(y_true, y_pred, sample_weight, multioutput, "regression_report")
r.program()
squared = r.term("sq")
absolute = r.term("abs")
squared_sums = r.sums(squared)
mse = r.average(r.fin(squared_sums))
mae = r.average(r.fin(r.sums(absolute)))
y_mean = r.fin(r.sums(r.Y), f32=True)[1]
denominator = _centered(r, r.Y, y_mean, mean=False)
r2 = _assemble(r, r.fin(squared_sums, 0), denominator, force_finite)
_execute(r.prog, numeric_mode)
def result(offset):
if r.mo == "raw_values":
return Array._owned(r.prog.words(offset, 2*r.D, "d"), (r.D,), "<f8", "C")
return float(r.prog.words(offset, 2, "d")[0])
if r.n < 2:
_undefined_warning("R^2 score is not well-defined with less than two samples.")
r2_value = float("nan")
else:
r2_value = result(r2)
return {"mean_squared_error": result(mse), "mean_absolute_error": result(mae), "r2_score": r2_value}
def ranking_report(y_true, y_score, *, pos_label=None, sample_weight=None,
include_curves=False, numeric_mode="identical"):
"""Binary ROC AUC/AP and optional ROC/PR curves in one native program.
C10 A reuses a stable score order and canonical prefixes. B executes
both incumbent curve producers. Weighted ties keep their row order.
NOT COMPILED — NOT TESTED — IDENTITY NOT VERIFIED — QUALITY NOT VERIFIED — NOT MEASURED.
"""
if numeric_mode != "identical":
raise ValueError("ranking_report requires numeric_mode='identical'")
from ._metrics_impl import _label_map
true, kind, classes = _targets(y_true, "ranking_report")
if len(classes) > 2:
raise ValueError("ranking_report currently requires binary targets")
pos = _pos_label(pos_label, kind, classes, "ranking_report")
n = len(true)
if n <= 0:
raise ValueError("mojolearn metrics: an empty curve has no thresholds")
scores = _scores(y_score, n, "ranking_report", ndim=1)
weights = _weights(sample_weight, n, "ranking_report")
flags = _label_map(true, lambda v: int(v == pos))
prog = _Prog()
S, POS, W = prog.put(scores), _put_flags(prog, flags), _put_weights(prog, weights)
curves = []
for _ in range(2): # glue: two fixed requested reports, never rows
order, fps, tps, thr = prog.scratch(n), prog.scratch(n), prog.scratch(n), prog.scratch(n)
count = prog.scratch(1)
curves.append((fps, tps, thr, count))
prog.stage("bin_curve", 1, S, 1, POS, W, n, order, fps, tps, thr, count, _NONE, 0, 0, 0)
scalar_outputs = []
curve_outputs = []
curve_totals = []
for fold, (fps, tps, thr, count) in enumerate(curves): # glue: fixed AUC/AP reports
folded = prog.alloc(_CF_OUT)
curve_totals.append(prog.want(folded+2, 2))
result, status = prog.want(prog.alloc(2), 2), prog.want(prog.alloc(1), 1)
prog.stage("curve_fold", 1, n, fps, tps, count, folded, fold, 0, 0, _CF_CHUNK)
prog.stage("rank_epi", 1, _RANK_CURVE_SCORE, folded, 1, fold, result, status, _NONE, 0, 0)
scalar_outputs.append((result, status))
if include_curves:
lengths = prog.want(prog.alloc(6), 6)
output = prog.alloc(6*(n+1))
for channel in range(3): # glue: three native curve output arrays
prog.want(output+2*channel*(n+1), 2*(n+1), count=lengths, mult=2)
prog.stage("curve_out", 1, _CO_ROC if fold == 0 else _CO_PR, n,
fps, tps, thr, count, 0, output, lengths)
curve_outputs.append((output, lengths))
_execute(prog, numeric_mode)
auc_out, auc_flag = scalar_outputs[0]
ap_out, ap_flag = scalar_outputs[1]
if prog.ints(auc_flag, 1)[0] == 1:
_undefined_warning("Only one class is present in y_true. ROC AUC score is not defined in that case.")
if prog.ints(ap_flag, 1)[0] == 2:
raise ZeroDivisionError("float division by zero")
if prog.floats(curve_totals[1]+1, 1)[0] == 0:
warnings.warn("No positive class found in y_true, recall is set to one for all thresholds.",
UserWarning, stacklevel=2)
result = {"roc_auc": float(prog.words(auc_out, 2, "d")[0]),
"average_precision": float(prog.words(ap_out, 2, "d")[0])}
for kind, (output, lengths) in enumerate(curve_outputs): # glue: wraps two fixed native results
length = prog.ints(lengths, 1)[0]
arrays = []
for channel in range(3): # glue: output shape/ownership only
size = length-1 if kind == 1 and channel == 2 else length
arrays.append(Array._owned(prog.words(output+2*channel*(n+1), 2*size, "d"),
(size,), "<f8", "C"))
result["roc_curve" if kind == 0 else "precision_recall_curve"] = tuple(arrays)
return result