# SPDX-License-Identifier: Apache-2.0
"""Cross-validation, splitters and hyperparameter search for mojolearn estimators.
This module follows scikit-learn's ``sklearn.model_selection``: the splitters
(``KFold``, ``StratifiedKFold``, ``GroupKFold``, ``ShuffleSplit``, ...),
``train_test_split``, ``cross_val_score``, ``cross_validate``,
``cross_val_predict``, ``GridSearchCV``, ``RandomizedSearchCV``,
``validation_curve``, ``learning_curve``, ``permutation_test_score`` and the
scorer helpers. scikit-learn is not required; scikit-learn pipelines,
splitters and estimators that follow its protocol also work.
Behavior to know:
* Fits run serially, one fold at a time. ``n_jobs`` must be ``1`` (or
``None`` where scikit-learn accepts it).
* Splitters yield ``(train, test)`` pairs of int64 row-index arrays.
* Shuffled splits draw from mojolearn's own counter-based random generator,
so a given ``random_state`` gives a reproducible split, but not the same
rows scikit-learn would pick for that seed.
* Fold rows are gathered on the device, and X and y stay resident there
across folds and candidates. All learning happens in the estimator.
"""
import array
import collections
import copy
import hashlib
import itertools
import json
from . import _portable_math as math
import numbers
import os
import warnings
from . import _arena_io
from ._array import Array
from ._buffer import _materialize, _native, _native_optional, empty
from ._arrays import _addr, _addr_ro
from ._labels import is_bool, flatten_labels
# The splitters' random draws (lane/metrics): a module-level import, so the
# lane selector sees model_selection reach the x_metrics binding.
from ._expansion_metrics import CounterRng, _mix64, fold_rows, stratified_fold_rows
#: The binding the splitters' permutations and the scorers' added metrics run
#: on (python/mojolearn/_expansion_metrics.py `_BINDING`). Named here because
#: tools/lane_select.py resolves a lane's bindings from the doors it runs
#: WHOLE, and a splitter lane's door is this file.
_SPLIT_BINDING = "_mojolearn_x_metrics"
__all__ = ['cross_val_score', 'split_descriptor']
#: THE DORMANT NEGATIVE CONTROL for the fold assignment (lane/data-ordering-
#: determinism, 2026-09-16). Fold assignment is the one data-ordering decision
#: mojolearn owns end to end -- which row is trained on and which is held out,
#: in which fold, in what order -- and `identity_break`'s `cross-val-folds`
#: lane hashes it. A cell that cannot be seen to move is not evidence, so this
#: is the switch that moves it.
#:
#: BOTH variables are required, for the reason `_backend.py` refuses a sabotage
#: binding outside the gate: a switch that quietly returns wrong answers on one
#: env var is a footgun. No build script, no workflow and no gate sets either
#: one; only the lane's own sabotage arm does.
#:
#: WHAT IT DOES, and why this shape. It rotates the row-to-fold assignment by
#: ONE position. Every fold keeps its size, train and test stay disjoint, train
#: stays exactly the complement, and every row is still held out exactly once --
#: so every invariant a partition check could test still passes and the answer
#: is simply a DIFFERENT partition. That is the failure class
#: `core/shuffle_iterator.mojo` names in its header: a wrong permutation is
#: still a perfectly uniform-looking permutation, and nothing downstream can
#: attribute the difference. A sabotage that broke an invariant would be caught
#: by the invariant and would prove nothing about the hash.
_FOLD_ORDER_SABOTAGE = 'MOJOLEARN_FOLD_ORDER_SABOTAGE'
def _sabotage_requested():
return (os.environ.get(_FOLD_ORDER_SABOTAGE) == '1'
and os.environ.get('MOJOLEARN_HOST_ALLOW_SABOTAGE') == '1')
#: DEVIATION 3104 (lane/python-hotpath, 2026-09-17). Below this many rows the
#: Python routines below are the only path; above it the fold bookkeeping
#: runs in the core helpers of bindings/hotpath_helpers.mojo when the binary
#: has them. Measured on the M4 at 1,000,000 rows, five folds: the ten
#: `_indices` calls and five overlap tests cost 1867 ms and the default folds
#: 195 to 416 ms, every one of them integer bookkeeping.
_NATIVE_MIN_ROWS = 1 # lane cgr4-py-compute: every size takes the core helpers
def _native_indices(value, n):
"""`_indices`' three tests through `check_indices_i64`: 0 accepted, 1 out
of range, 2 duplicate, tested in that order as below; None when the
helper is not available."""
check = _native('check_indices_i64')
as_i64 = value if value.dtype == '<i8' else value.astype('<i8')
return as_i64, int(check(_addr_ro(as_i64), as_i64.size, int(n)))
def _overlap(train, test, n):
"""Whether two accepted index Arrays share a row."""
if not train.size or not test.size or n <= 0:
return False
return bool(_native('indices_overlap_i64')(_addr_ro(train), train.size, _addr_ro(test), test.size, int(n)))
def _indices(value, n, name):
value = _materialize(value, name)[0]
if value.ndim != 1 or value.dtype[1:2] not in 'iu' or not value.size:
raise ValueError(f'{name} must be a nonempty 1-D integer index array')
as_i64, status = _native_indices(value, n)
if status == 1:
raise ValueError(f'{name} contains an out-of-range index')
if status == 2:
raise ValueError(f'{name} contains duplicate indices')
return as_i64
#: The dtype codes of core/msel_convert.mojo (bindings/hotpath_helpers.mojo's HP_*).
_MSEL_CODE = {'<f4': 0, '<f8': 1, '<i4': 2, '<i8': 3, '<u4': 4, '<u1': 5}
class _Resident:
"""Device-resident copies of the buffers a cross-validation reuses (lane
cpu2-l4-modelsel, re-audit L4): X, y and every fold's index Array are put
ONCE into the base binding's model-selection store (`msel_put`,
core/msel_device.mojo), and each fold's rows for each candidate are one
device gather out of them (`msel_take_rows`), downloaded straight into
the Array the estimator is handed. There is no host gather: the host
column is the core host binding's `msel_*` on a CPU-only install.
Keyed by object identity (the entry holds the object, so its id cannot
be reused while the entry lives). Freed deterministically by `close`
(or the end of a `with` block), never left to the garbage collector;
`release` frees one entry. The caller must not write into a buffer it
has handed here while the entry lives."""
def __init__(self):
self._put = _native('msel_put')
self._free = _native('msel_free')
self._take = _native('msel_take_rows')
self._entries = {}
def _entry(self, values, dtype=None):
hit = self._entries.get(id(values))
if hit is not None and hit[0] is values:
return hit
arr = _materialize(values, 'fold data')[0]
if dtype is not None and arr.dtype != dtype:
arr = arr.astype(dtype)
arr = arr._as_c()
sid = int(self._put(_addr_ro(arr), arr.nbytes)) if arr.nbytes else -1
entry = (values, arr, sid)
self._entries[id(values)] = entry
return entry
def take(self, values, indices):
"""Rows `indices` (an int64 index Array) of `values`, gathered on
the device."""
_, arr, sid = self._entry(values)
_, idx, iid = self._entry(indices, '<i8')
n_idx = idx.size
out = empty((n_idx, *arr.shape[1:]), arr.dtype)
n = len(arr)
row_bytes = arr.nbytes // n if n else 0
if n_idx and row_bytes:
self._take(sid, n, row_bytes, iid, n_idx, _addr(out))
return out
def release(self, values):
hit = self._entries.get(id(values))
if hit is not None and hit[0] is values:
del self._entries[id(values)]
if hit[2] >= 0:
self._free(hit[2])
def close(self):
entries, self._entries = self._entries, {}
for _, _, sid in entries.values(): # glue: frees each resident id
if sid >= 0:
self._free(sid)
def __enter__(self):
return self
def __exit__(self, *exc):
self.close()
return False
def _take_rows(values, indices):
"""Dense fold rows by one device gather (`_Resident`, used once); a
Python list of labels (str or object labels, the G5 input-prep case)
keeps its list comprehension."""
if isinstance(values, list):
return [values[i] for i in indices] # cpu-route: Python list of label objects (str/object) taken by index; no Array holds them [py-data-loop]
with _Resident() as res:
return res.take(values, indices)
def _clone(value, *, parameter=False):
"""Fresh constructor state; never deepcopy an estimator's fitted buffers.
Reference: sklearn 1.9.1 base.py::_clone_parametrized. External estimators
may supply their own clone protocol; built-in estimators need no sklearn.
"""
kind = type(value)
if kind is dict:
return {key: _clone(item, parameter=True) for key, item in value.items()} # glue: estimator parameter clone, one entry per parameter
if kind in (list, tuple, set, frozenset):
return kind(_clone(item, parameter=True) for item in value) # glue: estimator parameter clone, one entry per parameter
if not isinstance(value, type) and hasattr(value, '__sklearn_clone__'):
return value.__sklearn_clone__()
if isinstance(value, type) or not callable(getattr(value, 'get_params', None)):
if parameter:
return copy.deepcopy(value)
raise TypeError('cross_val_score estimator must implement get_params')
parameters = {name: _clone(item, parameter=True)
for name, item in value.get_params(deep=False).items()} # glue: estimator parameter clone, one entry per parameter
result = kind(**parameters)
actual = result.get_params(deep=False)
if any(actual[name] is not item for name, item in parameters.items()): # glue: estimator parameter clone, one entry per parameter
raise RuntimeError('estimator constructor must retain its parameter objects for cloning')
return result
def _classifier(estimator):
kind = getattr(estimator, '_estimator_type', None)
if kind is not None:
return kind == 'classifier'
# External Pipeline and custom estimators can expose their tag protocol;
# calling it is optional interoperability, never a built-in dependency.
tags = getattr(estimator, '__sklearn_tags__', None)
if callable(tags) and not type(estimator).__module__.startswith('mojolearn'):
return tags().estimator_type == 'classifier'
return False
def _default_fold_arrays(y, n_splits, classifier):
"""The unshuffled default folds as int64 index Arrays (DEVIATION 3104),
computed by the core helpers `fold_ids` and `select_fold_i64` for every
label kind and size, the fold-order sabotage control included (lane
py-runtime-b). The Python DEFINITION the fold tests and tools hold this
to is `mojolearn/tests/_fold_reference.py::default_folds`."""
yield from _native_default_folds(y, n_splits, classifier)
def _discrete_codes(y):
"""(classes, int32 codes) when y is discrete as `_default_folds` reads it
(every label an integer valued finite number, or not a number), else
None. The integral test is `reduce_stat`; the codes are `encode_labels`."""
from ._labels import encode_labels
from ._array import _NATIVE_CODE, _REDUCE_INTEGRAL
try:
arr, _ = _materialize(y, 'y')
except (TypeError, ValueError):
arr = None
if arr is not None and arr.dtype in ('<f4', '<f8'):
flat = arr.reshape((arr.size,))
if flat.size and not bool(_native('reduce_stat')(_addr_ro(flat), _NATIVE_CODE[flat.dtype], flat.size,
_REDUCE_INTEGRAL)):
return None
return encode_labels(y if arr is None else arr.reshape((arr.size,)))
def _native_default_folds(y, n_splits, classifier):
n = len(y)
if is_bool(n_splits) or not isinstance(n_splits, numbers.Integral) or n_splits < 2:
raise ValueError('cv must specify at least two folds')
if n_splits > n:
raise ValueError('cv cannot exceed the number of samples')
n_splits = int(n_splits)
codes = None
classes = ()
if classifier:
got = _discrete_codes(y)
if got is not None:
classes, codes = got
from ._buffer import _output_store
fold_store = _output_store('i', n)
fold_counts = _output_store('q', n_splits)
class_counts = _output_store('q', max(len(classes), 1))
_native('fold_ids')(0 if codes is None else _addr_ro(codes), n, len(classes), n_splits,
class_counts.buffer_info()[0], fold_store.buffer_info()[0],
fold_counts.buffer_info()[0])
if codes is not None:
counts = [int(class_counts[i]) for i in range(len(classes))] # glue: k class counts read back for the fold count checks
if max(counts) < n_splits: # glue: k class counts read back for the fold count checks
raise ValueError('cv cannot exceed the number of members in every class')
if min(counts) < n_splits: # glue: k class counts read back for the fold count checks
warnings.warn('The least populated class has fewer members than cv folds',
UserWarning, stacklevel=4)
out = _select_folds(fold_store, fold_counts, n, n_splits)
if _sabotage_requested():
# THE DORMANT NEGATIVE CONTROL (a verification switch, never set in
# production): the row-to-fold assignment rotated by one over the
# fold-ordered ascending test rows, so each nonempty fold's smallest
# test row moves to the previous nonempty fold (the last fold takes
# fold 0's). n_splits row ids are moved in the fold table and the
# folds selected again; every fold keeps its size.
nonempty = [f for f in range(n_splits) if out[f][1].size] # glue: nonempty folds (n_splits sized)
firsts = [int(out[f][1][0]) for f in nonempty] # glue: each nonempty fold's smallest test row (n_splits sized)
for at, fold in enumerate(nonempty): # glue: moves one row id per nonempty fold (n_splits sized)
fold_store[firsts[(at + 1) % len(nonempty)]] = fold
out = _select_folds(fold_store, fold_counts, n, n_splits)
return out
def _select_folds(fold_store, fold_counts, n, n_splits):
"""Each fold's (train, test) int64 rows from the int32 fold table, by the
core helper `select_fold_i64`."""
select = _native('select_fold_i64')
out = []
for fold in range(n_splits): # glue: one native fold selection per fold (n_splits sized)
size = int(fold_counts[fold])
test = empty((size,), '<i8')
train = empty((n - size,), '<i8')
scratch = empty((n,), '<i8')
got = int(select(fold_store.buffer_info()[0], n, fold, _addr(test) if size else _addr(scratch),
_addr(train) if n - size else _addr(scratch)))
if got != size:
raise RuntimeError('mojolearn: select_fold_i64 disagrees with fold_ids')
out.append((train, test))
return out
def _folds(cv, estimator, X, y, groups):
if cv is None or isinstance(cv, numbers.Integral):
if groups is not None:
# Refused, not warned and ignored (the claim-surface census,
# 2026-09-14): the default unshuffled folds never read groups,
# and a caller who passed them asked for grouped folds. Pass a
# splitter with `.split(X, y, groups)` to get them.
raise ValueError(
'mojolearn cross_val_score: groups is read only by a splitter '
'passed as cv; the default unshuffled folds would ignore it, '
'so it is refused (pass cv=<splitter with .split(X, y, groups)>)'
)
return _default_fold_arrays(y, 5 if cv is None else cv, _classifier(estimator))
if callable(getattr(cv, 'split', None)):
return cv.split(X, y, groups)
if isinstance(cv, str):
raise ValueError('cv must be an integer, splitter or iterable of index pairs')
try:
return iter(cv)
except TypeError:
raise ValueError('cv must be an integer, splitter or iterable of index pairs') from None
#: The descriptor `split_descriptor` returns. Versioned because a reader who
#: is handed one has to know what was and was not covered by its digest.
SPLIT_DESCRIPTOR_SCHEMA = 'mojolearn.split_descriptor.v1'
def _canonical(value):
return json.dumps(value, sort_keys=True, separators=(',', ':'),
ensure_ascii=True, allow_nan=False).encode('ascii')
def _digest(*chunks):
"""sha256 over length-prefixed chunks, so no two field layouts collide."""
m = hashlib.sha256()
for chunk in chunks: # glue: length-prefixed sha256 over a few descriptor chunks
m.update(len(chunk).to_bytes(8, 'little'))
m.update(chunk)
return m.hexdigest()
def _order_digest(value, name):
"""The VALUES IN ARRIVAL ORDER, with their dtype and shape.
This is the only field of the descriptor that moves when rows are
permuted and nothing else changes, so it is the field that carries the
claim. A buffer array hashes its C-order bytes; a Python label list
hashes its canonical JSON, because label objects have no dtype.
"""
if isinstance(value, list):
return _digest(b'list', _canonical(value))
array = _materialize(value, name)[0]._as_c()
return _digest(b'array', array.dtype.encode('ascii'),
_canonical(list(array.shape)), array.tobytes())
# Maintainer notes (moved from the docstring):
# LINK 3 OF THE REPRODUCIBLE PIPELINE. Everything else mojolearn promises is
# pinned by something mojolearn holds: the kernels by the numeric mode, the
# artifact by its file hash, the byte-LM corpus by the caller's
# `data_schedule`. The order the rows arrive in is not, and cannot be:
# mojolearn does not fetch, sort or reorder a caller's data. What is inside
# the boundary is recording it; before this function a cross-validation run
# recorded nothing, so the same code, config and rows in a different order
# produced different scores with no artifact that showed why.
# The trap: hashing the FOLD ASSIGNMENT looks like it pins the split and does
# not. `_default_folds` never reads X, so a permutation that keeps the label
# sequence keeps every fold index byte identical (measured: 2048 rows rotated
# within their class, zero of four fold-index hashes moved, four of four
# fold-content hashes moved), and the KFold branch keeps its indices under ANY
# permutation because its folds are blocks of positions.
# `fold_assignment_sha256` is a readable summary; `X_sha256` and `y_sha256`
# carry the claim.
# `estimator` is required (not defaulted) when cv is None or an integer:
# `_classifier(None)` is False, which would silently describe a classifier's
# stratified folds as plain KFold ones.
[docs]
def split_descriptor(X, y, *, estimator=None, cv=None, groups=None):
"""Describe a cross-validation split so a run can be reproduced.
The scores from ``cross_val_score`` depend on the order of the rows in
``X`` and ``y``: the default folds are unshuffled, so the fold a row lands
in is decided by its position. mojolearn never reorders your data, so it
cannot tell two orderings of the same rows apart. Record this descriptor
beside the scores; it hashes the data in arrival order and the fold
assignment, so a reader can check that a rerun used the same split.
The descriptor covers the split only. It does not hash the scores, the
estimator or the numeric mode.
Parameters
----------
X : array-like of shape (n_samples, n_features)
Dense, nonempty 2-D training data, in the order passed to
``cross_val_score``.
y : array-like of shape (n_samples,)
Targets, in the same order. A buffer array or a list of labels.
estimator : estimator, default None
The estimator given to ``cross_val_score``. Required when ``cv`` is
None or an integer, because the default folds are stratified for a
classifier and plain ``KFold`` otherwise; omitting it then raises
``ValueError``.
cv : int, splitter or iterable of (train, test), default None
The ``cv`` given to ``cross_val_score`` (None means five folds).
groups : array-like of shape (n_samples,), default None
Group labels, when the splitter reads them.
Returns
-------
descriptor : dict
A JSON-serializable dict with keys ``schema``, ``n_rows``,
``n_features``, ``X_sha256``, ``y_sha256``, ``groups_sha256``,
``fold_assignment_sha256``, ``n_folds``, ``fold_sizes`` (one
``[n_train, n_test]`` pair per fold) and ``sha256``, a hash over the
canonical encoding of every other field. ``X_sha256`` and
``y_sha256`` change when the rows are reordered, even when the fold
indices do not.
Raises
------
ValueError
If ``estimator`` is missing for an integer or default ``cv``, if X is
not a nonempty 2-D array, if y does not match X's rows, or if ``cv``
produces no folds.
Examples
--------
>>> import numpy as np, mojolearn
>>> from mojolearn.model_selection import cross_val_score, split_descriptor
>>> rng = np.random.default_rng(0)
>>> X = rng.random((1000, 8), dtype=np.float32)
>>> y = rng.random(1000, dtype=np.float32)
>>> model = mojolearn.Ridge(alpha=1.0)
>>> scores = cross_val_score(model, X, y)
>>> record = split_descriptor(X, y, estimator=model)
"""
if (cv is None or (not is_bool(cv) and isinstance(cv, numbers.Integral))) and estimator is None:
raise ValueError(
'split_descriptor: the default folds are stratified for a classifier '
'and plain KFold otherwise, so estimator is required when cv is None '
'or an integer (pass the estimator cross_val_score was given)')
X = _materialize(X, 'X')[0]
if X.ndim != 2 or not X.shape[0] or not X.shape[1]:
raise ValueError('X must be a nonempty dense 2-D buffer array')
try:
y = _materialize(y, 'y')[0]
except TypeError:
y = flatten_labels(y)
if len(y) != len(X):
raise ValueError('y must be a 1-D buffer array matching X rows')
folds = []
for train, test in _folds(cv, estimator, X, y, groups): # glue: one validated index pair per fold (n_splits sized)
# ``_indices`` has already normalized both sides to signed Int64, and
# Array.tolist() returns ordinary Python ints. Re-wrapping every row
# with ``int`` duplicated millions of Python calls when recording a
# large cross-validation split without changing the canonical JSON.
folds.append((_indices(train, len(X), 'train').tolist(),
_indices(test, len(X), 'test').tolist()))
if not folds:
raise ValueError('cv must produce at least one fold')
descriptor = {
'schema': SPLIT_DESCRIPTOR_SCHEMA,
'n_rows': len(X),
'n_features': X.shape[1],
# THE ROW ORDER. The two fields a permutation moves.
'X_sha256': _order_digest(X, 'X'),
'y_sha256': _order_digest(y, 'y'),
'groups_sha256': None if groups is None else _order_digest(groups, 'groups'),
# A summary, NOT the pin; see the trap in this function's docstring.
'fold_assignment_sha256': _digest(b'folds', _canonical(folds)),
'n_folds': len(folds),
'fold_sizes': [[len(train), len(test)] for train, test in folds], # glue: two size integers per fold (n_splits sized)
}
descriptor['sha256'] = hashlib.sha256(_canonical(descriptor)).hexdigest()
return descriptor
[docs]
def cross_val_score(estimator, X, y, *, cv=None, scoring=None, groups=None,
n_jobs=1, error_score='raise'):
"""Evaluate an estimator by cross-validation and return one score per fold.
For each fold, a fresh clone of ``estimator`` is fit on the training rows
and scored on the held-out rows. Folds run one after another. Every fold
is checked before the first fit: each side must be nonempty, have no
repeated rows, and not overlap the other side. A row may be held out in
more than one fold.
The default folds are unshuffled, so the scores depend on the order of
the rows in X and y. Record ``split_descriptor(X, y, estimator=estimator,
cv=cv)`` beside the scores if the run must be reproducible.
Parameters
----------
estimator : estimator
An unfitted estimator with ``get_params``, ``fit`` and (when
``scoring`` is None) ``score``. A scikit-learn pipeline works too; put
unfitted transforms inside it so they are fit on each training fold.
X : array-like of shape (n_samples, n_features)
Dense 2-D training data. Its dtype is kept; the estimator checks the
dtypes it supports. Sparse input is not supported.
y : array-like of shape (n_samples,)
1-D targets matching X's rows.
cv : int, splitter or iterable of (train, test), default None
None means five folds. An integer gives that many unshuffled folds,
stratified by class for a classifier with discrete labels, plain
contiguous folds otherwise. A splitter (any object with
``split(X, y, groups)``) or an iterable of integer index pairs is
used as given.
scoring : str, callable or None, default None
None calls the fitted estimator's ``score``. A string is one of
``get_scorer_names()``. A callable is called as
``scoring(estimator, X_test, y_test)`` and must return a real
scalar. Loss scorers are negated (``'neg_mean_squared_error'``), so
higher is always better.
groups : array-like of shape (n_samples,), default None
Group labels for a splitter that reads them. Passing ``groups`` with
an integer or default ``cv`` raises ``ValueError``, because the
default folds would ignore them.
n_jobs : int, default 1
Only ``1`` is supported; other values raise ``NotImplementedError``.
error_score : 'raise', default 'raise'
Only ``'raise'`` is supported: an error in a fit or score propagates.
Returns
-------
scores : Array of shape (n_splits,), float64
The score of each fold, in fold order, with no aggregation.
Notes
-----
Fit parameters, sample weights, ``eval_set``, precomputed kernels and
refitting are not supported. Set ``numeric_mode`` explicitly on every
pipeline step and custom metric when the results must be reproducible
across devices.
Examples
--------
>>> import numpy as np, mojolearn
>>> from mojolearn.model_selection import cross_val_score
>>> rng = np.random.default_rng(0)
>>> X = rng.random((1000, 8), dtype=np.float32)
>>> y = rng.random(1000, dtype=np.float32)
>>> scores = cross_val_score(mojolearn.Ridge(alpha=1.0), X, y, cv=5)
"""
# Behavioral reference: sklearn 1.8.0 model_selection/_validation.py,
# cross_validate (clone per fold), cross_val_score and _fit_and_score
# (training slice -> fit -> heldout score, lines 540-690 and 820-865).
# CV-1: bounded serial/dense/no metadata; errors propagate. CV-2: validate
# all index pairs before fitting, additionally refusing overlap/duplicates.
if is_bool(n_jobs) or not isinstance(n_jobs, numbers.Integral) or n_jobs != 1:
raise NotImplementedError('cross_val_score supports n_jobs=1 only')
if isinstance(scoring, str):
scoring = get_scorer(scoring)
X, y, folds = _prepare_folds(estimator, X, y, cv, scoring, groups, error_score)
# CV_FAST_SLICE (FAST + Apple build of the resample binding, default ON
# since 2026-10-04, rollback -D MOJOLEARN_CV_FAST_SLICE_OFF;
# resample/estimator.mojo CV_FAST_SLICE): the native default
# folds of a regressor are contiguous ascending row blocks, so each
# fold's test rows are a zero-copy view and its training rows two
# memcpys (or a view) instead of four `_take_rows` byte gathers. Same
# rows in the same order.
ranges = None
if _cv_fast_on(_CV_SLICE_BIT) and _native_default_cv(cv, groups):
ranges = _kfold_ranges(X, y, folds)
range_at = iter(ranges or ())
scores = []
# X, y and the fold indices resident on the device for every fold; the
# CV_FAST_SLICE contiguous-range views above (FAST + Apple) when they apply
with _FoldRows(X, y, folds, device_rows=_device_rows_ok(estimator, scoring)) as rows:
for i in range(len(folds)): # glue: one fit per fold
fitted = _clone(estimator)
try:
if ranges is not None:
a, b = next(range_at)
fold_rows = (_rows_outside(X, a, b), _rows_outside(y, a, b),
_row_range_view(X, a, b), _row_range_view(y, a, b))
else:
fold_rows = rows.take(i)
scores.append(_fit_score_fold(fitted, *fold_rows, scoring))
finally:
# Release each fold before constructing the next estimator. Native
# contexts retain their own cleanup contract; no forced GPU reset.
del fitted
return Array.from_list(scores, "<f8")
def _prepare_folds(estimator, X, y, cv, scoring, groups, error_score):
"""Validate every fold before serial or GPU-worker fitting begins."""
if not isinstance(error_score, str) or error_score != 'raise':
raise NotImplementedError("cross_val_score supports error_score='raise' only")
if isinstance(scoring, str):
scoring = get_scorer(scoring) # lane/metrics: scikit-learn's scorer names
if scoring is not None and not callable(scoring):
raise TypeError('scoring must be None, a scorer name or a callable')
X = _materialize(X, "X")[0]
if getattr(y, "ndim", 1) != 1:
raise ValueError("y must be 1-D and match X rows")
try:
y = _materialize(y, "y")[0]
except TypeError:
y = flatten_labels(y)
if X.ndim != 2 or not X.shape[0] or not X.shape[1]:
raise ValueError('X must be a nonempty dense 2-D buffer array')
if len(y) != len(X):
raise ValueError('y must be a 1-D buffer array matching X rows')
if groups is not None:
if getattr(groups, "ndim", 1) != 1 or len(groups) != len(X):
raise ValueError('groups must be 1-D and match X rows')
# CV_FAST_TRUST_FOLDS (FAST + Apple build of the resample binding,
# default ON since 2026-10-04, rollback -D MOJOLEARN_CV_FAST_TRUST_FOLDS_OFF;
# resample/estimator.mojo CV_FAST_TRUST_FOLDS): the
# native default folds (`fold_ids` + `select_fold_i64`) are a partition
# of the rows by construction, so `_indices`' range/duplicate pass and
# `_overlap` on every fold's two int64 arrays are skipped. A splitter,
# groups, a bool cv or the sabotage control: validated as before.
if _cv_fast_on(_CV_TRUST_FOLDS_BIT) and _native_default_cv(cv, groups):
# n_splits >= 2 is checked inside, so this is never empty.
return X, y, _native_default_folds(y, 5 if cv is None else cv, _classifier(estimator))
folds = []
for train, test in _folds(cv, estimator, X, y, groups): # glue: one validated index pair per fold (n_splits sized)
train = _indices(train, len(X), 'train')
test = _indices(test, len(X), 'test')
if _overlap(train, test, len(X)):
raise ValueError('train and test indices overlap')
folds.append((train, test))
if not folds:
raise ValueError('cv must produce at least one fold')
return X, y, folds
# ---------------------------------------------------------------------------
# FAST + Apple cross-val candidates (recovered from
# lane/apple-fast-resample@50b96e795, lane apple-fast-rec-resample
# 2026-10-04). Switched by the resample binding's `resample_fast_defines`
# mask (build-time defines), never by the environment. Off on every other
# tier and build.
# ---------------------------------------------------------------------------
_CV_SLICE_BIT = 32 # CV_FAST_SLICE (default; _OFF rolls back)
_CV_TRUST_FOLDS_BIT = 64 # CV_FAST_TRUST_FOLDS (default; _OFF rolls back)
def _cv_fast_on(bit):
from . import resample as _resample
return bool(_resample.fast_defines() & bit)
def _native_default_cv(cv, groups):
"""Whether `_folds` takes `_native_default_folds` for this cv: the
default (None) or an integer, no groups, no sabotage control."""
return (groups is None and (cv is None or isinstance(cv, numbers.Integral))
and not is_bool(cv) and not _sabotage_requested())
def _row_range_view(arr, a, b):
"""Rows [a, b) of a C-order Array as a READ-ONLY view over the same
memory (no copy; `arr` stays alive through the view's `_base`). Read
only so an estimator that asks for a writable address is refused rather
than writing into the caller's X or y (`_take_rows` handed out copies)."""
if a == 0 and b == arr.shape[0]:
return arr
per = arr.size // arr.shape[0]
view = Array._view_of(arr, arr.shape, 'C')
view._mv = arr._mv[a * per:b * per]
view._addr = arr._addr + a * per * arr.itemsize
view._readonly = True
view._set_meta((b - a,) + tuple(arr.shape[1:]), arr.dtype, 'C')
return view
def _rows_outside(arr, a, b):
"""Rows [0, a) then [b, n) of a C-order Array: a view when one side is
empty, else one new Array filled by two memcpys."""
n = arr.shape[0]
if a == 0:
return _row_range_view(arr, b, n)
if b == n:
return _row_range_view(arr, 0, a)
per = arr.size // n
out = empty((n - (b - a),) + tuple(arr.shape[1:]), arr.dtype)
out._mv[:a * per] = arr._mv[:a * per]
out._mv[a * per:] = arr._mv[b * per:]
return out
def _kfold_ranges(X, y, folds):
"""Each fold's test block `(a, b)`, or None unless EVERY fold is a
contiguous block. Called only on `_native_default_folds`' folds, whose
sides are ascending, duplicate free and a partition of the rows
(`select_fold_i64` scans the rows in order), so test == [a, b) exactly
when its size is b - a with both ends a and b - 1, and train is then
the complement in ascending order."""
n = len(X)
if not isinstance(y, Array) or X.order != 'C' or y.order != 'C' or n < 2:
return None
ranges = []
for train, test in folds: # glue: one O(1) endpoint check per fold
if not test.size or not train.size:
return None
a = int(test._mv[0])
b = int(test._mv[test.size - 1]) + 1
if b - a != test.size or train.size != n - test.size:
return None
ranges.append((a, b))
return ranges
def _fit_score_fold(fitted, X_train, y_train, X_test, y_test, scoring):
"""Shared serial/worker operation; the caller supplies a fresh clone."""
fitted.fit(X_train, y_train)
score = fitted.score(X_test, y_test) if scoring is None else scoring(fitted, X_test, y_test)
if not isinstance(score, numbers.Real):
raise TypeError('scoring must return a real scalar')
return float(score)
# ===========================================================================
# THE METRICS LANE'S SPLITTERS, SEARCH AND VALIDATION HELPERS
# (lane/metrics, 2026-09-27; scikit-learn 1.9 model_selection/_split.py,
# _validation.py, _search.py). Index bookkeeping is exact Python integer
# work; every random draw is a device permutation keyed by the counter RNG
# (x_metrics/split.mojo, DEVIATION 6108), so a seeded split is the same on
# every column. The splits are NOT numpy's (their shuffles are Mersenne
# Twister draws); an int random_state always gives the same splits here.
# ===========================================================================
__all__ += [
'KFold', 'StratifiedKFold', 'GroupKFold', 'StratifiedGroupKFold', 'TimeSeriesSplit',
'ShuffleSplit', 'StratifiedShuffleSplit', 'GroupShuffleSplit', 'LeaveOneOut', 'LeavePOut',
'LeaveOneGroupOut', 'LeavePGroupsOut', 'RepeatedKFold', 'RepeatedStratifiedKFold',
'PredefinedSplit', 'train_test_split', 'check_cv', 'cross_validate', 'cross_val_predict',
'ParameterGrid', 'ParameterSampler', 'GridSearchCV', 'RandomizedSearchCV',
'validation_curve', 'learning_curve', 'permutation_test_score', 'get_scorer', 'make_scorer',
'get_scorer_names',
]
def _comb(n, k):
"""n choose k in exact integers."""
if k < 0 or k > n:
return 0
out = 1
for i in range(1, k + 1): # glue: n choose k of argument counts, combinatorics only
out = out * (n - k + i) // i
return out
def _n_samples(X):
if hasattr(X, 'shape') and len(X.shape):
return int(X.shape[0])
return len(X)
def _labels_list(y, name='y'):
if y is None:
raise ValueError(f"The '{name}' parameter should not be None.")
return flatten_labels(y)
def _encode_first_seen(values):
# the first-seen order by dict.fromkeys, the codes by a C map (the same
# dict equality as setdefault; lane metrics-apple)
index = {v: i for i, v in enumerate(dict.fromkeys(values))} # cpu-route: first-seen dict encoding of label objects (str/mixed) the native encoder refuses [py-data-loop]
return list(map(index.__getitem__, values)), len(index)
def _first_seen_native(y):
"""`_encode_first_seen` and the class counts with no per-row Python (lane
apple-fast-py2mojo-core): (int32 codes `Array` in first-seen order, k,
counts), or None (y None, fewer than `_NATIVE_MIN_ROWS` rows, labels the
native encoder refuses: str, mixed or NaN labels keep the dict route).
The order rule's codes and counts come from `_GroupCodes` (the native
encoder and `fold_ids`), each class's first row from the x_metrics
binding's `first_rows`, and the codes are renumbered by first row
through the core gather: the same classes (both group by numeric
equality) in the same first-seen order."""
if y is None:
return None
try:
n = _n_samples(y)
gc = _GroupCodes.get(y, n, 'y')
except (TypeError, ValueError, OverflowError):
return None
if gc is None or gc.m < 1:
return None
first = array.array('q', bytes(8 * gc.m))
_expansion_metrics_binding().x_metrics_first_rows(_addr_ro(gc.codes), n, gc.m, first.buffer_info()[0]) # cpu-route: first-seen class order for label encoding at splitter entry
order = sorted(range(gc.m), key=first.__getitem__) # glue: class order by first row over the k classes
table = [0] * gc.m
for rank, c in enumerate(order): # glue: class order by first row over the k classes
table[c] = rank
words = gc.mapped(table)
return Array._owned(words, (n,), '<i4', 'C'), gc.m, [gc.counts[c] for c in order] # glue: class order by first row over the k classes
def _as_index(values):
if isinstance(values, range) and values.step == 1:
# a contiguous row range, written by the base binding (lane cgr4-py-compute)
return _arange_rows(values.start, max(len(values), 0))
store = None
if isinstance(values, list):
# ints (and bools) go in as they are, in C; anything array('q')
# refuses keeps the int() path (lane metrics-apple)
try:
store = array.array('q', values)
except TypeError:
store = None
if store is None:
store = array.array('q', map(int, values))
return Array._owned(store, (len(store),), '<i8', 'C')
def _arange_rows(start, n):
"""Int64 rows start, start + 1, ..., start + n - 1 (`arange_i64`)."""
out = empty((n,), '<i8')
if n:
_native('arange_i64')(_addr(out), int(start), int(n))
return out
def _zeros_i64(n):
from ._buffer import zeros
return zeros((n,), '<i8')
def _split_mask(mask, n):
"""(train, test): the ascending rows whose mask byte is 0 / nonzero, by
`count_mask_u8` and `select_mask_u8_i64` (lane cgr4-py-compute)."""
m = mask if isinstance(mask, (bytearray, array.array)) else bytearray(mask)
maddr = m.buffer_info()[0] if isinstance(m, array.array) else _bytes_addr(m)
n_test = int(_native('count_mask_u8')(maddr, n))
test = empty((n_test,), '<i8')
train = empty((n - n_test,), '<i8')
_native('select_mask_u8_i64')(maddr, n, _addr(test) if n_test else 0, _addr(train) if n - n_test else 0)
return train, test
def _bytes_addr(buf):
import ctypes
return ctypes.addressof((ctypes.c_char * len(buf)).from_buffer(buf))
def _split_indices(test, n):
"""(train, test) from a test row list: the mask by `mask_from_indices_u8`
(indices checked in range there), then `_split_mask`."""
if isinstance(test, Array) and test.dtype == '<i8':
k, iaddr = test.size, _addr_ro(test)
else:
idx = test if isinstance(test, array.array) and test.typecode == 'q' else array.array('q', test)
k, iaddr = len(idx), idx.buffer_info()[0]
mask = array.array('B', bytes(n))
_native('mask_from_indices_u8')(iaddr if k else 0, k, n, mask.buffer_info()[0])
return _split_mask(mask, n)
# ---------------------------------------------------------------- native rows
# lane/py-misc-msel (2026-09-28): the group splitters, PredefinedSplit,
# unshuffled KFold and StratifiedShuffleSplit build their rows with the core
# helpers (`encode_labels`, `fold_ids` for the per-group counts, `gather_i32`
# for a per-group table, `select_fold_i64` for each split's ascending test
# and train rows, `gather_i64` for permuted row lists) instead of one Python
# iteration per row per split. Integer bookkeeping only: the same rows in the
# same order, the same draws in the same order. Every `_test_folds` / Python
# comprehension below stays as the DEFINITION and the route taken under
# MOJOLEARN_HOTPATH=python, below _NATIVE_MIN_ROWS rows, under the fold-order
# sabotage control, or when the binary lacks a helper.
#: The before arm of the lane's timing and equality job: False (or the
#: environment's MOJOLEARN_MSEL_BEFORE=1) sends every route above back to its
#: definition. Never set in production.
_MSEL_NATIVE = True
def _msel_native():
return True # lane cgr4-py-compute: the MOJOLEARN_MSEL_BEFORE route switch is deleted
class _GroupCodes:
"""Groups (or labels) under the order rule as int32 codes with their
per-code row counts, and the helpers that turn a per-code table into
split rows. `get` returns None whenever the native route cannot answer."""
def __init__(self, codes, m, counts, select, gather):
self.codes, self.m, self.counts = codes, m, counts
self.n = codes.size
self._select, self._gather = select, gather
self._scratch = None
@classmethod
def get(cls, values, n, name):
if values is None:
_labels_list(values, name) # raises the definition's error
# lane py-runtime-b: the only route (the Python `_test_folds`
# definitions it handed back to are deleted); `_native` raises by
# name when the binary lacks a helper
fold_ids = _native('fold_ids')
select = _native('select_fold_i64')
gather = _native('gather_i32')
from ._labels import encode_labels
classes, codes = encode_labels(values)
if codes.ndim != 1 or codes.size != n:
raise ValueError(f'{name} has {codes.size} labels for {n} samples')
if codes.dtype != '<i4':
codes = codes.astype('<i4')
m = len(classes)
counts = [0] * m
if m:
from ._buffer import _output_store
class_counts = _output_store('q', m)
fold_store = _output_store('i', n)
fold_counts = _output_store('q', 2)
fold_ids(_addr_ro(codes), n, m, 2, class_counts.buffer_info()[0], fold_store.buffer_info()[0],
fold_counts.buffer_info()[0])
counts = list(class_counts)
out = cls(codes, m, counts, select, gather)
out.classes = classes
return out
def mapped(self, table):
"""Each row's `table[code]` as int32 words (kept alive by the caller)."""
from ._buffer import _output_store
tbl = table if isinstance(table, array.array) and table.typecode == 'i' else array.array('i', table)
dst = _output_store('i', self.n)
self._gather(tbl.buffer_info()[0], len(tbl), _addr_ro(self.codes), self.n, dst.buffer_info()[0])
return dst
def rows(self, words, want, size):
"""(train, test): the ascending rows whose word is `want` (test) and
every other row (train), from int32 `words` (codes or `mapped`)."""
addr = words.buffer_info()[0] if hasattr(words, 'buffer_info') else _addr_ro(words)
test = empty((size,), '<i8')
train = empty((self.n - size,), '<i8')
got = int(self._select(addr, self.n, int(want), _addr(test), _addr(train)))
if got != size:
raise RuntimeError('mojolearn: select_fold_i64 disagrees with the group counts')
return train, test
def only(self, words, want, size):
"""The ascending rows whose word is `want` (the rest discarded)."""
from ._buffer import _output_store
if self._scratch is None:
self._scratch = _output_store('q', self.n)
addr = words.buffer_info()[0] if hasattr(words, 'buffer_info') else _addr_ro(words)
out = empty((size,), '<i8')
got = int(self._select(addr, self.n, int(want), _addr(out), self._scratch.buffer_info()[0]))
if got != size:
raise RuntimeError('mojolearn: select_fold_i64 disagrees with the group counts')
return out
def split_by_fold(self, to_fold, k, sizes):
"""[(train, test)] per fold f < k from each group's fold `to_fold[g]`
(int32 words) and each fold's row count `sizes[f]`."""
words = self.mapped(to_fold)
return [self.rows(words, f, int(sizes[f])) for f in range(k)] # glue: one native row split per fold (k folds)
_LITTLE_ENDIAN = __import__('sys').byteorder == 'little'
#: bytes.translate table flipping a 0/1 mask
_FLIP = bytes([1, 0]) + bytes(254)
def _row_stds(rows, means):
"""The population standard deviation of each equal-length row of float
scores about its mean, in Mojo (the core helper `row_stds_f64`, lane
py-runtime round 3; it was a Python sqrt(fsum((v - m)^2) / k) per row)."""
m = len(rows)
if not m:
return []
k = len(rows[0])
flat = array.array('d', itertools.chain.from_iterable(rows)) # glue: score rows packed as one float64 table
mu = array.array('d', means)
out = array.array('d', bytes(8 * m))
_native('row_stds_f64')(flat.buffer_info()[0], m, k, mu.buffer_info()[0], out.buffer_info()[0])
return out.tolist()
def _row_means(rows):
"""`fsum(row) / len(row)` of each equal-length row of float scores, by
the core helper `row_means_f64` (lane py-runtime-b: the search results'
mean test and train scores and the permutation test's mean score; the
same exact sum, rounded once, then one division)."""
m = len(rows)
k = len(rows[0]) if m else 0
flat = array.array('d', itertools.chain.from_iterable(rows)) # glue: score rows packed as one float64 table
out = array.array('d', bytes(8 * max(m, 1)))
if m and k:
if int(_native('row_means_f64')(flat.buffer_info()[0], m, k, out.buffer_info()[0])):
raise ValueError('-inf + inf in fsum')
return out.tolist()[:m]
def _rng(random_state):
rng = CounterRng(random_state)
assert rng.binding == _SPLIT_BINDING
return rng
class _Splitter:
"""sklearn's BaseCrossValidator protocol: split(X, y, groups) yields
(train, test) Int64 index Arrays; get_n_splits."""
def __repr__(self):
params = ', '.join(f'{k}={v!r}' for k, v in sorted(self.get_params().items())) # glue: repr and params over the splitter attributes
return f'{type(self).__name__}({params})'
def get_params(self, deep=True):
"""Return the splitter's constructor parameters as a dict."""
return {k: v for k, v in vars(self).items() if not k.startswith('_')} # glue: repr and params over the splitter attributes
def split(self, X, y=None, groups=None):
"""Yield ``(train, test)`` int64 row-index arrays, one pair per split."""
# every splitter builds its rows natively in its own `split` (lane
# py-runtime-b deleted the Python `_test_folds` definitions)
raise NotImplementedError
def _check_splits(n_splits):
if is_bool(n_splits) or not isinstance(n_splits, numbers.Integral):
raise ValueError(f'The number of folds must be of Integral type. {n_splits!r} was passed.')
if n_splits <= 1:
raise ValueError(f'k-fold cross-validation requires at least one train/test split by setting '
f'n_splits=2 or more, got n_splits={n_splits}.')
return int(n_splits)
class _KFoldBase(_Splitter):
def __init__(self, n_splits=5, *, shuffle=False, random_state=None):
self.n_splits = _check_splits(n_splits)
if not is_bool(shuffle):
raise TypeError(f'shuffle must be True or False; got {shuffle}')
if not shuffle and random_state is not None:
raise ValueError('Setting a random_state has no effect since shuffle is False. You should '
'leave random_state to its default (None), or set shuffle=True.')
self.shuffle = shuffle
self.random_state = random_state
def get_n_splits(self, X=None, y=None, groups=None):
"""Return the number of splits, ``n_splits``."""
return self.n_splits
[docs]
class KFold(_KFoldBase):
"""K-fold cross-validator.
Splits the rows into ``n_splits`` contiguous folds of the (optionally
shuffled) row order; each fold is the test set once. The first
``n_samples % n_splits`` folds are one row larger. Follows scikit-learn's
``KFold``; shuffling uses mojolearn's counter-based random generator, so
the shuffled folds differ from scikit-learn's for the same seed.
Parameters
----------
n_splits : int, default 5
Number of folds, at least 2.
shuffle : bool, default False
Shuffle the rows before splitting into folds.
random_state : int or None, default None
Seed for the shuffle. Setting it with ``shuffle=False`` raises
``ValueError``, as in scikit-learn.
Examples
--------
>>> import numpy as np
>>> from mojolearn.model_selection import KFold
>>> X = np.random.default_rng(0).random((1000, 8), dtype=np.float32)
>>> for train, test in KFold(n_splits=5, shuffle=True, random_state=0).split(X):
... pass
"""
[docs]
def split(self, X, y=None, groups=None):
"""Yield ``(train, test)`` int64 row-index arrays for each fold.
Raises ``ValueError`` when ``n_splits`` exceeds the number of rows.
"""
n = self._check_n(X)
if self.shuffle:
# the permutation and every fold's (train, test) rows in one
# device program, the rows as Int64 words (lane metrics-apple2)
rng = _rng(self.random_state)
got = fold_rows(n, self.n_splits, rng=rng)
if got is not None:
yield from got
return
# past the device table's bound: the same draw as Int64 rows, each
# fold's block masked and selected in Mojo (lane cgr4-py-compute)
perm = rng.permutation_rows([n])[0]
whole = Array._owned(perm, (n,), '<i8', 'C')
start = 0
for fold in range(self.n_splits): # glue: one native row split per fold (n_splits sized)
size = n // self.n_splits + (fold < n % self.n_splits)
yield _split_indices(whole[start:start + size], n)
start += size
return
# contiguous blocks by the core helpers (fold_ids with no codes,
# select_fold_i64)
yield from _native_default_folds(range(n), self.n_splits, False)
def _check_n(self, X):
n = _n_samples(X)
if self.n_splits > n:
raise ValueError(f'Cannot have number of splits n_splits={self.n_splits} greater than the '
f'number of samples: n_samples={n}.')
return n
[docs]
class StratifiedKFold(_KFoldBase):
"""Stratified k-fold cross-validator.
Each fold keeps roughly the class proportions of ``y``. Follows
scikit-learn's ``StratifiedKFold``: classes are taken in order of first
appearance and allocated round-robin over the class-sorted labels. With
``shuffle=True`` each class's fold assignment is permuted by mojolearn's
counter-based random generator, so shuffled folds differ from
scikit-learn's for the same seed.
Parameters
----------
n_splits : int, default 5
Number of folds, at least 2.
shuffle : bool, default False
Shuffle each class's rows before assigning folds.
random_state : int or None, default None
Seed for the shuffle. Setting it with ``shuffle=False`` raises
``ValueError``.
Examples
--------
>>> import numpy as np
>>> from mojolearn.model_selection import StratifiedKFold
>>> rng = np.random.default_rng(0)
>>> X = rng.random((1000, 8), dtype=np.float32)
>>> y = rng.integers(0, 3, 1000).astype(np.int32)
>>> folds = list(StratifiedKFold(n_splits=5).split(X, y))
"""
[docs]
def split(self, X, y=None, groups=None):
"""Yield ``(train, test)`` int64 row-index arrays for each fold.
``y`` holds the class labels. Raises ``ValueError`` when every class
has fewer than ``n_splits`` members, and warns when the smallest
class does.
"""
enc, k, counts, alloc = self._fold_plan(y)
rng = _rng(self.random_state) if self.shuffle else None
if self.n_splits <= 256:
# every row's fold and every fold's (train, test) rows in one
# device program (strat_codes + fold_rows), the rows as Int64
# words (lane metrics-apple2)
got = stratified_fold_rows(enc, counts, alloc, self.n_splits, rng)
if got is not None:
yield from got
return
rng = _rng(self.random_state) if self.shuffle else None
# the same assignment by `strat_fold_assign_i32`, then each fold's
# rows by `select_fold_i64` (lane cgr4-py-compute: no Python row loop)
folds = self._fold_of_rows(enc, k, counts, alloc, rng)
n = enc.size
sizes = _zeros_i64(self.n_splits)
_native('bincount_i64')(_addr_ro(folds), 2, n, self.n_splits, _addr(sizes), 0)
select = _native('select_fold_i64')
for f, size in enumerate(sizes.tolist()): # glue: one native row split per fold (n_splits sized)
test, train = empty((size,), '<i8'), empty((n - size,), '<i8')
scratch = empty((n,), '<i8')
select(_addr_ro(folds), n, f, _addr(test) if size else _addr(scratch),
_addr(train) if n - size else _addr(scratch))
yield train, test
def _fold_plan(self, y):
"""(first-seen int32 codes Array, classes, class counts, alloc):
alloc[i][c] = class c's rows in fold i (sklearn's _make_test_folds).
The x_metrics first-rows route (`_first_seen_native`), else the
labels encoded by `encode_labels` and renumbered by first appearance
in Mojo (`first_seen_i32`): no per-row Python either way."""
fs = _first_seen_native(y)
if fs is not None:
enc, k, counts = fs
else:
from ._labels import encode_labels
classes, codes = encode_labels(y)
n = codes.size
enc = empty((n,), '<i4')
cnt = _zeros_i64(max(len(classes), 1))
k = int(_native('first_seen_i32')(_addr_ro(codes), n, max(len(classes), 1), _addr(enc), _addr(cnt)))
counts = cnt.tolist()[:k]
if not counts or max(counts) < self.n_splits: # glue: fold count checks over the k class counts
raise ValueError(f'n_splits={self.n_splits} cannot be greater than the number of members in '
'each class.')
if min(counts) < self.n_splits: # glue: fold count checks over the k class counts
warnings.warn(f'The least populated class in y has only {min(counts)} members, which is less '
f'than n_splits={self.n_splits}.', UserWarning, stacklevel=3)
# alloc[i][c] = the positions p of class c in sorted(enc) (the run
# [s, e)) with p % n_splits == i: an int64 (K, k) Array from the base
# binding's `strat_alloc_i64` (lane pyglue-numeric: a Python table)
K = self.n_splits
alloc = empty((K, k), '<i8')
cnt = array.array('q', counts)
_native('strat_alloc_i64')(cnt.buffer_info()[0], k, K, _addr(alloc)) # cpu-route: fold allocation plan over folds x classes, not rows
return enc, k, counts, alloc
def _fold_of_rows(self, enc, k, counts, alloc, rng):
"""Each row's test fold as an int32 Array (sklearn's
_make_test_folds; with `rng`, each class's fold sequence permuted by
its draw), by `strat_fold_assign_i32`."""
n, K = enc.size, self.n_splits
cnt = array.array('q', counts)
perms = None
if rng is not None:
perms = array.array('q')
for p in rng.permutation_rows(counts): # glue: concatenates the k per-class device draws
perms.frombytes(p.tobytes())
out = empty((n,), '<i4')
_native('strat_fold_assign_i32')(_addr_ro(enc), n, k, K, _addr_ro(alloc),
perms.buffer_info()[0] if perms is not None and len(perms) else 0,
cnt.buffer_info()[0], _addr(out))
return out
[docs]
class GroupKFold(_KFoldBase):
"""K-fold cross-validator that keeps each group in a single fold.
Follows scikit-learn's ``GroupKFold``. Unshuffled, groups are assigned
largest first, each to the fold with the fewest rows so far (the lowest
fold index on a tie). Shuffled, the groups are permuted and split into
``n_splits`` runs of nearly equal group count.
Parameters
----------
n_splits : int, default 5
Number of folds, at least 2 and at most the number of groups.
shuffle : bool, default False
Shuffle the groups before splitting.
random_state : int or None, default None
Seed for the shuffle (mojolearn's counter-based generator). Setting
it with ``shuffle=False`` raises ``ValueError``.
Examples
--------
>>> import numpy as np
>>> from mojolearn.model_selection import GroupKFold
>>> rng = np.random.default_rng(0)
>>> X = rng.random((1000, 8), dtype=np.float32)
>>> groups = rng.integers(0, 20, 1000)
>>> folds = list(GroupKFold(n_splits=5).split(X, groups=groups))
"""
[docs]
def split(self, X, y=None, groups=None):
"""Yield ``(train, test)`` int64 row-index arrays for each fold.
``groups`` is required. Raises ``ValueError`` when ``n_splits``
exceeds the number of distinct groups.
"""
n = _n_samples(X)
gc = _GroupCodes.get(groups, n, 'groups')
m = gc.m
if self.n_splits > m:
raise ValueError(f'Cannot have number of splits n_splits={self.n_splits} greater than the '
f'number of groups: {m}.')
# each group's fold and each fold's row count by the base binding's
# `group_fold_assign_i32` (the assignment `_test_folds` makes; lane
# pyglue-numeric: Python loops over the groups), gathered per row
perm = _rng(self.random_state).permutation_rows([m])[0] if self.shuffle else None
to_fold = array.array('i', bytes(4 * m))
sizes = array.array('q', bytes(8 * self.n_splits))
counts = array.array('q', gc.counts)
if perm is not None:
# shuffled: the permuted groups' runs on the device (lane
# cpu2-l4-modelsel); the unshuffled greedy below is sequential
# by definition (each group goes to the fold lightest after
# every larger group) and stays the binding's group-level loop
_native('msel_group_fold_perm_i32')(counts.buffer_info()[0], m, self.n_splits,
perm.buffer_info()[0], to_fold.buffer_info()[0],
sizes.buffer_info()[0])
else:
_native('group_fold_assign_i32')(counts.buffer_info()[0], m, self.n_splits, 0, # cpu-route: sequential greedy fold plan over groups, not rows
to_fold.buffer_info()[0], sizes.buffer_info()[0])
yield from gc.split_by_fold(to_fold, self.n_splits, sizes)
[docs]
class StratifiedGroupKFold(_KFoldBase):
"""Stratified k-fold cross-validator that keeps each group in a single fold.
Follows scikit-learn's ``StratifiedGroupKFold``: groups are taken in
descending order of the standard deviation of their class distribution
(or in a shuffled order), and each goes to the fold whose class
distribution it changes least.
Parameters
----------
n_splits : int, default 5
Number of folds, at least 2.
shuffle : bool, default False
Shuffle the group order instead of sorting it.
random_state : int or None, default None
Seed for the shuffle (mojolearn's counter-based generator). Setting
it with ``shuffle=False`` raises ``ValueError``.
Examples
--------
>>> import numpy as np
>>> from mojolearn.model_selection import StratifiedGroupKFold
>>> rng = np.random.default_rng(0)
>>> X = rng.random((1000, 8), dtype=np.float32)
>>> y = rng.integers(0, 2, 1000)
>>> groups = rng.integers(0, 50, 1000)
>>> folds = list(StratifiedGroupKFold(n_splits=5).split(X, y, groups))
"""
[docs]
def split(self, X, y=None, groups=None):
"""Yield ``(train, test)`` int64 row-index arrays for each fold.
``y`` and ``groups`` are required. Raises ``ValueError`` when
``n_splits`` is greater than the number of members of a class.
"""
n = _n_samples(X)
gc = _GroupCodes.get(groups, n, 'groups')
# the class codes in first-seen order (natively; the dict encoder for
# labels the native encoder refuses: glue over label objects), each
# group's fold and each fold's rows by the base binding's
# `strat_group_assign_i32` (lane pyglue-numeric: `_assign`'s Python
# loops over the rows and groups), gathered per row
fs = _first_seen_native(y)
if fs is not None:
yenc, k = fs[0], fs[1]
else:
codes, k = _encode_first_seen(_labels_list(y))
yenc = Array._owned(array.array('i', codes), (len(codes),), '<i4', 'C')
if yenc.size != n:
raise ValueError(f'y has {yenc.size} labels for {n} samples')
m = gc.m
perm = _rng(self.random_state).permutation_rows([m])[0] if self.shuffle else None
to_fold = array.array('i', bytes(4 * m))
sizes = array.array('q', bytes(8 * self.n_splits))
# lane cpu4-python: the group x class table over the rows is the
# device's 2-D bincount (`bincount2_i32`); only the group-level
# greedy plan (`strat_group_plan_i32`, sequential by definition like
# GroupKFold's) reads it
dist = array.array('q', bytes(8 * m * k))
_native('bincount2_i32')(_addr_ro(yenc), _addr_ro(gc.codes), [n, k, m], dist.buffer_info()[0])
bad = int(_native('strat_group_plan_i32')( # cpu-route: sequential greedy fold plan over groups, not rows
[dist.buffer_info()[0], perm.buffer_info()[0] if perm is not None else 0,
to_fold.buffer_info()[0], sizes.buffer_info()[0]], [k, m, self.n_splits]))
if bad:
raise ValueError(f'n_splits={self.n_splits} cannot be greater than the number of members in '
'each class.')
yield from gc.split_by_fold(to_fold, self.n_splits, sizes)
[docs]
class TimeSeriesSplit(_Splitter):
"""Time-series cross-validator with expanding training windows.
Follows scikit-learn's ``TimeSeriesSplit``. Split ``i`` tests on a block
of consecutive rows and trains on the rows before it, so a model never
trains on rows that come after its test block. There is no randomness.
Parameters
----------
n_splits : int, default 5
Number of splits, at least 2.
max_train_size : int or None, default None
Largest training window; older rows are dropped beyond it.
test_size : int or None, default None
Rows per test block. None means ``n_samples // (n_splits + 1)``.
gap : int, default 0
Rows left out between the end of each training window and its test
block.
Examples
--------
>>> import numpy as np
>>> from mojolearn.model_selection import TimeSeriesSplit
>>> X = np.random.default_rng(0).random((1000, 8), dtype=np.float32)
>>> folds = list(TimeSeriesSplit(n_splits=5, gap=10).split(X))
"""
def __init__(self, n_splits=5, *, max_train_size=None, test_size=None, gap=0):
self.n_splits = _check_splits(n_splits)
self.max_train_size = max_train_size
self.test_size = test_size
self.gap = gap
[docs]
def get_n_splits(self, X=None, y=None, groups=None):
"""Return the number of splits, ``n_splits``."""
return self.n_splits
[docs]
def split(self, X, y=None, groups=None):
"""Yield ``(train, test)`` int64 row-index arrays for each split.
Raises ``ValueError`` when there are too few rows for ``n_splits``,
``test_size`` and ``gap``.
"""
n = _n_samples(X)
folds = self.n_splits + 1
test_size = self.test_size if self.test_size is not None else n // folds
if folds > n:
raise ValueError(f'Cannot have number of folds={folds} greater than the number of samples={n}.')
if n - self.gap - test_size * self.n_splits <= 0:
raise ValueError(f'Too many splits={self.n_splits} for number of samples={n} with '
f'test_size={test_size} and gap={self.gap}.')
for start in range(n - self.n_splits * test_size, n, test_size): # glue: one index range pair per split (n_splits sized)
end = start - self.gap
lo = end - self.max_train_size if self.max_train_size and self.max_train_size < end else 0
yield _as_index(range(lo, end)), _as_index(range(start, start + test_size))
[docs]
class LeaveOneOut(_Splitter):
"""Leave-one-out cross-validator.
Each row is the test set once, with every other row as the training set.
Follows scikit-learn's ``LeaveOneOut``. It takes no parameters.
Examples
--------
>>> import numpy as np
>>> from mojolearn.model_selection import LeaveOneOut
>>> X = np.random.default_rng(0).random((20, 4), dtype=np.float32)
>>> folds = list(LeaveOneOut().split(X))
"""
[docs]
def get_n_splits(self, X=None, y=None, groups=None):
"""Return the number of rows of ``X`` (``X`` is required)."""
if X is None:
raise ValueError("The 'X' parameter should not be None.")
return _n_samples(X)
[docs]
def split(self, X, y=None, groups=None):
"""Yield ``(train, test)`` int64 row-index arrays, one per row.
Raises ``ValueError`` when ``X`` has fewer than two rows.
"""
n_splits = _n_samples(X)
if n_splits <= 1:
raise ValueError(f'Cannot perform LeaveOneOut with n_samples={n_splits}.')
leave = _native('leave_range_i64')
# split s: test row s, train every other row, written in Mojo
for s in range(n_splits): # glue: one native leave-range call per split
train, test = empty((n_splits - 1,), '<i8'), empty((1,), '<i8')
leave(n_splits, s, s + 1, _addr(train), _addr(test))
yield train, test
[docs]
class LeavePOut(_Splitter):
"""Leave-P-out cross-validator.
Every combination of ``p`` rows is the test set once, in lexicographic
order, with the remaining rows as the training set. Follows
scikit-learn's ``LeavePOut``. The number of splits grows as
``n_samples choose p``.
Parameters
----------
p : int
Size of each test set; must be less than the number of rows.
Examples
--------
>>> import numpy as np
>>> from mojolearn.model_selection import LeavePOut
>>> X = np.random.default_rng(0).random((10, 4), dtype=np.float32)
>>> folds = list(LeavePOut(2).split(X))
"""
def __init__(self, p):
self.p = p
[docs]
def get_n_splits(self, X=None, y=None, groups=None):
"""Return ``n_samples choose p``."""
return _comb(_n_samples(X), self.p)
[docs]
def split(self, X, y=None, groups=None):
"""Yield ``(train, test)`` int64 row-index arrays for each combination.
Raises ``ValueError`` when ``p`` is not less than the number of rows.
"""
n = _n_samples(X)
if n <= self.p:
raise ValueError(f'p={self.p} must be strictly less than the number of samples={n}')
p = int(self.p)
if p < 0:
raise ValueError('r must be non-negative')
if p < 1:
# itertools.combinations(range(n), 0): one empty test set
yield _arange_rows(0, n), empty((0,), '<i8')
return
# the combinations in lexicographic order, advanced in Mojo
combo = _arange_rows(0, p)
step = _native('next_combination_i64') # cpu-route: LeavePOut combinatorics, one p-sized step per split
while True:
yield _split_indices(combo, n)
if not int(step(_addr(combo), p, n)):
return
[docs]
class LeaveOneGroupOut(_Splitter):
"""Leave-one-group-out cross-validator.
Each distinct group is the test set once, in sorted group order, with
the rows of every other group as the training set. Follows
scikit-learn's ``LeaveOneGroupOut``. It takes no parameters.
Examples
--------
>>> import numpy as np
>>> from mojolearn.model_selection import LeaveOneGroupOut
>>> rng = np.random.default_rng(0)
>>> X = rng.random((1000, 8), dtype=np.float32)
>>> groups = rng.integers(0, 5, 1000)
>>> folds = list(LeaveOneGroupOut().split(X, groups=groups))
"""
[docs]
def get_n_splits(self, X=None, y=None, groups=None):
"""Return the number of distinct values in ``groups``."""
return len(set(_labels_list(groups, 'groups')))
[docs]
def split(self, X, y=None, groups=None):
"""Yield ``(train, test)`` int64 row-index arrays, one per group.
Raises ``ValueError`` when ``groups`` has fewer than two distinct
values.
"""
n = _n_samples(X)
gc = _GroupCodes.get(groups, n, 'groups')
if gc.m <= 1:
raise ValueError(f'The groups parameter contains fewer than 2 unique groups ({gc.classes}). '
'LeaveOneGroupOut expects at least 2.')
# each group's rows straight from the codes (lane/py-misc-msel)
for gi in range(gc.m): # glue: one native row split per group (m groups)
yield gc.rows(gc.codes, gi, gc.counts[gi])
[docs]
class LeavePGroupsOut(_Splitter):
"""Leave-P-groups-out cross-validator.
Every combination of ``n_groups`` distinct groups is the test set once,
with the rows of the other groups as the training set. Follows
scikit-learn's ``LeavePGroupsOut``.
Parameters
----------
n_groups : int
Number of groups in each test set; must be less than the number of
distinct groups.
Examples
--------
>>> import numpy as np
>>> from mojolearn.model_selection import LeavePGroupsOut
>>> rng = np.random.default_rng(0)
>>> X = rng.random((1000, 8), dtype=np.float32)
>>> groups = rng.integers(0, 5, 1000)
>>> folds = list(LeavePGroupsOut(n_groups=2).split(X, groups=groups))
"""
def __init__(self, n_groups):
self.n_groups = n_groups
[docs]
def get_n_splits(self, X=None, y=None, groups=None):
"""Return ``n_distinct_groups choose n_groups``."""
return _comb(len(set(_labels_list(groups, 'groups'))), self.n_groups)
[docs]
def split(self, X, y=None, groups=None):
"""Yield ``(train, test)`` int64 row-index arrays for each combination.
Raises ``ValueError`` when ``n_groups`` is not less than the number
of distinct groups.
"""
n = _n_samples(X)
gc = _GroupCodes.get(groups, n, 'groups')
if self.n_groups >= gc.m:
raise ValueError(f'The groups parameter contains fewer than (or equal to) n_groups '
f'({self.n_groups}) numbers of unique groups ({gc.classes}).')
# the chosen groups as a per-group table, gathered per row (lane/py-misc-msel)
for combo in itertools.combinations(range(gc.m), self.n_groups): # glue: one per-group table per combination of groups
table = [0] * gc.m
for g in combo: # glue: one per-group table per combination of groups
table[g] = 1
yield gc.rows(gc.mapped(table), 1, sum(gc.counts[g] for g in combo)) # glue: one per-group table per combination of groups
class _Repeated:
def __init__(self, cv, *, n_repeats=10, random_state=None, **params):
if is_bool(n_repeats) or not isinstance(n_repeats, numbers.Integral) or n_repeats <= 0:
raise ValueError('Number of repetitions must be greater than 0.')
self.cv = cv
self.n_repeats = int(n_repeats)
self.random_state = random_state
self.cvargs = params
def get_params(self, deep=True):
"""Return ``n_repeats``, ``random_state`` and ``n_splits`` as a dict."""
return dict(n_repeats=self.n_repeats, random_state=self.random_state, **self.cvargs)
def get_n_splits(self, X=None, y=None, groups=None):
"""Return ``n_splits * n_repeats``."""
return self.cv(**self.cvargs).get_n_splits(X, y, groups) * self.n_repeats
def split(self, X, y=None, groups=None):
"""Yield ``(train, test)`` int64 row-index arrays, repeat by repeat.
Each repeat is a shuffled run of the underlying splitter with its own
seed derived from ``random_state``, so the whole sequence is fixed by
``random_state``.
"""
# Repeat r draws its own seed from the base seed, so every repeat is
# a different shuffle and the whole sequence is fixed by random_state.
base = _rng(self.random_state)
for r in range(self.n_repeats): # glue: one splitter run per repeat (n_repeats sized)
seed = _mix_seed(base.seed, r)
yield from self.cv(random_state=seed, shuffle=True, **self.cvargs).split(X, y, groups)
def _mix_seed(seed, r):
return _mix64(seed * 0x9E3779B97F4A7C15 + 0x632BE59BD9B4E019 * (r + 1)) >> 1
[docs]
class RepeatedKFold(_Repeated):
"""Repeated k-fold cross-validator.
Runs a shuffled ``KFold`` ``n_repeats`` times, each repeat with a
different shuffle. Follows scikit-learn's ``RepeatedKFold``; the
shuffles come from mojolearn's counter-based random generator.
Parameters
----------
n_splits : int, default 5
Number of folds per repeat, at least 2.
n_repeats : int, default 10
Number of repeats, at least 1.
random_state : int or None, default None
Seed that fixes every repeat's shuffle.
Examples
--------
>>> import numpy as np
>>> from mojolearn.model_selection import RepeatedKFold
>>> X = np.random.default_rng(0).random((1000, 8), dtype=np.float32)
>>> folds = list(RepeatedKFold(n_splits=5, n_repeats=3, random_state=0).split(X))
"""
def __init__(self, *, n_splits=5, n_repeats=10, random_state=None):
super().__init__(KFold, n_repeats=n_repeats, random_state=random_state, n_splits=n_splits)
[docs]
class RepeatedStratifiedKFold(_Repeated):
"""Repeated stratified k-fold cross-validator.
Runs a shuffled ``StratifiedKFold`` ``n_repeats`` times, each repeat with
a different shuffle. Follows scikit-learn's ``RepeatedStratifiedKFold``;
the shuffles come from mojolearn's counter-based random generator.
Parameters
----------
n_splits : int, default 5
Number of folds per repeat, at least 2.
n_repeats : int, default 10
Number of repeats, at least 1.
random_state : int or None, default None
Seed that fixes every repeat's shuffle.
Examples
--------
>>> import numpy as np
>>> from mojolearn.model_selection import RepeatedStratifiedKFold
>>> rng = np.random.default_rng(0)
>>> X = rng.random((1000, 8), dtype=np.float32)
>>> y = rng.integers(0, 2, 1000)
>>> cv = RepeatedStratifiedKFold(n_splits=5, n_repeats=3, random_state=0)
>>> folds = list(cv.split(X, y))
"""
def __init__(self, *, n_splits=5, n_repeats=10, random_state=None):
super().__init__(StratifiedKFold, n_repeats=n_repeats, random_state=random_state, n_splits=n_splits)
def _validate_shuffle_split(n, test_size, train_size, default_test_size=None):
if test_size is None and train_size is None:
test_size = default_test_size
def kind(v):
if v is None:
return None
if is_bool(v):
return 'x'
if isinstance(v, numbers.Integral):
return 'i'
if isinstance(v, numbers.Real):
return 'f'
return 'x'
tk, rk = kind(test_size), kind(train_size)
if (tk == 'i' and (test_size >= n or test_size <= 0)) or (tk == 'f' and not 0 < test_size < 1):
raise ValueError(f'test_size={test_size} should be either positive and smaller than the number of '
f'samples {n} or a float in the (0, 1) range')
if (rk == 'i' and (train_size >= n or train_size <= 0)) or (rk == 'f' and not 0 < train_size < 1):
raise ValueError(f'train_size={train_size} should be either positive and smaller than the number '
f'of samples {n} or a float in the (0, 1) range')
if rk == 'x':
raise ValueError(f'Invalid value for train_size: {train_size}')
if tk == 'x':
raise ValueError(f'Invalid value for test_size: {test_size}')
if rk == 'f' and tk == 'f' and train_size + test_size > 1:
raise ValueError(f'The sum of test_size and train_size = {train_size + test_size}, should be in the '
'(0, 1) range. Reduce test_size and/or train_size.')
n_test = math.ceil(test_size * n) if tk == 'f' else test_size
n_train = math.floor(train_size * n) if rk == 'f' else train_size
if train_size is None:
n_train = n - n_test
elif test_size is None:
n_test = n - n_train
if n_train + n_test > n:
raise ValueError(f'The sum of train_size and test_size = {n_train + n_test}, should be smaller than '
f'the number of samples {n}. Reduce test_size and/or train_size.')
n_train, n_test = int(n_train), int(n_test)
if n_train == 0:
raise ValueError(f'With n_samples={n}, test_size={test_size} and train_size={train_size}, the '
'resulting train set will be empty. Adjust any of the aforementioned parameters.')
return n_train, n_test
[docs]
class ShuffleSplit(_Splitter):
"""Random permutation cross-validator.
Each split draws a fresh random permutation of the rows; its first
``n_test`` rows are the test set and the next ``n_train`` rows the
training set. Splits may overlap. Follows scikit-learn's
``ShuffleSplit``; the permutations come from mojolearn's counter-based
random generator, so the rows differ from scikit-learn's for the same
seed.
Parameters
----------
n_splits : int, default 10
Number of splits.
test_size : float, int or None, default None
A float in (0, 1) is a fraction of the rows (rounded up), an int an
absolute count. None means the complement of ``train_size``, or 0.1
when both are None.
train_size : float, int or None, default None
A float in (0, 1) is a fraction of the rows (rounded down), an int an
absolute count. None means the complement of ``test_size``.
random_state : int or None, default None
Seed for the permutations.
Examples
--------
>>> import numpy as np
>>> from mojolearn.model_selection import ShuffleSplit
>>> X = np.random.default_rng(0).random((1000, 8), dtype=np.float32)
>>> folds = list(ShuffleSplit(n_splits=5, test_size=0.2, random_state=0).split(X))
"""
_default_test_size = 0.1
def __init__(self, n_splits=10, *, test_size=None, train_size=None, random_state=None):
self.n_splits = n_splits
self.test_size = test_size
self.train_size = train_size
self.random_state = random_state
[docs]
def get_n_splits(self, X=None, y=None, groups=None):
"""Return the number of splits, ``n_splits``."""
return self.n_splits
def _sizes(self, n):
return _validate_shuffle_split(n, self.test_size, self.train_size, self._default_test_size)
[docs]
def split(self, X, y=None, groups=None):
"""Yield ``(train, test)`` int64 row-index arrays for each split.
Raises ``ValueError`` for sizes out of range or summing to more than
the number of rows.
"""
n = _n_samples(X)
n_train, n_test = self._sizes(n)
rng = _rng(self.random_state)
# the permutations as Int64 rows, sliced as arrays (lane metrics-apple2)
for perm in rng.permutation_rows([n] * self.n_splits): # glue: one device permutation per split (n_splits sized)
yield (Array._owned(perm[n_test:n_test + n_train], (n_train,), '<i8', 'C'),
Array._owned(perm[:n_test], (n_test,), '<i8', 'C'))
[docs]
class GroupShuffleSplit(ShuffleSplit):
"""Shuffle-split cross-validator over groups.
Works like ``ShuffleSplit`` on the sorted distinct groups, and every row
follows its group, so a group is never split between train and test.
``test_size`` and ``train_size`` count groups, not rows. Follows
scikit-learn's ``GroupShuffleSplit``.
Parameters
----------
n_splits : int, default 10
Number of splits.
test_size : float, int or None, default None
Fraction or number of groups in the test set. None means the
complement of ``train_size``, or 0.2 when both are None.
train_size : float, int or None, default None
Fraction or number of groups in the training set. None means the
complement of ``test_size``.
random_state : int or None, default None
Seed for the permutations (mojolearn's counter-based generator).
Examples
--------
>>> import numpy as np
>>> from mojolearn.model_selection import GroupShuffleSplit
>>> rng = np.random.default_rng(0)
>>> X = rng.random((1000, 8), dtype=np.float32)
>>> groups = rng.integers(0, 50, 1000)
>>> cv = GroupShuffleSplit(n_splits=5, test_size=0.2, random_state=0)
>>> folds = list(cv.split(X, groups=groups))
"""
_default_test_size = 0.2
[docs]
def split(self, X, y=None, groups=None):
"""Yield ``(train, test)`` int64 row-index arrays for each split.
``groups`` is required.
"""
gc = _GroupCodes.get(groups, _n_samples(X), 'groups')
# each group's side (0 train, 1 test, 2 neither) as a table
# gathered per row, the same draws (lane/py-misc-msel)
# each draw's per-group side table and side sizes in Mojo
# (`split_table_i32`; lane cgr4-py-compute; on the device since
# lane cpu2-l4-modelsel, `msel_split_table_i32`), gathered per row
m = gc.m
n_train, n_test = self._sizes(m)
rng = _rng(self.random_state)
counts = array.array('q', gc.counts)
table = array.array('i', bytes(4 * m))
sums = array.array('q', [0, 0])
for perm in rng.permutation_rows([m] * self.n_splits): # glue: one device permutation per split (n_splits sized)
_native('msel_split_table_i32')(perm.buffer_info()[0], m, n_test, n_train,
counts.buffer_info()[0], table.buffer_info()[0],
sums.buffer_info()[0])
words = gc.mapped(table)
yield gc.only(words, 0, sums[0]), gc.only(words, 1, sums[1])
[docs]
class StratifiedShuffleSplit(ShuffleSplit):
"""Stratified random-permutation cross-validator.
Each split keeps roughly the class proportions of ``y`` in both the
training and the test set. Follows scikit-learn's
``StratifiedShuffleSplit``: each class's rows are permuted and the
per-class train and test counts are scikit-learn's approximate mode of
the class proportions; the training and test rows are then shuffled. The
permutations come from mojolearn's counter-based random generator.
Parameters
----------
n_splits : int, default 10
Number of splits.
test_size : float, int or None, default None
Fraction or number of rows in the test set. None means the
complement of ``train_size``, or 0.1 when both are None.
train_size : float, int or None, default None
Fraction or number of rows in the training set. None means the
complement of ``test_size``.
random_state : int or None, default None
Seed for the permutations.
Examples
--------
>>> import numpy as np
>>> from mojolearn.model_selection import StratifiedShuffleSplit
>>> rng = np.random.default_rng(0)
>>> X = rng.random((1000, 8), dtype=np.float32)
>>> y = rng.integers(0, 3, 1000)
>>> cv = StratifiedShuffleSplit(n_splits=5, test_size=0.2, random_state=0)
>>> folds = list(cv.split(X, y))
"""
# Maintainer note (moved from the class docstring): n_i and t_i come
# from the exact-integer approximate mode (`CounterRng.approximate_modes`,
# x_metrics/split.mojo).
[docs]
def split(self, X, y, groups=None):
"""Yield ``(train, test)`` int64 row-index arrays for each split.
``y`` is required. Raises ``ValueError`` when a class has a single
member, or when the training or test set is smaller than the number
of classes.
"""
# lane py-runtime-b: the core-helper route is the only one (the
# Python row route is deleted)
yield from self._native_split(y)
def _native_split(self, y):
"""`split`'s rows by the core helpers (lane/py-misc-msel):
each class's ascending rows once (`select_fold_i64` on the codes),
then per split the same draws in the same order, the permuted rows
by `gather_i64` straight into the train and test arrays, and the
final shuffles by `gather_i64` again. A generator after its checks,
which raise before the first draw exactly as `split` does."""
n0 = 0 if y is None else (
len(y) if not hasattr(y, 'shape') else (int(y.shape[0]) if len(y.shape) else 0))
gc = _GroupCodes.get(y, n0, 'y') # raises the definition's error for y None
gather64 = _native('gather_i64')
n, k, counts = gc.n, gc.m, gc.counts
n_train, n_test = self._sizes(n)
if min(counts) < 2: # glue: least populated class check over k class counts
raise ValueError('The least populated classes in y have only 1 member, which is too few. The '
'minimum number of groups for any class cannot be less than 2.')
if n_train < k:
raise ValueError(f'The train_size = {n_train} should be greater or equal to the number of classes = {k}')
if n_test < k:
raise ValueError(f'The test_size = {n_test} should be greater or equal to the number of classes = {k}')
rows = [gc.only(gc.codes, c, counts[c]) for c in range(k)] # glue: one native row selection per class (k classes)
rng = _rng(self.random_state)
def gen():
for _ in range(self.n_splits): # glue: one split per iteration (n_splits sized)
# scikit-learn's _approximate_mode for train then test, in
# one device program (x_metrics approx_mode; lane py-runtime-b)
n_i, t_i = rng.approximate_modes(counts, n_train, n_test)
tr_len, te_len = sum(n_i), sum(t_i) # glue: train and test sizes summed over k classes
train = empty((tr_len,), '<i8')
test = empty((te_len,), '<i8')
at_tr = at_te = 0
for c, perm in enumerate(rng.permutation_rows(counts)): # glue: one native row selection per class (k classes)
p0 = perm.buffer_info()[0]
tab = _addr_ro(rows[c])
gather64(tab, counts[c], p0, n_i[c], _addr(train) + 8 * at_tr)
gather64(tab, counts[c], p0 + 8 * n_i[c], t_i[c], _addr(test) + 8 * at_te)
at_tr += n_i[c]
at_te += t_i[c]
ptr, pte = rng.permutation_rows([tr_len, te_len])
out_tr = empty((tr_len,), '<i8')
out_te = empty((te_len,), '<i8')
gather64(_addr_ro(train), tr_len, ptr.buffer_info()[0], tr_len, _addr(out_tr))
gather64(_addr_ro(test), te_len, pte.buffer_info()[0], te_len, _addr(out_te))
yield out_tr, out_te
return gen()
[docs]
class PredefinedSplit(_Splitter):
"""Cross-validator with user-defined folds.
``test_fold[i]`` is the fold in which row ``i`` is tested; a value of -1
keeps that row in every training set and never tests it. Folds are
yielded in sorted order of their values. Follows scikit-learn's
``PredefinedSplit``.
Parameters
----------
test_fold : array-like of int, shape (n_samples,)
The fold of each row, or -1.
Examples
--------
>>> import numpy as np
>>> from mojolearn.model_selection import PredefinedSplit
>>> cv = PredefinedSplit(np.array([0, 1, -1, 1, 0]))
>>> folds = list(cv.split())
"""
def __init__(self, test_fold):
# lane py-runtime-b: the fold labels widened once to int64 (a C
# copy of an integer buffer, array('q') over a list), then the fold
# values and rows from the native encoder and row selection; no
# Python pass over the rows
self._folds = _index_copy(test_fold)
self.test_fold = self._folds.tolist()
def _codes(self):
return _GroupCodes.get(self._folds, self._folds.size, 'test_fold')
[docs]
def get_n_splits(self, X=None, y=None, groups=None):
"""Return the number of distinct fold values other than -1."""
from ._labels import encode_labels
classes = encode_labels(self._folds)[0]
return len(classes) - (1 if -1 in classes else 0) # glue: membership test over the fold values
[docs]
def split(self, X=None, y=None, groups=None):
"""Yield ``(train, test)`` int64 row-index arrays for each fold."""
gc = self._codes()
# the sorted fold values natively, each fold's rows by its code
# (lane/py-misc-msel)
for c, f in enumerate(gc.classes): # glue: one native row split per fold value
if f != -1:
yield gc.rows(gc.codes, c, gc.counts[c])
def _index_copy(value):
"""`_as_index(flatten_labels(value))`: an int64 or int32 1-D buffer is
widened in C (array('q') over its memoryview) instead of through a list
of Python ints (lane/py-misc-msel); anything else takes the definition."""
if _msel_native() and not isinstance(value, (list, tuple, range)):
try:
a = _materialize(value, 'cv indices')[0]
except (TypeError, ValueError):
a = None
if a is not None and a.ndim == 1 and a.dtype in ('<i8', '<i4'):
raw = a._as_c().tobytes()
if a.dtype == '<i8' and _LITTLE_ENDIAN:
store = array.array('q')
store.frombytes(raw)
else:
narrow = array.array('i' if a.dtype == '<i4' else 'q')
narrow.frombytes(raw)
if not _LITTLE_ENDIAN:
narrow.byteswap()
store = array.array('q', narrow)
return Array._owned(store, (len(store),), '<i8', 'C')
return _as_index(flatten_labels(value))
class _IterableCV(_Splitter):
def __init__(self, cv):
self._pairs = [(_index_copy(tr), _index_copy(te)) for tr, te in cv] # cpu-route: user-provided iterable of index pairs, copied once at construction [py-data-loop]
def get_n_splits(self, X=None, y=None, groups=None):
return len(self._pairs)
def split(self, X=None, y=None, groups=None):
yield from self._pairs
def _native_discrete(y):
"""`check_cv`'s stratify test on a 1-D numeric buffer without a Python
object per label (lane/py-misc-msel): every integer or bool label is
integral, and a float buffer is discrete when every value is finite and
integer valued (`reduce_stat`'s integral test, the predicate
`_native_default_folds` uses). None for anything else. A numeric list
or buffer is materialized first (lane py-runtime-b), so only label
objects (str, mixed) reach `check_cv`'s per-label test."""
if not isinstance(y, Array):
try:
y = _materialize(y, 'y')[0]
except (TypeError, ValueError, OverflowError):
return None
if y.ndim == 2 and y.shape[1] == 1:
y = y.reshape((y.shape[0],))
if y.ndim != 1 or y.size < _NATIVE_MIN_ROWS or not _msel_native():
return None
from ._labels import _NATIVE_ENCODE
if y.dtype not in _NATIVE_ENCODE:
return None
if y.dtype not in ('<f4', '<f8'):
return True if _native('reduce_stat') is not None else None
integral = _native('reduce_stat')
if integral is None:
return None
from ._array import _NATIVE_CODE, _REDUCE_INTEGRAL
return bool(integral(_addr_ro(y._as_c()), _NATIVE_CODE[y.dtype], y.size, _REDUCE_INTEGRAL))
[docs]
def check_cv(cv=5, y=None, *, classifier=False, shuffle=False, random_state=None):
"""Turn a ``cv`` argument into a splitter.
Follows scikit-learn's ``check_cv``. An integer (or None, meaning 5)
becomes ``StratifiedKFold`` when ``classifier`` is true and ``y`` holds
discrete labels (strings, or integer-valued finite numbers), and
``KFold`` otherwise. An object with a ``split`` method is returned as is,
and an iterable of ``(train, test)`` index pairs is wrapped in a
splitter that yields those pairs.
Parameters
----------
cv : int, splitter, iterable or None, default 5
The cross-validation strategy.
y : array-like of shape (n_samples,), default None
Targets, read only to decide whether to stratify.
classifier : bool, default False
Whether the estimator is a classifier.
shuffle : bool, default False
Passed to the ``KFold`` or ``StratifiedKFold`` built from an integer.
random_state : int or None, default None
Seed for that splitter, used only when ``shuffle`` is true.
Returns
-------
splitter
An object with ``split(X, y, groups)`` and ``get_n_splits``.
Raises
------
ValueError
If ``cv`` is a string or another value that is not an integer,
splitter or iterable.
Examples
--------
>>> import numpy as np
>>> from mojolearn.model_selection import check_cv
>>> y = np.random.default_rng(0).integers(0, 2, 1000)
>>> cv = check_cv(5, y, classifier=True)
"""
cv = 5 if cv is None else cv
if isinstance(cv, numbers.Integral) and not is_bool(cv):
stratify = False
native = _native_discrete(y) if classifier and y is not None else None
if native is not None:
stratify = native
elif classifier and y is not None:
labels = flatten_labels(y)
stratify = (all(isinstance(v, str) for v in labels) or # cpu-route: stratify test over label objects (str/mixed) after the native route declines [py-data-loop]
all(isinstance(v, numbers.Integral) or
(isinstance(v, numbers.Real) and math.isfinite(v) and float(v).is_integer())
for v in labels)) # cpu-route: stratify test over label objects (str/mixed) after the native route declines [py-data-loop]
kind = StratifiedKFold if stratify else KFold
return kind(cv, shuffle=shuffle, random_state=random_state if shuffle else None)
if callable(getattr(cv, 'split', None)):
return cv
if isinstance(cv, str) or not hasattr(cv, '__iter__'):
raise ValueError(f'Expected cv as an integer, cross-validation object (from '
f'sklearn.model_selection) or an iterable. Got {cv!r}.')
return _IterableCV(cv)
[docs]
def train_test_split(*arrays, test_size=None, train_size=None, random_state=None, shuffle=True,
stratify=None):
"""Split arrays into random train and test subsets.
Follows scikit-learn's ``train_test_split``. With ``shuffle=True`` the
rows are chosen by one ``ShuffleSplit`` draw (or ``StratifiedShuffleSplit``
when ``stratify`` is given) from mojolearn's counter-based random
generator, so the rows differ from scikit-learn's for the same seed.
With ``shuffle=False`` the leading rows are the training set and the
rows after them the test set.
Parameters
----------
*arrays : sequence of array-likes
Arrays or lists with the same number of rows. Buffer arrays are
returned as mojolearn ``Array`` objects, lists as lists.
test_size : float, int or None, default None
Fraction (rounded up) or number of rows in the test set. None means
the complement of ``train_size``, or 0.25 when both are None.
train_size : float, int or None, default None
Fraction (rounded down) or number of rows in the training set. None
means the complement of ``test_size``.
random_state : int or None, default None
Seed for the shuffle.
shuffle : bool, default True
Whether to shuffle before splitting.
stratify : array-like or None, default None
Class labels to stratify by. Requires ``shuffle=True``; with
``shuffle=False`` it raises ``ValueError``.
Returns
-------
splitting : list
Two pieces per input, in scikit-learn's order:
``X_train, X_test, y_train, y_test, ...``.
Raises
------
ValueError
If no array is given, the arrays have different numbers of rows, or
the sizes are out of range.
Examples
--------
>>> import numpy as np
>>> from mojolearn.model_selection import train_test_split
>>> rng = np.random.default_rng(0)
>>> X = rng.random((1000, 8), dtype=np.float32)
>>> y = rng.random(1000, dtype=np.float32)
>>> X_train, X_test, y_train, y_test = train_test_split(
... X, y, test_size=0.2, random_state=0)
"""
if not arrays:
raise ValueError('At least one array required as input')
n = _n_samples(arrays[0])
if any(_n_samples(a) != n for a in arrays): # glue: sample-count check over the input arrays
raise ValueError('Found input variables with inconsistent numbers of samples: '
f'{[_n_samples(a) for a in arrays]}')
n_train, n_test = _validate_shuffle_split(n, test_size, train_size, 0.25)
if not shuffle:
if stratify is not None:
raise ValueError('Stratified train/test split is not implemented for shuffle=False')
train, test = _as_index(range(n_train)), _as_index(range(n_train, n_train + n_test))
else:
cls = StratifiedShuffleSplit if stratify is not None else ShuffleSplit
cv = cls(n_splits=1, test_size=n_test, train_size=n_train, random_state=random_state)
train, test = next(cv.split(arrays[0], stratify))
out = []
for a in arrays: # glue: sample-count check over the input arrays
out.extend([_take_any(a, train), _take_any(a, test)])
return out
def _take_any(values, indices):
if isinstance(values, (list, tuple)):
return [values[i] for i in indices.tolist()] # cpu-route: Python list of label objects taken by index [py-data-loop]
return _take_rows(values, indices)
# ---------------------------------------------------------------- fold rows
# cpu-gpu-cleanup c-metrics-prep: lane metrics-apple3's opt-in
# switch routes (fold-row cache, shared scorer predictions,
# native cross_val_predict / learning_curve / first-seen codes) were never
# run; the switch and the routes are deleted and every call takes its
# definition.
class _FoldRows:
"""Each fold's rows of X (and of y) for the fits that share the folds:
the candidates of a search, the values of a validation curve, the
permutations of a permutation test, the folds of cross_validate and
cross_val_predict. X, y and the fold indices stay resident on the device
(`_Resident`) across every fold and every candidate; each `take` is one
device gather per piece. A y handed to `take` (a permuted y) is put once
and replaces the previous such y on the device. Close it (or use it in a
`with` block) to free the device copies.
device_rows (lane cpu4-misc; `_device_rows_ok`): X's fold rows are
handed over as `_arena_io.DeviceRows` instead of host Arrays, when X is
float32 C-contiguous 2-D. The estimator's arena runner puts X once into
its own binding's store (the `resident()` scope opened here, which holds
only those bases) and gathers each fold on the device: no per-fold
download of X. Every other estimator gets host Arrays as before."""
def __init__(self, X, y, folds, *, keep_y=True, device_rows=False):
self.X, self.y, self.folds = X, y, folds
self._res = _Resident()
self._extra = None
self._dev = (bool(device_rows) and isinstance(X, Array) and X.dtype == '<f4' and X.ndim == 2
and X.size > 0 and X._has_order('C'))
self._scope = None
if self._dev:
# only DeviceRows bases enter this scope (`rows_slot` ignores the
# size floor); no other program input is kept across folds
self._scope = _arena_io.resident(min_words=1 << 62)
self._scope.__enter__()
def _xrows(self, idx):
if not self._dev:
return self._rows(self.X, idx)
if idx.dtype != '<i8' or not idx._has_order('C'):
idx = idx.astype('<i8')._as_c()
return _arena_io.DeviceRows(self.X, idx, self._res.take)
def _rows(self, values, idx):
if isinstance(values, list):
return _take_rows(values, idx)
return self._res.take(values, idx)
def take(self, i, y=None):
"""(X_train, y_train, X_test, y_test) of fold i; `y` given here (a
permuted y) stands in for the stored one."""
train, test = self.folds[i]
Xtr, Xte = self._xrows(train), self._xrows(test)
if y is None:
y = self.y
elif y is not self.y and y is not self._extra:
if self._extra is not None:
self._res.release(self._extra)
self._extra = y
if y is None:
return Xtr, None, Xte, None
return Xtr, self._rows(y, train), Xte, self._rows(y, test)
def close(self):
self._extra = None
scope, self._scope = self._scope, None
try:
if scope is not None:
scope.__exit__(None, None, None)
finally:
self._res.close()
def __enter__(self):
return self
def __exit__(self, *exc):
self.close()
return False
def _device_rows_ok(estimator, scoring=None):
"""Whether fold X may go to `estimator` as `_arena_io.DeviceRows` (lane
cpu4-misc): its class opts in (`_mojolearn_device_rows = True`; a
search candidate's `_Pinned` is unwrapped, a Pipeline or any other
estimator is not) and every scorer only calls the estimator's own
methods on X (None, a scorer name, or mojolearn's `_Scorer`s; a user
callable may read X on the host, so it gets host rows)."""
est = estimator
while isinstance(est, _Pinned):
est = est._est
if getattr(type(est), '_mojolearn_device_rows', False) is not True:
return False
if isinstance(scoring, dict):
scoring = list(scoring.values())
if not isinstance(scoring, (list, tuple, set)):
scoring = [scoring]
ok = True
for sc in scoring: # glue: the kind of each scorer (a handful)
ok = ok and (sc is None or isinstance(sc, (str, _Scorer)))
return ok
# ---------------------------------------------------------------- scorers
class _Scorer:
def __init__(self, score_func, sign, kwargs, response_method, name):
self._score_func = score_func
self._sign = sign
self._kwargs = kwargs
self._response_method = response_method
self._name = name
def __repr__(self):
return f'make_scorer({getattr(self._score_func, "__name__", self._score_func)})'
def __call__(self, estimator, X, y, sample_weight=None, *, _memo=None):
# `_memo` (lane metrics-apple3): the predictions of THIS estimator
# on THIS X, by response method, shared by the scorers of one
# multimetric fold (scikit-learn's _MultimetricScorer caches the
# same way); a metric never writes into its inputs.
methods = self._response_method
if isinstance(methods, str):
methods = (methods,)
for m in methods: # glue: response method names tried in order
fn = getattr(estimator, m, None)
if fn is not None:
break
else:
raise AttributeError(f'{type(estimator).__name__} has none of {methods}')
pred = None if _memo is None else _memo.get(m)
if pred is None:
pred = fn(X)
if _memo is not None:
_memo[m] = pred
if m == 'predict_proba' and getattr(pred, 'ndim', 1) == 2 and pred.shape[1] == 2 \
and self._name in _BINARY_PROBA:
col = None if _memo is None else _memo.get('predict_proba[:, 1]')
if col is None:
fast = _proba_column1(pred)
col = fast if fast is not None else Array.from_list([row[1] for row in pred.tolist()], '<f4') # cpu-route: Python list probabilities from an external estimator [py-data-loop]
if _memo is not None:
_memo['predict_proba[:, 1]'] = col
pred = col
kw = dict(self._kwargs)
if sample_weight is not None:
kw['sample_weight'] = sample_weight
return self._sign * float(self._score_func(y, pred, **kw))
_BINARY_PROBA = {'roc_auc', 'average_precision', 'neg_brier_score', 'neg_log_loss'}
def _proba_column1(pred):
"""Column 1 of an (n, 2) float32 or float64 buffer as a float32 Array,
read on the device (`msel_proba_column`, lane cpu2-l4-modelsel): one
kernel takes column 1 straight out of the row-major block and narrows a
float64 to nearest even (the `(float)` cast), so the host makes no cast,
no transpose and no column copy. None (a Python list from an external
estimator, or another shape) hands it to the caller's route."""
if isinstance(pred, (list, tuple)):
return None
try:
a = _materialize(pred, 'pred')[0]
except (TypeError, ValueError):
return None
if a.ndim != 2 or a.shape[1] != 2 or a.dtype not in ('<f4', '<f8') or a.shape[0] < 1:
return None
n = a.shape[0]
c = a._as_c()
out = empty((n,), '<f4')
_native('msel_proba_column')(_addr_ro(c), _MSEL_CODE[c.dtype], n, 2, 1, _addr(out))
return out
[docs]
def make_scorer(score_func, *, response_method='predict', greater_is_better=True, **kwargs):
"""Make a scorer from a metric function.
Follows scikit-learn's ``make_scorer``. The scorer is called as
``scorer(estimator, X, y)``: it gets predictions from the estimator's
response method and returns ``score_func(y, predictions, **kwargs)`` as a
float, negated when ``greater_is_better=False``.
Parameters
----------
score_func : callable
Metric with signature ``score_func(y_true, y_pred, **kwargs)``.
response_method : str or tuple of str, default 'predict'
Estimator method that produces the predictions. A tuple is tried in
order and the first method the estimator has is used.
greater_is_better : bool, default True
False for a loss; the scorer then returns the negated value.
**kwargs
Extra keyword arguments passed to ``score_func``.
Returns
-------
scorer : callable
Examples
--------
>>> from mojolearn import metrics
>>> from mojolearn.model_selection import make_scorer
>>> scorer = make_scorer(metrics.mean_absolute_error, greater_is_better=False)
"""
return _Scorer(score_func, 1 if greater_is_better else -1, kwargs, response_method, None)
def _scorer_table():
from . import metrics as m
def s(fn, sign=1, rm='predict', name=None, **kw):
return _Scorer(fn, sign, kw, rm, name)
t = {
'accuracy': s(m.accuracy_score), 'balanced_accuracy': s(m.balanced_accuracy_score),
'top_k_accuracy': s(m.top_k_accuracy_score, rm=('decision_function', 'predict_proba')),
'average_precision': s(m.average_precision_score, rm=('decision_function', 'predict_proba'),
name='average_precision'),
'neg_brier_score': s(m.brier_score_loss, -1, 'predict_proba', 'neg_brier_score'),
'f1': s(m.f1_score), 'neg_log_loss': s(m.log_loss, -1, 'predict_proba', 'neg_log_loss'),
'precision': s(m.precision_score), 'recall': s(m.recall_score), 'jaccard': s(m.jaccard_score),
'roc_auc': s(m.roc_auc_score, rm=('decision_function', 'predict_proba'), name='roc_auc'),
'roc_auc_ovr': s(m.roc_auc_score, rm='predict_proba', multi_class='ovr'),
'roc_auc_ovo': s(m.roc_auc_score, rm='predict_proba', multi_class='ovo'),
'roc_auc_ovr_weighted': s(m.roc_auc_score, rm='predict_proba', multi_class='ovr', average='weighted'),
'roc_auc_ovo_weighted': s(m.roc_auc_score, rm='predict_proba', multi_class='ovo', average='weighted'),
'matthews_corrcoef': s(m.matthews_corrcoef),
'explained_variance': s(m.explained_variance_score), 'r2': s(m.r2_score),
'max_error': s(m.max_error, -1), 'neg_median_absolute_error': s(m.median_absolute_error, -1),
'neg_mean_absolute_error': s(m.mean_absolute_error, -1),
'neg_mean_absolute_percentage_error': s(m.mean_absolute_percentage_error, -1),
'neg_mean_squared_error': s(m.mean_squared_error, -1),
'neg_mean_squared_log_error': s(m.mean_squared_log_error, -1),
'neg_root_mean_squared_error': s(m.root_mean_squared_error, -1),
'neg_root_mean_squared_log_error': s(m.root_mean_squared_log_error, -1),
'neg_mean_poisson_deviance': s(m.mean_poisson_deviance, -1),
'neg_mean_gamma_deviance': s(m.mean_gamma_deviance, -1),
'd2_absolute_error_score': s(m.d2_absolute_error_score),
'adjusted_rand_score': s(m.adjusted_rand_score), 'rand_score': s(m.rand_score),
'homogeneity_score': s(m.homogeneity_score), 'completeness_score': s(m.completeness_score),
'v_measure_score': s(m.v_measure_score), 'mutual_info_score': s(m.mutual_info_score),
'adjusted_mutual_info_score': s(m.adjusted_mutual_info_score),
'normalized_mutual_info_score': s(m.normalized_mutual_info_score),
'fowlkes_mallows_score': s(m.fowlkes_mallows_score),
}
for base, fn in (('precision', m.precision_score), ('recall', m.recall_score), ('f1', m.f1_score), # glue: scorer table over the scorer names
('jaccard', m.jaccard_score)):
for avg in ('macro', 'micro', 'weighted'): # glue: scorer table over the scorer names
t[f'{base}_{avg}'] = s(fn, average=avg)
for name, sc in t.items(): # glue: scorer table over the scorer names
sc._name = sc._name or name
return t
[docs]
def get_scorer_names():
"""Return the sorted list of scorer names that ``get_scorer`` accepts.
The names are scikit-learn's, computed with ``mojolearn.metrics``. The
multilabel ``'samples'`` averages (for example ``'f1_samples'``) are not
available.
Examples
--------
>>> from mojolearn.model_selection import get_scorer_names
>>> 'neg_mean_squared_error' in get_scorer_names()
True
"""
return sorted(_scorer_table()) # glue: scorer table over the scorer names
[docs]
def get_scorer(scoring):
"""Return the scorer for a scoring argument.
Follows scikit-learn's ``get_scorer``.
Parameters
----------
scoring : str, callable or None
A name from ``get_scorer_names()``, a callable (returned as is), or
None (returned as is, meaning the estimator's own ``score``).
Returns
-------
scorer : callable or None
Raises
------
ValueError
If ``scoring`` is an unknown name or not a str, callable or None.
Examples
--------
>>> from mojolearn.model_selection import get_scorer
>>> scorer = get_scorer('r2')
"""
if scoring is None or callable(scoring):
return scoring
if isinstance(scoring, str):
table = _scorer_table()
if scoring not in table:
raise ValueError(f'{scoring!r} is not a valid scoring value. Use '
'sklearn.metrics.get_scorer_names() to get valid options.')
return table[scoring]
raise ValueError(f'scoring must be a str, a callable or None, got {scoring!r}')
def _scorers(scoring):
"""{name: scorer} for multimetric scoring, or (None, single) for one."""
if isinstance(scoring, (list, tuple, set)):
return {s: get_scorer(s) for s in scoring} # glue: scorer table over the scorer names
if isinstance(scoring, dict):
return {k: get_scorer(v) for k, v in scoring.items()} # glue: scorer table over the scorer names
return None
def _score(estimator, X, y, scorer, memo=None):
if scorer is None:
value = estimator.score(X, y)
elif memo is not None and isinstance(scorer, _Scorer):
value = scorer(estimator, X, y, _memo=memo)
else:
value = scorer(estimator, X, y)
if not isinstance(value, numbers.Real):
raise TypeError('scoring must return a real scalar')
return float(value)
# ---------------------------------------------------------------- validation
def _cv_folds(estimator, X, y, cv, groups):
"""(X, y, folds) with every fold validated before any fit."""
X = _materialize(X, "X")[0]
try:
y = None if y is None else _materialize(y, "y")[0]
except TypeError:
y = flatten_labels(y)
splitter = check_cv(cv, y, classifier=_classifier(estimator))
folds = []
for train, test in splitter.split(X, y, groups): # glue: one validated index pair per fold (n_splits sized)
train = _indices(train, len(X), 'train')
test = _indices(test, len(X), 'test')
if _overlap(train, test, len(X)):
raise ValueError('train and test indices overlap')
folds.append((train, test))
if not folds:
raise ValueError('cv must produce at least one fold')
return X, y, folds
def _require_serial(n_jobs, error_score, caller):
if n_jobs not in (None, 1):
raise NotImplementedError(f'{caller} supports n_jobs=1 (or None) only')
if not (isinstance(error_score, str) and error_score == 'raise') and not isinstance(error_score, numbers.Real):
raise ValueError("error_score must be 'raise' or a number")
[docs]
def cross_validate(estimator, X, y=None, *, groups=None, scoring=None, cv=None, n_jobs=None,
return_train_score=False, return_estimator=False, return_indices=False,
error_score=float('nan')):
"""Evaluate one or more scores by cross-validation, with fit and score times.
Follows scikit-learn's ``cross_validate``. For each fold, a fresh clone
of ``estimator`` is fit on the training rows and scored on the held-out
rows. Folds run one after another.
Parameters
----------
estimator : estimator
An unfitted estimator with ``get_params`` and ``fit``.
X : array-like of shape (n_samples, n_features)
Training data.
y : array-like of shape (n_samples,), default None
Targets; None for unsupervised estimators.
groups : array-like of shape (n_samples,), default None
Group labels for a splitter that reads them.
scoring : str, callable, list, tuple, set, dict or None, default None
None uses the estimator's ``score``. A name or callable gives one
score, stored as ``test_score``. A list, tuple or set of names, or a
dict of name to scorer, gives several, stored as ``test_<name>``.
cv : int, splitter, iterable or None, default None
As in ``check_cv``: None means five folds, stratified for a
classifier.
n_jobs : int or None, default None
Only None or 1 is supported; other values raise
``NotImplementedError``.
return_train_score : bool, default False
Also score each fold's training rows (``train_<name>``).
return_estimator : bool, default False
Also return the fitted estimator of each fold.
return_indices : bool, default False
Also return each fold's train and test indices.
error_score : 'raise' or float, default nan
``'raise'`` propagates a fit error; a number is recorded as the
fold's score instead.
Returns
-------
results : dict
``fit_time``, ``score_time`` (wall-clock seconds) and the score
arrays, each a float64 ``Array`` of shape (n_splits,); plus
``estimator`` (a list) and ``indices`` (``{'train': [...], 'test':
[...]}``) when requested.
Examples
--------
>>> import numpy as np, mojolearn
>>> from mojolearn.model_selection import cross_validate
>>> rng = np.random.default_rng(0)
>>> X = rng.random((1000, 8), dtype=np.float32)
>>> y = rng.random(1000, dtype=np.float32)
>>> res = cross_validate(mojolearn.Ridge(alpha=1.0), X, y, cv=5,
... scoring=['r2', 'neg_mean_squared_error'])
"""
_require_serial(n_jobs, error_score, 'cross_validate')
X, y, folds = _cv_folds(estimator, X, y, cv, groups)
return _cross_validate_folds(estimator, X, y, folds, scoring, return_train_score, return_estimator,
return_indices, error_score)
def _cross_validate_folds(estimator, X, y, folds, scoring, return_train_score=False, return_estimator=False,
return_indices=False, error_score=float('nan'), rows=None):
"""`cross_validate` on (X, y, folds) that `_cv_folds` already made: a
search or a validation curve draws its folds ONCE and scores every
candidate on them, as scikit-learn does (a shuffling splitter with
random_state=None would otherwise draw new folds per candidate).
permutation_test_score reuses it on its fixed folds."""
multi = _scorers(scoring)
single = None if multi is not None else get_scorer(scoring)
names = list(multi) if multi is not None else ['score']
out = {'fit_time': [], 'score_time': []}
for nm in names: # glue: one result list per scorer name
out[f'test_{nm}'] = []
if return_train_score:
out[f'train_{nm}'] = []
ests, idx = [], {'train': [], 'test': []}
own = rows is None
if own:
rows = _FoldRows(X, y, folds, device_rows=_device_rows_ok(estimator, scoring))
try:
return _cross_validate_loop(estimator, y, folds, rows, names, multi, single, out, ests, idx,
return_train_score, return_estimator, return_indices, error_score)
finally:
if own:
rows.close()
def _cross_validate_loop(estimator, y, folds, rows, names, multi, single, out, ests, idx,
return_train_score, return_estimator, return_indices, error_score):
import time
for i, (train, test) in enumerate(folds): # glue: one fit and score per fold (n_splits sized)
est = _clone(estimator)
t0 = time.perf_counter()
Xtr, ytr, Xte, yte = rows.take(i, None if y is rows.y else y)
try:
est.fit(Xtr, ytr) if ytr is not None else est.fit(Xtr)
ok = True
except Exception:
if isinstance(error_score, str):
raise
ok = False
t1 = time.perf_counter()
for nm in names: # glue: one result list per scorer name
sc = multi[nm] if multi is not None else single
out[f'test_{nm}'].append(_score(est, Xte, yte, sc) if ok else float(error_score))
if return_train_score:
out[f'train_{nm}'].append(_score(est, Xtr, ytr, sc) if ok else float(error_score))
out['fit_time'].append(t1 - t0)
out['score_time'].append(time.perf_counter() - t1)
if return_estimator:
ests.append(est)
if return_indices:
idx['train'].append(train)
idx['test'].append(test)
res = {k: Array.from_list(v, '<f8') for k, v in out.items()} # glue: one result list per scorer name
if return_estimator:
res['estimator'] = ests
if return_indices:
res['indices'] = idx
return res
[docs]
def cross_val_predict(estimator, X, y=None, *, groups=None, cv=None, n_jobs=None, method='predict'):
"""Return each row's prediction from the fold that held it out.
Follows scikit-learn's ``cross_val_predict``. For each fold, a fresh
clone of ``estimator`` is fit on the training rows and its ``method`` is
called on the held-out rows; the predictions are put back in row order.
The folds must hold out every row exactly once, otherwise ``ValueError``
is raised.
Parameters
----------
estimator : estimator
An unfitted estimator with ``get_params``, ``fit`` and ``method``.
X : array-like of shape (n_samples, n_features)
Training data.
y : array-like of shape (n_samples,), default None
Targets.
groups : array-like of shape (n_samples,), default None
Group labels for a splitter that reads them.
cv : int, splitter, iterable or None, default None
As in ``check_cv``: None means five folds, stratified for a
classifier.
n_jobs : int or None, default None
Only None or 1 is supported.
method : str, default 'predict'
Estimator method to call, for example ``'predict_proba'``.
Returns
-------
predictions : Array or list
Shape (n_samples,) or (n_samples, ...) for numeric predictions:
int64 when every fold returns 1-D integer predictions, float64
otherwise. Predictions that are not numbers (string labels) come
back as a list.
Examples
--------
>>> import numpy as np, mojolearn
>>> from mojolearn.model_selection import cross_val_predict
>>> rng = np.random.default_rng(0)
>>> X = rng.random((1000, 8), dtype=np.float32)
>>> y = rng.random(1000, dtype=np.float32)
>>> pred = cross_val_predict(mojolearn.Ridge(alpha=1.0), X, y, cv=5)
"""
_require_serial(n_jobs, 'raise', 'cross_val_predict')
X, y, folds = _cv_folds(estimator, X, y, cv, groups)
n = len(X)
return _cross_val_predict_native(estimator, X, y, folds, n, method)
def _cross_val_predict_native(estimator, X, y, folds, n, method):
"""`cross_val_predict` with no per-row Python (lane apple-fast-py2mojo-core):
the partition test is `check_indices_i64` over every fold's test rows
laid end to end (each row held out exactly once: n rows in all, none
twice; the folds' rows are already in range), and each fold's
predictions are put back in row order ON THE DEVICE (lane
cpu2-l4-modelsel): `msel_scatter_rows` casts each fold's predictions
and scatters them by the fold's resident test indices into one
resident int64 (integer predictions) or float64 (real predictions, or
any prediction with columns) block, read back once. The folds' rows are
device gathers out of the resident X and y (`_FoldRows`). A fold whose
predictions are not a numeric `Array` (str labels come back as a Python
list) sends the placement to the label loop below it (object labels,
the G5 input-prep case)."""
import ctypes
from ._buffer import _output_store
total = sum(int(test.size) for _, test in folds) # glue: fold sizes summed over the folds
if total != n:
raise ValueError('cross_val_predict only works for partitions')
held = _output_store('q', n)
at = 0
keep = []
for _, test in folds: # glue: fold sizes summed over the folds
t = test if test.dtype == '<i8' and test._has_order('C') else test.astype('<i8')._as_c()
ctypes.memmove(held.buffer_info()[0] + 8 * at, _addr_ro(t), 8 * t.size)
at += t.size
keep.append(t)
if int(_native('check_indices_i64')(held.buffer_info()[0], n, n)) != 0:
raise ValueError('cross_val_predict only works for partitions')
with _FoldRows(X, y, folds, device_rows=_device_rows_ok(estimator)) as rows:
preds = []
for i, t in enumerate(keep): # glue: one fit per fold
est = _clone(estimator)
Xtr, ytr, Xte, _ = rows.take(i)
est.fit(Xtr, ytr)
preds.append((getattr(est, method)(Xte), t))
return _cross_val_predict_place(preds, n, rows._res)
def _numeric_pred(pred):
"""`pred` as an Array when it is a numeric buffer or list an external
estimator returned (lane py-runtime-b: those are scattered natively
too), else `pred` unchanged (label objects keep the list route)."""
if isinstance(pred, Array):
return pred
try:
a = _materialize(pred, 'predictions')[0]
except (TypeError, ValueError, OverflowError):
return pred
return a if a.dtype in ('<f4', '<f8', '<i4', '<i8', '<u4', '<u1') else pred
def _cross_val_predict_place(preds, n, res):
preds = [(_numeric_pred(p), t) for p, t in preds] # glue: one conversion check per fold
numeric = all(isinstance(p, Array) and p.dtype in ('<f4', '<f8', '<i4', '<i8', '<u4', '<u1')
and p.ndim >= 1 and p.shape[0] == t.size for p, t in preds) # glue: per-fold dtype and shape checks
width = tuple(preds[-1][0].shape[1:]) if numeric else ()
if numeric and all(tuple(p.shape[1:]) == width for p, _ in preds): # glue: per-fold dtype and shape checks
integral = not width and all(p.dtype[1] in 'iu' for p, _ in preds) # glue: per-fold dtype and shape checks
dtype = '<i8' if integral else '<f8'
out = empty((n,) + width, dtype)
cols = 1
for w in width: # glue: the prediction shape's column count
cols *= int(w)
if cols and n:
block = int(_native('msel_alloc')(n * cols * 8))
try:
scatter = _native('msel_scatter_rows')
for p, t in preds: # glue: one device scatter per fold
if t.size:
src = p._as_c()
scatter(block, n, _MSEL_CODE[dtype], _addr_ro(src), _MSEL_CODE[src.dtype],
t.size, cols, res._entry(t, '<i8')[2])
_native('msel_read')(block, _addr(out), n * cols * 8)
finally:
_native('msel_free')(block)
return out
rows = [None] * n
width = None
for pred, t in preds: # cpu-route: label-object predictions (str/mixed) placed by list; numeric non-Array predictions are now materialized and scattered natively [py-data-loop]
vals = pred.tolist() if hasattr(pred, 'tolist') else list(pred)
for i, v in zip(t.tolist(), vals): # cpu-route: label-object predictions (str/mixed) placed by list; numeric non-Array predictions are now materialized and scattered natively [py-data-loop]
rows[i] = v
width = getattr(pred, 'shape', (0,))[1:] if hasattr(pred, 'shape') else ()
if width:
return Array.from_list([float(v) for row in rows for v in row], '<f8').reshape((n,) + tuple(width)) # cpu-route: label-object predictions (str/mixed) placed by list; numeric non-Array predictions are now materialized and scattered natively [py-data-loop]
if all(isinstance(v, numbers.Integral) for v in rows): # cpu-route: label-object predictions (str/mixed) placed by list; numeric non-Array predictions are now materialized and scattered natively [py-data-loop]
return Array.from_list(rows, '<i8')
if all(isinstance(v, numbers.Real) for v in rows): # cpu-route: label-object predictions (str/mixed) placed by list; numeric non-Array predictions are now materialized and scattered natively [py-data-loop]
return Array.from_list(rows, '<f8')
return rows
def _expansion_metrics_binding():
"""The x_metrics binding of the running tier (its host epilogues)."""
from ._expansion_metrics import _binding
return _binding(None)
[docs]
class ParameterGrid:
"""Grid of parameter combinations, iterated as dicts.
Follows scikit-learn's ``ParameterGrid``: every combination of each
dict's values, with keys in sorted order, and the dicts in list order.
Supports ``len()`` and indexing.
Parameters
----------
param_grid : dict or list of dicts
Maps each parameter name to a nonempty list of values. Raises
``TypeError`` for a value that is not a list-like, and
``ValueError`` for an empty one.
Examples
--------
>>> from mojolearn.model_selection import ParameterGrid
>>> list(ParameterGrid({'alpha': [0.1, 1.0], 'fit_intercept': [True, False]}))[0]
{'alpha': 0.1, 'fit_intercept': True}
"""
def __init__(self, param_grid):
if isinstance(param_grid, dict):
param_grid = [param_grid]
if not isinstance(param_grid, (list, tuple)):
raise TypeError(f'Parameter grid should be a dict or a list, got: {param_grid!r}')
for g in param_grid: # glue: parameter grid over the candidate dicts
if not isinstance(g, dict):
raise TypeError(f'Parameter grid is not a dict ({g!r})')
for k, v in g.items(): # glue: parameter grid over the candidate dicts
if isinstance(v, str) or not hasattr(v, '__iter__'):
raise TypeError(f'Parameter grid for parameter {k!r} needs to be a list, got: {v!r}')
if len(list(v)) == 0:
raise ValueError(f'Parameter grid for parameter {k!r} need to be a non-empty sequence.')
self.param_grid = [dict(g) for g in param_grid] # glue: parameter grid over the candidate dicts
def __iter__(self):
import itertools
for g in self.param_grid: # glue: parameter grid over the candidate dicts
keys = sorted(g) # glue: parameter grid over the candidate dicts
if not keys:
yield {}
continue
for combo in itertools.product(*[list(g[k]) for k in keys]): # glue: parameter grid over the candidate dicts
yield dict(zip(keys, combo))
def __len__(self):
total = 0
for g in self.param_grid: # glue: parameter grid over the candidate dicts
size = 1
for v in g.values(): # glue: parameter grid over the candidate dicts
size *= len(list(v))
total += size
return total
def __getitem__(self, ind):
return list(self)[ind]
[docs]
class ParameterSampler:
"""Random sample of parameter combinations, iterated as dicts.
Follows scikit-learn's ``ParameterSampler`` for lists of values: the
combinations are sampled without replacement, as a random permutation of
the full grid drawn by mojolearn's counter-based random generator. When
the grid has fewer than ``n_iter`` combinations, a warning is issued and
every combination is returned.
Parameters
----------
param_distributions : dict or list of dicts
Maps each parameter name to a list of values. scipy.stats
distributions are not supported and raise ``NotImplementedError``.
n_iter : int
Number of combinations to sample.
random_state : int or None, default None
Seed for the sampling.
Examples
--------
>>> from mojolearn.model_selection import ParameterSampler
>>> params = list(ParameterSampler({'alpha': [0.01, 0.1, 1.0, 10.0]},
... n_iter=2, random_state=0))
"""
# Maintainer note: scipy.stats distributions are refused because their
# draws are numpy's (x_metrics/split.mojo DEVIATION 6108).
def __init__(self, param_distributions, n_iter, *, random_state=None):
if isinstance(param_distributions, dict):
param_distributions = [param_distributions]
for d in param_distributions: # glue: parameter grid over the candidate dicts
for k, v in d.items(): # glue: parameter grid over the candidate dicts
if hasattr(v, 'rvs'):
raise NotImplementedError(
f'mojolearn ParameterSampler: {k!r} is a scipy.stats distribution; its draws are '
"numpy's, so only lists are sampled here (x_metrics/split.mojo DEVIATION 6108)")
if isinstance(v, str) or not hasattr(v, '__iter__'):
raise TypeError(f'Parameter value for {k!r} is not a list or a distribution ({v!r})')
self.param_distributions = param_distributions
self.n_iter = n_iter
self.random_state = random_state
def __iter__(self):
grid = list(ParameterGrid(self.param_distributions))
n_iter = self.n_iter
if len(grid) < n_iter:
warnings.warn(f'The total space of parameters {len(grid)} is smaller than n_iter={n_iter}. '
f'Running {len(grid)} iterations. For exhaustive searches, use GridSearchCV.',
UserWarning, stacklevel=2)
n_iter = len(grid)
perm = _rng(self.random_state).permutation(len(grid))
for j in perm[:n_iter]: # glue: parameter grid over the candidate dicts
yield grid[j]
def __len__(self):
return min(self.n_iter, len(ParameterGrid(self.param_distributions)))
def _rank(values):
"""scikit-learn's rank_test_score: 'min' ranking of the scores,
descending (1 is best)."""
order = sorted(set(values), reverse=True) # glue: explicit scalar tail, one score per candidate
pos, start = {}, 1
for v in order: # glue: explicit scalar tail, one score per candidate
pos[v] = start
start += values.count(v)
return [pos[v] for v in values] # glue: explicit scalar tail, one rank per candidate
class _BaseSearch:
_estimator_type = None
def __init__(self, estimator, *, scoring=None, n_jobs=None, refit=True, cv=None, verbose=0,
pre_dispatch='2*n_jobs', error_score=float('nan'), return_train_score=False):
self.estimator = estimator
self.scoring = scoring
self.n_jobs = n_jobs
self.refit = refit
self.cv = cv
self.verbose = verbose
self.pre_dispatch = pre_dispatch
self.error_score = error_score
self.return_train_score = return_train_score
@property
def _estimator_type(self):
return getattr(self.estimator, '_estimator_type', None)
def get_params(self, deep=False):
"""Return the search's constructor parameters as a dict."""
import inspect
names = [p for p in inspect.signature(type(self).__init__).parameters if p != 'self'] # glue: estimator parameters over the init names
return {k: getattr(self, k) for k in names} # glue: estimator parameters over the init names
def set_params(self, **params):
"""Set constructor parameters by name and return ``self``."""
for k, v in params.items(): # glue: estimator parameters over the init names
setattr(self, k, v)
return self
def fit(self, X, y=None, *, groups=None):
"""Cross-validate every candidate and, with ``refit``, fit the best one.
The folds are drawn once and every candidate is scored on the same
folds. Fits run serially.
Parameters
----------
X : array-like of shape (n_samples, n_features)
Training data.
y : array-like of shape (n_samples,), default None
Targets.
groups : array-like of shape (n_samples,), default None
Group labels for a splitter that reads them.
Returns
-------
self
Raises
------
ValueError
If there are no candidates, or if ``scoring`` names several
metrics and ``refit`` is neither False, one of their names, nor a
callable.
"""
_require_serial(self.n_jobs, self.error_score, type(self).__name__)
candidates = list(self._candidates())
if not candidates:
raise ValueError('No fits were performed. Was the CV iterator empty? Were there no candidates?')
multi = _scorers(self.scoring)
names = list(multi) if multi is not None else ['score']
if multi is not None and self.refit is not False and (
not isinstance(self.refit, str) or self.refit not in multi) and not callable(self.refit):
raise ValueError('For multi-metric scoring, the parameter refit must be set to a scorer key '
'or a callable to refit an estimator with the best parameter setting on the '
'whole data and make the best_* attributes available for that metric.')
scoring = multi if multi is not None else self.scoring
results = {'params': candidates}
per = {nm: [] for nm in names} # glue: result lists over the scorer names
trains = {nm: [] for nm in names} # glue: result lists over the scorer names
n_splits = None
# the folds are drawn ONCE for every candidate (scikit-learn's
# evaluate_candidates materializes cv.split once)
Xf, yf, folds = _cv_folds(_Pinned(self.estimator), X, y, self.cv, groups)
# X, y and the fold indices stay resident on the device for every
# candidate (lane cpu2-l4-modelsel)
with _FoldRows(Xf, yf, folds, device_rows=_device_rows_ok(self.estimator, scoring)) as rows:
for params in candidates: # glue: one cross-validation per candidate
est = _clone(self.estimator)
if hasattr(est, 'set_params'):
est.set_params(**params)
else:
for k, v in params.items(): # glue: candidate parameters set by name
setattr(est, k, v)
est = _Pinned(est)
cvr = _cross_validate_folds(est, Xf, yf, folds, scoring,
return_train_score=self.return_train_score,
error_score=self.error_score, rows=rows)
for nm in names: # glue: result lists over the scorer names
per[nm].append(cvr[f'test_{nm}'].tolist())
if self.return_train_score:
trains[nm].append(cvr[f'train_{nm}'].tolist())
n_splits = len(cvr['fit_time'])
for k in sorted({k for p in candidates for k in p}): # glue: param columns over the candidate keys
results[f'param_{k}'] = [p.get(k) for p in candidates] # glue: param columns over the candidate keys
for nm in names: # glue: result lists over the scorer names
suffix = '' if multi is None else f'_{nm}'
for i in range(n_splits): # glue: one score column per split index
results[f'split{i}_test_score{suffix}'] = Array.from_list([s[i] for s in per[nm]], '<f8') # glue: one score column per split index
# glue: explicit scalar tail over the candidates x folds scores (Python floats, not data)
means = _row_means(per[nm])
stds = _row_stds(per[nm], means)
results[f'mean_test_score{suffix}'] = Array.from_list(means, '<f8')
results[f'std_test_score{suffix}'] = Array.from_list(stds, '<f8')
results[f'rank_test_score{suffix}'] = Array.from_list(_rank(means), '<i4')
if self.return_train_score:
tm = _row_means(trains[nm])
results[f'mean_train_score{suffix}'] = Array.from_list(tm, '<f8')
self.cv_results_ = results
self.n_splits_ = n_splits
self.multimetric_ = multi is not None
key = names[0] if multi is None else (self.refit if isinstance(self.refit, str) else None)
if key is not None:
suffix = '' if multi is None else f'_{key}'
ranks = results[f'rank_test_score{suffix}'].tolist()
self.best_index_ = ranks.index(1)
self.best_score_ = results[f'mean_test_score{suffix}'].tolist()[self.best_index_]
self.best_params_ = candidates[self.best_index_]
elif callable(self.refit):
self.best_index_ = int(self.refit(results))
self.best_params_ = candidates[self.best_index_]
if self.refit is not False:
best = _clone(self.estimator)
if hasattr(best, 'set_params'):
best.set_params(**self.best_params_)
Xa = _materialize(X, 'X')[0]
best.fit(Xa, y) if y is not None else best.fit(Xa)
self.best_estimator_ = best
self.scorer_ = get_scorer(self.scoring) if multi is None else multi
return self
def _best(self):
if not hasattr(self, 'best_estimator_'):
raise AttributeError(f'This {type(self).__name__} instance was initialized with refit=False or '
'is not fitted.')
return self.best_estimator_
def predict(self, X):
"""Call ``predict`` on ``best_estimator_``."""
return self._best().predict(X)
def predict_proba(self, X):
"""Call ``predict_proba`` on ``best_estimator_``."""
return self._best().predict_proba(X)
def decision_function(self, X):
"""Call ``decision_function`` on ``best_estimator_``."""
return self._best().decision_function(X)
def transform(self, X):
"""Call ``transform`` on ``best_estimator_``."""
return self._best().transform(X)
def score(self, X, y=None):
"""Score ``best_estimator_`` on X and y with the search's scorer.
With several metrics, the metric named by ``refit`` is used.
"""
sc = get_scorer(self.scoring) if not self.multimetric_ else self.scorer_[self.refit]
return _score(self._best(), X, y, sc)
@property
def classes_(self):
"""The class labels of ``best_estimator_``."""
return self._best().classes_
class _Pinned:
"""A configured candidate that cross_validate may clone: `_clone` of it
returns a fresh clone of the configured estimator."""
def __init__(self, est):
self._est = est
def __sklearn_clone__(self):
return _clone(self._est)
@property
def _estimator_type(self):
return getattr(self._est, '_estimator_type', None)
[docs]
class GridSearchCV(_BaseSearch):
"""Exhaustive search over a parameter grid by cross-validation.
Follows scikit-learn's ``GridSearchCV``. Every combination in
``ParameterGrid(param_grid)`` is cross-validated on the same folds, one
fit at a time. With ``refit``, the best combination is then fit on all
of X and y, and ``predict``, ``predict_proba``, ``decision_function``,
``transform`` and ``score`` call that fitted estimator.
Parameters
----------
estimator : estimator
An unfitted estimator with ``get_params`` and ``fit``.
param_grid : dict or list of dicts
Parameter names mapped to lists of values.
scoring : str, callable, list, tuple, set, dict or None, default None
As in ``cross_validate``. None uses the estimator's ``score``.
n_jobs : int or None, default None
Only None or 1 is supported; other values raise
``NotImplementedError``.
refit : bool, str or callable, default True
Refit the best candidate on the whole data. With several metrics,
the name of the metric that picks the best candidate, or a callable
that takes ``cv_results_`` and returns the best index.
cv : int, splitter, iterable or None, default None
As in ``check_cv``: None means five folds, stratified for a
classifier.
verbose : int, default 0
Accepted for compatibility; nothing is printed.
pre_dispatch : str, default '2*n_jobs'
Accepted for compatibility; not used.
error_score : 'raise' or float, default nan
``'raise'`` propagates a fit error; a number is recorded as the
fold's score instead.
return_train_score : bool, default False
Also record training scores (``mean_train_score``).
Attributes
----------
cv_results_ : dict
``params``, ``param_<name>``, ``split<i>_test_score``,
``mean_test_score``, ``std_test_score`` and ``rank_test_score`` (1 is
best); with several metrics each score key ends in ``_<metric>``.
best_index_ : int
Index of the best candidate in ``cv_results_``.
best_score_ : float
Mean cross-validated score of the best candidate (not set when
``refit`` is a callable).
best_params_ : dict
Parameters of the best candidate.
best_estimator_ : estimator
The best candidate refit on the whole data (only with ``refit``).
scorer_ : callable or dict
The scorer(s) used (only with ``refit``).
n_splits_ : int
Number of cross-validation splits.
multimetric_ : bool
Whether several metrics were scored.
Examples
--------
>>> import numpy as np, mojolearn
>>> from mojolearn.model_selection import GridSearchCV
>>> rng = np.random.default_rng(0)
>>> X = rng.random((1000, 8), dtype=np.float32)
>>> y = rng.random(1000, dtype=np.float32)
>>> search = GridSearchCV(mojolearn.Ridge(), {'alpha': [0.1, 1.0, 10.0]}, cv=5)
>>> search = search.fit(X, y)
>>> best = search.best_params_
"""
def __init__(self, estimator, param_grid, *, scoring=None, n_jobs=None, refit=True, cv=None, verbose=0,
pre_dispatch='2*n_jobs', error_score=float('nan'), return_train_score=False):
super().__init__(estimator, scoring=scoring, n_jobs=n_jobs, refit=refit, cv=cv, verbose=verbose,
pre_dispatch=pre_dispatch, error_score=error_score,
return_train_score=return_train_score)
self.param_grid = param_grid
def _candidates(self):
return ParameterGrid(self.param_grid)
[docs]
class RandomizedSearchCV(_BaseSearch):
"""Randomized search over parameter lists by cross-validation.
Follows scikit-learn's ``RandomizedSearchCV`` for lists of values:
``n_iter`` combinations are sampled without replacement by
``ParameterSampler`` and each is cross-validated on the same folds, one
fit at a time. scipy.stats distributions are not supported. Fitting,
refitting, the result attributes and the prediction methods are as in
``GridSearchCV``.
Parameters
----------
estimator : estimator
An unfitted estimator with ``get_params`` and ``fit``.
param_distributions : dict or list of dicts
Parameter names mapped to lists of values.
n_iter : int, default 10
Number of combinations to try.
scoring : str, callable, list, tuple, set, dict or None, default None
As in ``cross_validate``.
n_jobs : int or None, default None
Only None or 1 is supported.
refit : bool, str or callable, default True
As in ``GridSearchCV``.
cv : int, splitter, iterable or None, default None
As in ``check_cv``.
verbose : int, default 0
Accepted for compatibility; nothing is printed.
pre_dispatch : str, default '2*n_jobs'
Accepted for compatibility; not used.
random_state : int or None, default None
Seed for the sampling (mojolearn's counter-based generator).
error_score : 'raise' or float, default nan
As in ``GridSearchCV``.
return_train_score : bool, default False
Also record training scores.
Attributes
----------
cv_results_, best_index_, best_score_, best_params_, best_estimator_, scorer_, n_splits_, multimetric_
As in ``GridSearchCV``.
Examples
--------
>>> import numpy as np, mojolearn
>>> from mojolearn.model_selection import RandomizedSearchCV
>>> rng = np.random.default_rng(0)
>>> X = rng.random((1000, 8), dtype=np.float32)
>>> y = rng.random(1000, dtype=np.float32)
>>> search = RandomizedSearchCV(mojolearn.Ridge(), {'alpha': [0.01, 0.1, 1.0, 10.0]},
... n_iter=2, random_state=0).fit(X, y)
"""
def __init__(self, estimator, param_distributions, *, n_iter=10, scoring=None, n_jobs=None, refit=True,
cv=None, verbose=0, pre_dispatch='2*n_jobs', random_state=None, error_score=float('nan'),
return_train_score=False):
super().__init__(estimator, scoring=scoring, n_jobs=n_jobs, refit=refit, cv=cv, verbose=verbose,
pre_dispatch=pre_dispatch, error_score=error_score,
return_train_score=return_train_score)
self.param_distributions = param_distributions
self.n_iter = n_iter
self.random_state = random_state
def _candidates(self):
return ParameterSampler(self.param_distributions, self.n_iter, random_state=self.random_state)
[docs]
def validation_curve(estimator, X, y, *, param_name, param_range, groups=None, cv=None, scoring=None,
n_jobs=None, error_score=float('nan')):
"""Training and test scores for each value of one parameter.
Follows scikit-learn's ``validation_curve``. For each value in
``param_range``, the estimator with that parameter set is
cross-validated on the same folds, one fit at a time.
Parameters
----------
estimator : estimator
An unfitted estimator with ``get_params``, ``set_params`` and
``fit``.
X : array-like of shape (n_samples, n_features)
Training data.
y : array-like of shape (n_samples,)
Targets.
param_name : str
Name of the parameter to vary.
param_range : sequence
Values of the parameter.
groups : array-like of shape (n_samples,), default None
Group labels for a splitter that reads them.
cv : int, splitter, iterable or None, default None
As in ``check_cv``.
scoring : str, callable or None, default None
As in ``cross_val_score``.
n_jobs : int or None, default None
Only None or 1 is supported.
error_score : 'raise' or float, default nan
``'raise'`` propagates a fit error; a number is recorded instead.
Returns
-------
train_scores : Array of shape (len(param_range), n_splits), float64
test_scores : Array of shape (len(param_range), n_splits), float64
Examples
--------
>>> import numpy as np, mojolearn
>>> from mojolearn.model_selection import validation_curve
>>> rng = np.random.default_rng(0)
>>> X = rng.random((1000, 8), dtype=np.float32)
>>> y = rng.random(1000, dtype=np.float32)
>>> train, test = validation_curve(mojolearn.Ridge(), X, y, param_name='alpha',
... param_range=[0.1, 1.0, 10.0], cv=5)
"""
_require_serial(n_jobs, error_score, 'validation_curve')
# the folds are drawn ONCE for every parameter value, as scikit-learn does
Xf, yf, folds = _cv_folds(_Pinned(estimator), X, y, cv, groups)
tr, te = [], []
with _FoldRows(Xf, yf, folds, device_rows=_device_rows_ok(estimator, scoring)) as rows:
for v in param_range: # glue: one cross-validation per parameter value
est = _clone(estimator)
est.set_params(**{param_name: v})
r = _cross_validate_folds(_Pinned(est), Xf, yf, folds, scoring, return_train_score=True,
error_score=error_score, rows=rows)
tr.append(r['train_score'].tolist())
te.append(r['test_score'].tolist())
k = len(tr[0])
return (Array.from_list([v for row in tr for v in row], '<f8').reshape((len(tr), k)), # glue: score rows flattened into the result block
Array.from_list([v for row in te for v in row], '<f8').reshape((len(te), k))) # glue: score rows flattened into the result block
[docs]
def learning_curve(estimator, X, y, *, groups=None, train_sizes=(0.1, 0.325, 0.55, 0.775, 1.0), cv=None,
scoring=None, n_jobs=None, shuffle=False, random_state=None, error_score=float('nan'),
return_times=False):
"""Training and test scores for growing training-set sizes.
Follows scikit-learn's ``learning_curve``, without
``exploit_incremental_learning``. For each size and fold, a fresh clone
is fit on the first ``size`` training rows of that fold and scored on
those rows and on the fold's test rows. Fits run one at a time.
Parameters
----------
estimator : estimator
An unfitted estimator with ``get_params`` and ``fit``.
X : array-like of shape (n_samples, n_features)
Training data.
y : array-like of shape (n_samples,)
Targets.
groups : array-like of shape (n_samples,), default None
Group labels for a splitter that reads them.
train_sizes : sequence of float or int, default (0.1, 0.325, 0.55, 0.775, 1.0)
Floats are fractions of the smallest training fold (rounded down),
ints are row counts. Each must resolve to between 1 and the smallest
training fold's size, or ``ValueError`` is raised. Duplicates are
dropped and the sizes sorted.
cv : int, splitter, iterable or None, default None
As in ``check_cv``.
scoring : str, callable or None, default None
As in ``cross_val_score``.
n_jobs : int or None, default None
Accepted for compatibility; fits always run serially.
shuffle : bool, default False
Shuffle each fold's training rows once before taking the prefixes.
random_state : int or None, default None
Seed for the shuffle (mojolearn's counter-based generator).
error_score : 'raise' or float, default nan
Accepted for compatibility; a fit error always propagates.
return_times : bool, default False
Also return fit and score times.
Returns
-------
train_sizes_abs : Array of shape (n_sizes,), int64
The training-set sizes used.
train_scores : Array of shape (n_sizes, n_splits), float64
test_scores : Array of shape (n_sizes, n_splits), float64
fit_times : Array of shape (n_sizes, n_splits), float64
Wall-clock seconds; only with ``return_times=True``.
score_times : Array of shape (n_sizes, n_splits), float64
Wall-clock seconds; only with ``return_times=True``.
Examples
--------
>>> import numpy as np, mojolearn
>>> from mojolearn.model_selection import learning_curve
>>> rng = np.random.default_rng(0)
>>> X = rng.random((1000, 8), dtype=np.float32)
>>> y = rng.random(1000, dtype=np.float32)
>>> sizes, train, test = learning_curve(mojolearn.Ridge(), X, y, cv=5)
"""
import time
X, y, folds = _cv_folds(estimator, X, y, cv, groups)
n_max = min(len(tr) for tr, _ in folds) # glue: smallest training fold size over the folds
sizes = []
for s in train_sizes: # glue: train size arguments resolved and sorted
a = int(math.floor(s * n_max)) if isinstance(s, float) else int(s)
if not 0 < a <= n_max:
raise ValueError(f'train_sizes has been interpreted as absolute numbers of training samples and '
f'must be within (0, {n_max}], but is within [{a}, {a}].')
sizes.append(a)
sizes = sorted(set(sizes)) # glue: train size arguments resolved and sorted
# scikit-learn permutes each fold's training rows ONCE (in fold order)
# and takes nested prefixes of that one order for every size
rng = _rng(random_state) if shuffle else None
orders = []
# the draws, in fold order, as Int64 rows from one device program,
# and each fold's training rows reordered by the core gather
perms = rng.permutation_rows([int(tr.size) for tr, _ in folds]) if rng is not None else None # glue: one device permutation length per fold
for f, (train, _) in enumerate(folds): # glue: one device permutation length per fold
tr = train if train.dtype == '<i8' and train._has_order('C') else train.astype('<i8')._as_c()
if perms is not None and tr.size:
perm = Array._owned(perms[f], (tr.size,), '<i8', 'C')
out = empty((tr.size,), '<i8')
_native('gather_i64')(_addr_ro(tr), tr.size, _addr_ro(perm), tr.size, _addr(out))
tr = out
orders.append(tr)
tr_s, te_s, ft, st = [], [], [], []
# X, y and the test folds resident on the device for every size; each
# size's training prefix is put once and freed after its fit and score
res = _Resident()
def take(values, idx):
return _take_rows(values, idx) if isinstance(values, list) else res.take(values, idx)
try:
for a in sizes: # glue: one fit per size and fold
row_tr, row_te, row_ft, row_st = [], [], [], []
for (train, test), tr_idx in zip(folds, orders): # glue: one fit per size and fold
sub = tr_idx[0:a]
est = _clone(estimator)
t0 = time.perf_counter()
Xs, ys = take(X, sub), take(y, sub)
est.fit(Xs, ys)
t1 = time.perf_counter()
sc = get_scorer(scoring)
row_tr.append(_score(est, Xs, ys, sc))
row_te.append(_score(est, take(X, test), take(y, test), sc))
res.release(sub)
row_ft.append(t1 - t0)
row_st.append(time.perf_counter() - t1)
tr_s.append(row_tr)
te_s.append(row_te)
ft.append(row_ft)
st.append(row_st)
finally:
res.close()
k = len(folds)
shape = (len(sizes), k)
pack = lambda m: Array.from_list([v for row in m for v in row], '<f8').reshape(shape) # glue: score rows flattened into the result block
out = [Array.from_list(sizes, '<i8'), pack(tr_s), pack(te_s)]
if return_times:
out += [pack(ft), pack(st)]
return tuple(out)
[docs]
def permutation_test_score(estimator, X, y, *, groups=None, cv=None, n_permutations=100, n_jobs=None,
random_state=0, verbose=0, scoring=None, fit_params=None, params=None):
"""Test the significance of a cross-validated score by permuting y.
Follows scikit-learn's ``permutation_test_score``. The mean
cross-validated score on the real targets is compared with the mean
scores after ``n_permutations`` random permutations of ``y`` (within
each group when ``groups`` is given). The permutations come from
mojolearn's counter-based random generator.
Parameters
----------
estimator : estimator
An unfitted estimator with ``get_params`` and ``fit``.
X : array-like of shape (n_samples, n_features)
Training data.
y : array-like of shape (n_samples,)
Targets.
groups : array-like of shape (n_samples,), default None
Permute ``y`` only within each group; also passed to the splitter.
cv : int, splitter, iterable or None, default None
As in ``check_cv``.
n_permutations : int, default 100
Number of permutations.
n_jobs : int or None, default None
Accepted for compatibility; fits run serially.
random_state : int or None, default 0
Seed for the permutations.
verbose : int, default 0
Accepted for compatibility; nothing is printed.
scoring : str, callable or None, default None
As in ``cross_val_score``.
fit_params, params : dict or None, default None
Accepted for compatibility; not passed to ``fit``.
Returns
-------
score : float
Mean cross-validated score on the real targets.
permutation_scores : Array of shape (n_permutations,), float64
Mean score for each permutation.
pvalue : float
``(C + 1) / (n_permutations + 1)``, where ``C`` is the number of
permutation scores greater than or equal to ``score``.
Examples
--------
>>> import numpy as np, mojolearn
>>> from mojolearn.model_selection import permutation_test_score
>>> rng = np.random.default_rng(0)
>>> X = rng.random((1000, 8), dtype=np.float32)
>>> y = rng.random(1000, dtype=np.float32)
>>> score, perm_scores, pvalue = permutation_test_score(
... mojolearn.Ridge(), X, y, cv=5, n_permutations=20)
"""
fast = _NativePermutation.get(estimator, X, y, groups, cv)
if fast is not None:
return fast.run(estimator, groups, cv, n_permutations, random_state, get_scorer(scoring))
Xa = _materialize(X, 'X')[0]
yl = flatten_labels(y)
sc = get_scorer(scoring)
def mean_score(yv):
yarr = _materialize(Array.from_list(yv, '<f4' if isinstance(yv[0], float) else '<i4'), 'y')[0]
r = cross_validate(estimator, Xa, yarr, groups=groups, cv=cv, scoring=sc)
return _row_means([r['test_score'].tolist()])[0]
score = mean_score(yl)
rng = _rng(random_state)
perm_scores = []
for _ in range(n_permutations): # cpu-route: definition route for label objects (str/mixed) the native permutation cannot hold [py-data-loop]
if groups is None:
perm = rng.permutation(len(yl))
yp = [yl[j] for j in perm] # cpu-route: definition route for label objects (str/mixed) the native permutation cannot hold [py-data-loop]
else:
g = flatten_labels(groups)
yp = list(yl)
for gv in sorted(set(g)): # cpu-route: definition route for label objects (str/mixed) the native permutation cannot hold [py-data-loop,py-reduce]
rows = [i for i, v in enumerate(g) if v == gv] # cpu-route: definition route for label objects (str/mixed) the native permutation cannot hold [py-data-loop]
perm = rng.permutation(len(rows))
for i, j in zip(rows, perm): # cpu-route: definition route for label objects (str/mixed) the native permutation cannot hold [py-data-loop]
yp[i] = yl[rows[j]]
perm_scores.append(mean_score(yp))
pvalue = (sum(1 for s in perm_scores if s >= score) + 1.0) / (n_permutations + 1) # cpu-route: definition route for label objects (str/mixed), one score per permutation [py-data-loop,py-reduce]
return score, Array.from_list(perm_scores, '<f8'), pvalue
class _NativePermutation:
"""`permutation_test_score` with y kept as a buffer (lane/py-misc-msel).
The definition above rebuilds y from a Python list per permutation
(`Array.from_list(yv, '<f4' if isinstance(yv[0], float) else '<i4')`),
permutes it by a list comprehension, and with groups filters every row
once per group per permutation. Here y is converted ONCE into that same
dtype (the same words: the array('f') / array('i') item setter over the
same Python scalars, or a buffer of the same values), and each permuted
y is a device gather of it (`_Resident.take`, base y resident for every
permutation; lane cpu2-l4-modelsel) by an Int64 row index: without groups the
permutation itself (`permutation_rows`, the same draw), with groups the
index the definition builds, from the same per-group draws in the same
sorted-group order (one program for all of them), each group's rows
permuted by `gather_i64` over the group-sorted rows and put back in row
order by the inverse sort. Folds are computed once when the splitter
cannot read y and is deterministic (`_Y_FREE`), which is exactly when
every `cross_validate` call of the definition draws the same folds.
Only ints (bools) or only floats take this route; anything else, or a
missing helper, takes the definition."""
@classmethod
def get(cls, estimator, X, y, groups, cv):
if not _msel_native() or _sabotage_requested():
return None
gather64 = _native('gather_i64')
if gather64 is None:
return None
base = cls._base(y)
if base is None or base.size < 2:
return None
self = cls()
self.X = _materialize(X, 'X')[0]
self.base, self.n = base, base.size
self.gather64 = gather64
return self
@staticmethod
def _base(y):
"""y as the definition's first `mean_score` array, or None."""
if isinstance(y, (list, tuple)):
kinds = set(map(type, y))
if kinds and kinds <= {int, bool}:
code, dtype = 'i', '<i4'
elif kinds == {float}:
code, dtype = 'f', '<f4'
else:
return None
try:
store = array.array(code, y)
except OverflowError:
return None
return Array._owned(store, (len(store),), dtype, 'C')
try:
a = _materialize(y, 'y')[0]
except (TypeError, ValueError):
return None
if a.ndim != 1 or a.size < 1:
return None
if a.dtype in ('<f4', '<f8'):
from ._buffer import as_f32_c
return as_f32_c(a, ndim=1, name='y')[0]
if a.dtype == '<i4':
return a._as_c()
if a.dtype in ('<i8', '<u1', '<u4'):
# the int32 narrowing in the base binding's cast_elements (a
# device twin on GPU builds); 1 is the OverflowError of the
# array('i') route, which hands back to the definition
c = a._as_c()
narrow = empty((c.size,), '<i4')
if int(_native('cast_elements')(_addr_ro(c), _MSEL_CODE[c.dtype], _addr(narrow), 2, c.size)) != 0:
return None
return narrow
if a.dtype in ('<i2', '<u2', '<i1'):
try:
narrow = array.array('i', a.tolist()) # glue: dtypes the native casts do not name
except OverflowError:
return None
return Array._owned(narrow, (len(narrow),), '<i4', 'C')
return None
def _folds(self, estimator, yarr, groups, cv):
return _cv_folds(estimator, self.X, yarr, cv, groups)[2]
def run(self, estimator, groups, cv, n_permutations, random_state, sc):
n, base = self.n, self.base
splitter = None if cv is None or isinstance(cv, numbers.Integral) else cv
reuse = splitter is not None and _y_free(splitter)
folds0 = self._folds(estimator, base, groups, cv)
# lane metrics-apple3: X's fold rows are the same for every
# permutation that runs on folds0 (only y is permuted)
rows0 = _FoldRows(self.X, None, folds0, keep_y=False, device_rows=_device_rows_ok(estimator, sc))
try:
return self._run(estimator, groups, cv, n_permutations, random_state, sc, reuse, folds0, rows0)
finally:
rows0.close()
def _run(self, estimator, groups, cv, n_permutations, random_state, sc, reuse, folds0, rows0):
n, base = self.n, self.base
def mean_score(yarr):
folds = folds0 if reuse or yarr is base else self._folds(estimator, yarr, groups, cv)
r = _cross_validate_folds(estimator, self.X, yarr, folds, sc,
rows=rows0 if folds is folds0 else None)
return _row_means([r['test_score'].tolist()])[0]
score = mean_score(base)
rng = _rng(random_state)
order = None if groups is None else self._group_order(groups)
perm_scores = []
for _ in range(n_permutations): # glue: one permuted cross-validation per permutation
if order is None:
idx = rng.permutation_rows([n])[0]
else:
idx = self._group_index(rng, order)
# the permuted y: one device gather out of the resident base y
idx = Array._owned(idx, (n,), '<i8', 'C')
yp = rows0._res.take(base, idx)
rows0._res.release(idx)
perm_scores.append(mean_score(yp))
pvalue = (sum(1 for s in perm_scores if s >= score) + 1.0) / (n_permutations + 1) # glue: explicit scalar tail, one score per permutation
return score, Array.from_list(perm_scores, '<f8'), pvalue
def _group_order(self, groups):
"""(sizes, sorted rows, inverse) of the groups in sorted-group order."""
from ._expansion_metrics import _Prog, _execute
gc = _GroupCodes.get(groups, self.n, 'groups') if self.n >= _NATIVE_MIN_ROWS else None
if gc is None:
from ._labels import encode_labels
classes, codes = encode_labels(flatten_labels(groups))
if codes.size != self.n:
raise ValueError('groups and y have different lengths')
m, codes = len(classes), codes
else:
m, codes = gc.m, gc.codes
n = self.n
prog = _Prog()
key = prog.put_i32(codes)
off = prog.want(prog.alloc(m + 1), m + 1)
ordr = prog.alloc(n)
prog.stage("group_sort", 1, key, n, m, off, ordr)
off2 = prog.alloc(n + 1)
inv = prog.alloc(n)
# the inverse of a permutation is its stable sort by value
prog.stage("group_sort", 1, ordr, n, n, off2, inv)
w_ord = prog.want(prog.alloc(2 * n), 2 * n)
w_inv = prog.want(prog.alloc(2 * n), 2 * n)
prog.stage("rows64", n, ordr, w_ord)
prog.stage("rows64", n, inv, w_inv)
_execute(prog, None)
offs = prog.ints(off, m + 1)
sizes = [offs[g + 1] - offs[g] for g in range(m)] # glue: group sizes from the m+1 offsets
return sizes, prog.words(w_ord, 2 * n, "q"), prog.words(w_inv, 2 * n, "q")
def _group_index(self, rng, order):
"""The definition's `yp[rows[i]] = yl[rows[perm[i]]]` as a row index."""
sizes, ordr, inv = order
n = self.n
src = array.array('q', bytes(8 * n))
s0, o0 = src.buffer_info()[0], ordr.buffer_info()[0]
at = 0
for size, perm in zip(sizes, rng.permutation_rows(sizes)): # glue: one device gather per group (m groups)
self.gather64(o0 + 8 * at, size, perm.buffer_info()[0], size, s0 + 8 * at)
at += size
idx = array.array('q', bytes(8 * n))
self.gather64(s0, n, inv.buffer_info()[0], n, idx.buffer_info()[0])
return idx
def _y_free(splitter):
"""True when `splitter.split` cannot read y and draws the same folds on
every call (an int seed, or no shuffle)."""
t = type(splitter)
if t in (LeaveOneGroupOut, LeavePGroupsOut, TimeSeriesSplit, LeaveOneOut, LeavePOut, PredefinedSplit,
_IterableCV):
return True
seeded = isinstance(getattr(splitter, 'random_state', None), numbers.Integral)
if t in (KFold, GroupKFold):
return not splitter.shuffle or seeded
if t in (ShuffleSplit, GroupShuffleSplit):
return seeded
return False