Source code for mojolearn.preprocessing

# SPDX-License-Identifier: Apache-2.0
"""Feature scaling: :class:`StandardScaler` and :class:`MinMaxScaler`.

Both scalers take dense two-dimensional float32 input, compute their
statistics in float32, treat NaN as a missing value and support the
``numeric_mode`` setting. Other preprocessing estimators (encoders,
imputers, transformers) are available from the top-level ``mojolearn``
namespace.
"""
import ctypes
import struct
from . import _portable_math as math
import numbers
from ._array import Array
from ._buffer import materialize_f32_lists, all_finite, empty, zeros, full, as_f32_c
from ._labels import is_bool

from . import _backend, _serialize
from ._arrays import _addr, _addr_ro

__all__ = ['MinMaxScaler', 'StandardScaler']


class NotFittedError(ValueError, AttributeError):
    """Raised by transform before fit, with scikit-learn's `NotFittedError`
    bases (ValueError and AttributeError), so callers that catch either keep
    working without this module importing scikit-learn."""

#: The saved-model format of both scalers (lane/inference-linear-svm,
#: 2026-09-15): `mojolearn.host_model(path)` transforms from it on a CPU.
_SCALER_FORMAT = "mojolearn-scaler-1"


def _require_training(estimator):
    """The CPU inference boundary for the scalers, which do not inherit
    `NumericModeMixin`'s guard: on a CPU-only install `fit` refuses outside
    `mojolearn._cpu_reference.reference_training()`."""
    from ._cpu_reference import require_training
    require_training(estimator)


def _saved_statistic(arrays, name, d, path):
    value = _serialize.exact(arrays, name, "<f4")
    if value.ndim != 1 or value.size != d:
        raise ValueError(f"mojolearn: {path!r} {name} does not match n_features_in_")
    return value


def _scaler_header(arrays, path, cls, fields):
    """The format, estimator, numeric mode and `meta` checks both loads
    share; returns `(mode, meta)`."""
    saved_as = _serialize.scalar_str(arrays, "estimator")
    if saved_as not in (c.__name__ for c in cls.__mro__):  # glue: walks the class hierarchy names
        raise ValueError(f"mojolearn: {path!r} was saved by {saved_as}, not {cls.__name__}")
    mode = _serialize.scalar_str(arrays, "numeric_mode")
    if mode not in ("fast", "deterministic", "identical"):
        raise ValueError(f"mojolearn: invalid saved numeric_mode {mode!r}")
    meta = _serialize.exact(arrays, "meta", "<i8")
    if meta.size != fields:
        raise ValueError(f"mojolearn: {path!r} meta holds {meta.size} fields, {fields} are needed")
    return mode, meta


def _prep():
    from . import _expansion_prep
    return _expansion_prep


def _refuse_inf(pr, st, d):
    """col_stats rows 3 and 4 (min and max over the non-NaN entries): an
    infinity is refused as the reference's `ensure_all_finite='allow-nan'`."""
    lo, hi = pr.values(st + 3 * d, d), pr.values(st + 4 * d, d)
    if any(math.isinf(v) for v in lo + hi):  # glue: raises on an infinite per-column statistic
        raise ValueError('Scaler input contains infinity; only NaN may be missing')


def _nan_scan_fill(mode, values, fill_row):
    """One x_prep program over X with NaN: col_stats (count, mean, var, min,
    max, maxabs over the non-NaN entries; an infinity refused) and X with
    every NaN replaced by the per-column value of col_stats row `fill_row`
    (3: the column's minimum, which leaves its min and max unchanged; None:
    zero). Returns (filled X, counts, colnan): colnan the int32 words
    count == 0 (c2_cnt0, lane cpu2-l3-prep). Data movement only: the filled
    X feeds the binding's own fit or transform, so every finite entry takes
    the same arithmetic as a NaN-free call."""
    P = _prep()
    n, d = values.shape
    pr = P._Prog()
    xo = pr.put(values)
    st = pr.alloc(6 * d)
    keep = pr.put_list(range(d))
    stat = st + fill_row * d if fill_row is not None else pr.put_list([0.0] * d)
    out = pr.alloc(n * d)
    pr.stage("col_stats", d, xo, n, d, st)
    pr.stage("fill", n * d, xo, n, d, stat, out, keep, d)
    cn = pr.alloc(d)
    pr.stage("c2_cnt0", d, st, cn)
    pr.run(mode)
    _refuse_inf(pr, st, d)
    return pr.get(out, (n, d)), pr.get(st, d), pr.get_i32(cn, d)


def _nan_keep(mode, values, transformed, colnan):
    """The transform of X with NaN: NaN entries of X kept bit for bit, a
    column never seen in fit (NaN statistics; `colnan` int32 words, or None
    for none) all NaN, else the binding's transform of the NaN-filled X
    (x_prep `nan_keep`)."""
    P = _prep()
    n, d = values.shape
    pr = P._Prog()
    xo = pr.put(values)
    to = pr.put(transformed)
    cn = pr.put_codes(colnan) if colnan is not None else pr.put(zeros((d,), "<f4"))
    out = pr.alloc(n * d)
    pr.stage("nan_keep", n * d, xo, d, to, cn, out)
    return pr.run(mode).get(out, (n, d))


def _stat_sub(mode, a, b, use_a, use_b, fill_a, fill_b):
    """Lane cpu2-l3-prep: a transform's two statistic vectors on the device
    (x_prep c2_nan_sub): colnan = (use_a and a is NaN) or (use_b and b is
    NaN); there a, b become fill_a, fill_b. Returns (a, b, colnan int32
    words or None when no column is NaN, nonfinite count, nonpositive-b
    count); the counts are c2_isum words (three read back)."""
    P = _prep()
    d = a.size
    pr = P._Prog()
    oa, ob, cn, nf, npos, sums = (pr.alloc(d), pr.alloc(d), pr.alloc(d), pr.alloc(d), pr.alloc(d),
                                  pr.alloc(3))
    pr.stage("c2_nan_sub", d, pr.put(a), pr.put(b), int(use_a), int(use_b), pr.put_scalar(fill_a),
             pr.put_scalar(fill_b), oa, ob, cn, nf, npos)
    pr.stage("c2_isum", 1, cn, d, sums)
    pr.stage("c2_isum", 1, nf, d, sums + 1)
    pr.stage("c2_isum", 1, npos, d, sums + 2)
    pr.run(mode)
    k = pr.get_i32(sums, 3).tolist()
    return pr.get(oa, d), pr.get(ob, d), (pr.get_i32(cn, d) if k[0] else None), k[1], k[2]


def _sample_weight(sample_weight, n):
    """The reference's `_check_sample_weight`: a scalar broadcasts, else a
    one-dimensional float32 vector of n weights (their finiteness is checked
    on the device by `_standard_stats`, lane cpu2-l3-prep)."""
    if isinstance(sample_weight, numbers.Real) and not is_bool(sample_weight):
        w = full((n,), float(sample_weight), "<f4")
    else:
        w = as_f32_c(sample_weight, ndim=1, name="sample_weight")[0]
    if w.ndim != 1 or w.shape[0] != n:
        raise ValueError(f"sample_weight must have shape ({n},), got {tuple(w.shape)}")
    return w


def _standard_stats(mode, values, weight):
    """StandardScaler statistics with NaN and / or sample_weight (x_prep
    `scaler_stats`, then `std_scale`): per column the counted weight, mean,
    population variance and scale. A NaN-free unweighted fit never comes
    here: it stays the binding's standard_fit and its recorded bits."""
    P = _prep()
    n, d = values.shape
    pr = P._Prog()
    xo = pr.put(values)
    wo = pr.put(weight) if weight is not None else P._NONE
    st = pr.alloc(6 * d)
    out = pr.alloc(3 * d)
    sc = pr.alloc(d)
    pr.stage("col_stats", d, xo, n, d, st)
    pr.stage("scaler_stats", d, xo, n, d, wo, out)
    pr.stage("std_scale", d, out + 2 * d, sc)
    wbad = None
    if weight is not None:
        # the weights' nonfinite count by chunks (x_prep c2_nonfinite + c2_isum, lane cpu2-l3-prep)
        ch = max(4096, -(-n // 4096))
        nch = -(-n // ch)
        wcnt, wbad = pr.alloc(nch), pr.alloc(1)
        pr.stage("c2_nonfinite", nch, wo, n, ch, wcnt)
        pr.stage("c2_isum", 1, wcnt, nch, wbad)
    pr.run(mode)
    if wbad is not None and pr.get_i32(wbad, 1).tolist()[0]:
        raise ValueError("sample_weight must be finite")
    _refuse_inf(pr, st, d)
    return pr.get(out, d), pr.get(out + d, d), pr.get(out + 2 * d, d), pr.get(sc, d)


def _seen(counts, weighted):
    """n_samples_seen_ as the reference keeps it: one number when every
    feature saw the same count, else a per-feature vector (int64 counts,
    float32 weight sums)."""
    if weighted:
        vals = [float(v) for v in counts]  # glue: converts the device sample counts (counts-sized: per-column sample counts)
        return vals[0] if len(set(vals)) == 1 else Array.from_list(vals, "<f4")
    vals = [int(v) for v in counts]  # glue: converts the device sample counts (counts-sized: per-column sample counts)
    return vals[0] if len(set(vals)) == 1 else Array.from_list(vals, "<i8")


def _per_feature(seen, d):
    if isinstance(seen, Array):
        return [float(v) for v in seen.tolist()]  # glue: per-column sample counts of n_samples_seen_
    return [float(seen)] * d


def _write_back(copy, copied, values, X, output):
    """copy=False (the reference's in-place transform): when X's own float32
    C-contiguous writable buffer was used without a copy, the result is
    written into it and X is returned; otherwise the new Array (the
    reference also copies an input it had to convert)."""
    if copy or copied or getattr(values, "_readonly", False):
        return output
    ctypes.memmove(_addr(values), _addr_ro(output), 4 * output.size)
    return X


def _direct_entry(binding, name):
    """The binding's direct fit entry (lane gap-prep2: X goes up from its own
    buffer and the device scans it for a nonfinite value; the same kernels
    and words as the List route), or None: an older binary, the host
    binding, or MOJOLEARN_SCALER_DIRECT=0 (the A/B switch: the host scan and
    the binding's List copies, as before)."""
    import os
    if os.environ.get("MOJOLEARN_SCALER_DIRECT", "1").strip() == "0":
        return None
    try:
        return getattr(binding, name)
    except (AttributeError, ImportError):
        return None


def _counts_i64(seen, d):
    """n_samples_seen_ (an int or an int64 vector) as d int64 words."""
    if isinstance(seen, Array):
        return seen.astype("<i8") if seen.dtype != "<i8" else seen
    return full((d,), int(seen), "<i8")


def _add_counts(mode, a, b, d):
    """Two n_samples_seen_ counts added per feature on the device
    (x_prep c2_add_i64): an int64 Array."""
    P = _prep()
    pr = P._Prog()
    out = pr.alloc(2 * d)
    pr.stage("c2_add_i64", d, pr.put_words(P._label_buffer(_counts_i64(a, d)).words),
             pr.put_words(P._label_buffer(_counts_i64(b, d)).words), out)
    pr.run(mode)
    return P._class_words(pr, out, d, True)


class _ScalerProtocol:
    @staticmethod
    def _input(X, allow_nan=False, with_copied=False, scan=True):
        """A float32 C-contiguous Array of X. `allow_nan`: NaN passes (the
        reference's `ensure_all_finite='allow-nan'`) and the second value
        says whether every entry is finite; +-inf is refused by the NaN path's
        own scan. `with_copied`: also whether that cost a copy (copy=False
        writes in place only into the caller's own buffer). `scan=False` (a
        direct entry: lane gap-prep2's fits, lane apple-fast-prep's
        transform): the finite walk is the device's and the second value is
        None."""
        values, copied = materialize_f32_lists(X, "input")
        if values.dtype != "<f4":
            raise TypeError('Scaler input must have dtype float32')
        if values.ndim != 2 or min(values.shape) == 0:  # glue: smallest axis of the shape
            raise ValueError('Scaler requires a nonempty two-dimensional input')
        if values.size > 2147483647:
            raise ValueError('Scaler exceeds the native Int32 indexing bound')
        # scan=False (a direct fit, lane gap-prep2): the device scans X for
        # a NaN or an infinity, so `finite` is None (not known here)
        finite = all_finite(values) if scan or not allow_nan else None
        if not finite and not allow_nan:
            raise ValueError('Scaler input must be finite; NaN/inf are unsupported')
        values, c2 = as_f32_c(values, ndim=values.ndim, name="values")
        if not allow_nan:
            return values
        return (values, finite, copied or c2) if with_copied else (values, finite)

    @staticmethod
    def _binding(mode):
        binding = _backend.binding('_mojolearn_preprocessing', mode)
        expected = {'fast': 0, 'identical': 1, 'deterministic': 2}[mode]
        if binding.preprocessing_numeric_mode() != expected:
            raise RuntimeError('Scaler native numeric mode disagrees with requested mode')
        return binding

    def fit_transform(self, X, y=None, **fit_params):
        return self.fit(X, y, **fit_params).transform(X)

    def get_params(self, deep=True):
        return {name: getattr(self, name) for name in self._parameters}  # glue: collects the estimator parameters

    def set_params(self, **params):
        values = self.get_params()
        if not params:
            return self
        unknown = sorted(set(params) - values.keys())  # glue: sorts unknown parameter names
        if unknown:
            raise ValueError(f'Invalid {type(self).__name__} parameters: {unknown}')
        values.update(params)
        replacement = type(self)(**values)
        self.__dict__.clear()
        self.__dict__.update(replacement.__dict__)
        return self

    def __sklearn_is_fitted__(self):
        return hasattr(self, 'scale_') and hasattr(self, 'numeric_mode_')

    def __sklearn_tags__(self):
        """scikit-learn's tag protocol, read only by scikit-learn: the shared
        `_mode.ParamsMixin` answer (no estimator type, no required target),
        with float32-preserving transformer tags from the same tag module."""
        import sys
        from ._mode import ParamsMixin
        tags = ParamsMixin.__sklearn_tags__(self)
        tags.transformer_tags = sys.modules[type(tags).__module__].TransformerTags(preserves_dtype=['float32'])
        return tags


# Maintainer notes: partial_fit's first batch reproduces fit's bits; statistics
# and transforms run in the preprocessing binding (and x_prep for the NaN path).
[docs] class MinMaxScaler(_ScalerProtocol): """Scale each feature to a given range. Computes each feature's minimum and maximum and maps them linearly onto ``feature_range``. Input must be a dense, nonempty two-dimensional float32 array: a float64 or integer array raises ``TypeError`` (nested Python lists of numbers are converted). Sparse input is not supported. The endpoints of ``feature_range`` are rounded to float32. A feature whose range is below 10 float32 epsilons (including a constant feature) uses a denominator of one. NaN is treated as missing: ``fit`` ignores it (a feature with no values gets NaN statistics) and ``transform`` keeps it. Infinity raises ``ValueError``. The numeric mode is resolved at ``fit`` and kept for later transforms, including after pickling. Parameters ---------- feature_range : tuple of two floats, default (0, 1) Target range ``(min, max)``; both finite in float32, with ``min < max`` after rounding. copy : bool, default True If False, transform in place when ``X`` is already a writable C-contiguous float32 buffer. clip : bool, default False Clip transformed values to ``feature_range``. Values lost to clipping are not recovered by ``inverse_transform``. numeric_mode : {"fast", "deterministic", "identical"} or None, default None Numeric mode; None uses the library default. Attributes ---------- data_min_ : Array of shape (n_features,), float32 Per-feature minimum seen. data_max_ : Array of shape (n_features,), float32 Per-feature maximum seen. data_range_ : Array of shape (n_features,), float32 ``data_max_ - data_min_``. scale_ : Array of shape (n_features,), float32 Per-feature scale factor. min_ : Array of shape (n_features,), float32 Per-feature offset. n_features_in_ : int Number of features seen during ``fit``. n_samples_seen_ : int Number of rows processed. numeric_mode_ : str The numeric mode used by the fit. feature_range_ : tuple of float ``feature_range`` as rounded to float32. clip_ : bool The ``clip`` setting captured by the fit. Examples -------- >>> import numpy as np, mojolearn >>> X = np.random.default_rng(0).random((1000, 8), dtype=np.float32) >>> scaler = mojolearn.MinMaxScaler().fit(X) >>> Z = scaler.transform(X) """ _parameters = ('feature_range', 'copy', 'clip', 'numeric_mode')
[docs] def __init__(self, feature_range=(0, 1), *, copy=True, clip=False, numeric_mode=None): self.feature_range = feature_range self.copy = copy self.clip = clip self.numeric_mode = numeric_mode self._configuration()
def _configuration(self): if not is_bool(self.copy): raise ValueError('copy must be a bool') if not is_bool(self.clip): raise ValueError('clip must be a bool') if self.numeric_mode is not None and ( not isinstance(self.numeric_mode, str) or self.numeric_mode.strip().lower() not in ('fast', 'deterministic', 'identical')): raise ValueError('numeric_mode must be fast, deterministic, identical or None') if not isinstance(self.feature_range, (tuple, list, Array)) and not hasattr(self.feature_range, "__array_interface__"): raise ValueError("feature_range must be a reusable tuple, list or 1D array") if getattr(self.feature_range, "ndim", 1) != 1: raise ValueError("feature_range must be one-dimensional") try: endpoints = tuple(self.feature_range) except TypeError: raise ValueError('feature_range must contain two numeric endpoints') from None if len(endpoints) != 2 or any( is_bool(x) or not isinstance(x, numbers.Real) for x in endpoints): # glue: checks the two feature_range endpoints raise ValueError('feature_range must contain two numeric endpoints') try: values = [float(x) for x in endpoints] # glue: converts the two feature_range endpoints except (OverflowError, ValueError): raise ValueError('feature_range endpoints must be finite Float32 values') from None if any(not math.isfinite(x) or abs(x) > 3.4028234663852886e+38 for x in values): # glue: checks the two feature_range endpoints raise ValueError('feature_range endpoints must be finite Float32 values') lower, upper = (struct.unpack("<f", struct.pack("<f", x))[0] for x in values) # glue: rounds the two feature_range endpoints if not lower < upper: raise ValueError('feature_range must have lower < upper after Float32 conversion') return lower, upper def _fit_extrema(self, binding, values, lower, upper, colnan, mode): """The binding's minmax_fit over `values` (finite), then NaN statistics for the `colnan` columns (int32 words, or None).""" n, d = values.shape output = empty((5, d), '<f4') binding.minmax_fit(_addr_ro(values), _addr(output), [n, d, float(lower), float(upper)]) self._keep_extrema(output, colnan, mode) def _keep_extrema(self, output, colnan, mode): """The five fitted rows from `output` (5, d), NaN for the `colnan` columns (int32 words, or None). Lane cpu2-l3-prep: the check (every value finite, every scale positive) and the NaN rows on the device (x_prep c2_mm_keep, one word read back).""" P = _prep() d = output.shape[1] pr = P._Prog() cn = pr.put_words(colnan) if colnan is not None else pr.put_words(full((d,), 0, "<i4")) rows, bad, nbad = pr.alloc(5 * d), pr.alloc(d), pr.alloc(1) pr.stage("c2_mm_keep", d, pr.put(output), d, cn, rows, bad) pr.stage("c2_isum", 1, bad, d, nbad) pr.run(mode) if pr.get_i32(nbad, 1).tolist()[0]: raise ValueError('MinMaxScaler fitted statistics overflowed or scale is not positive in Float32') got = pr.get(rows, (5, d)) for i, name in enumerate(('data_min_', 'data_max_', 'data_range_', 'scale_', 'min_')): # glue: sets five fitted attribute names setattr(self, name, got[i].copy())
[docs] def fit(self, X, y=None, sample_weight=None): """Compute the per-feature minimum and maximum. Parameters ---------- X : array-like of shape (n_samples, n_features), float32 Training data; NaN is ignored, infinity is refused. y : ignored sample_weight : None Not supported; any value raises ``NotImplementedError``. Returns ------- self : MinMaxScaler The fitted scaler. """ _require_training(self) lower, upper = self._configuration() # Once a new fit begins, a failed fit cannot expose stale statistics. for name in list(self.__dict__): # glue: drops stale fitted attributes if name.endswith('_'): del self.__dict__[name] if sample_weight is not None: raise NotImplementedError('MinMaxScaler does not support sample_weight') mode = (self.numeric_mode if self.numeric_mode is not None else _backend.default_mode()).strip().lower() binding = self._binding(mode) direct = _direct_entry(binding, "minmax_fit_direct") values, finite = self._input(X, allow_nan=True, scan=direct is None) n, d = values.shape colnan = None if finite is None: output = empty((5, d), '<f4') if int(direct(_addr_ro(values), _addr(output), [n, d, float(lower), float(upper)])): self._keep_extrema(output, colnan, mode) finite = True else: finite = False elif finite: self._fit_extrema(binding, values, lower, upper, colnan, mode) if not finite: # NaN -> the column's own minimum: min and max are those of its # non-NaN entries, and the binding's arithmetic is unchanged. values, _counts, colnan = _nan_scan_fill(mode, values, 3) self._fit_extrema(binding, values, lower, upper, colnan, mode) self.n_features_in_ = d self.n_samples_seen_ = n self.numeric_mode_ = mode self.feature_range_ = (lower, upper) self.clip_ = bool(self.clip) return self
[docs] def partial_fit(self, X, y=None): """Update the running minimum and maximum with a batch of rows. On an unfitted scaler this is ``fit``. A feature with NaN statistics stays NaN. Parameters ---------- X : array-like of shape (n_samples, n_features), float32 A batch of rows with the fitted feature count. y : ignored Returns ------- self : MinMaxScaler The updated scaler. Raises ------ ValueError If ``feature_range`` changed since the first batch or the feature count differs. """ _require_training(self) if not self.__sklearn_is_fitted__(): return self.fit(X) lower, upper = self._configuration() if (lower, upper) != tuple(self.feature_range_): raise ValueError('MinMaxScaler partial_fit: feature_range changed since the first batch') batch = MinMaxScaler(feature_range=self.feature_range, clip=self.clip, numeric_mode=self.numeric_mode_).fit(X) d = self.n_features_in_ if batch.n_features_in_ != d: raise ValueError('MinMaxScaler input feature count differs from fit') # lane cpu2-l3-prep: the merge rows on the device (x_prep c2_mm_merge: a column # whose old or new minimum is NaN is colnan, its rows 0) P = _prep() pr = P._Prog() rows, cn = pr.alloc(4 * d), pr.alloc(d) pr.stage("c2_mm_merge", d, pr.put(self.data_min_), pr.put(self.data_max_), pr.put(batch.data_min_), pr.put(batch.data_max_), d, rows, cn) pr.run(self.numeric_mode_) extrema = pr.get(rows, (4, d)) self._fit_extrema(self._binding(self.numeric_mode_), extrema, lower, upper, pr.get_i32(cn, d), self.numeric_mode_) self.n_samples_seen_ = int(self.n_samples_seen_) + int(batch.n_samples_seen_) self.clip_ = bool(self.clip) return self
def _transform(self, X, inverse): if not self.__sklearn_is_fitted__(): raise NotFittedError('MinMaxScaler is not fitted') binding = self._binding(self.numeric_mode_) # `minmax_transform_direct` (lane apple-fast-prep; lane cpu2-l3-prep: every GPU # binding): X up from its own buffer, the device scans the output (and X when the # output is not finite). The host binding (the host column) has none: its List # route with the host scans direct = _direct_entry(binding, "minmax_transform_direct") values, finite, copied = self._input(X, allow_nan=True, with_copied=True, scan=direct is None) n, d = values.shape if d != self.n_features_in_: raise ValueError('MinMaxScaler input feature count differs from fit') for name in ("scale_", "min_"): # glue: checks two fitted attribute names statistic = getattr(self, name) if (not isinstance(statistic, Array) or statistic.dtype != "<f4" or statistic.shape != (d,) or not statistic.flags["C_CONTIGUOUS"]): raise ValueError(f"MinMaxScaler {name} must remain a finite contiguous Float32 feature vector") # a column never seen (NaN statistics) transforms with scale 1, offset 0, then NaN # (lane cpu2-l3-prep: substituted and checked on the device, `_stat_sub`) offset, scale, colnan, nonfinite, nonpos = _stat_sub(self.numeric_mode_, self.min_, self.scale_, 1, 1, 0.0, 1.0) if nonfinite: raise ValueError("MinMaxScaler scale_ and min_ must remain finite (NaN: a column never seen)") if nonpos: raise ValueError("MinMaxScaler scale_ must remain positive") params = [n, d, int(inverse), int(self.clip_), float(self.feature_range_[0]), float(self.feature_range_[1])] output = empty(values.shape, '<f4') if direct is not None: got = int(direct(_addr_ro(values), _addr_ro(scale), _addr_ro(offset), _addr(output), params)) if got == 0: raise ValueError('MinMaxScaler transform overflowed in Float32') finite = got == 1 if not finite: # X holds NaN: the NaN-filled X through the same entry (an infinity is refused) source = _nan_scan_fill(self.numeric_mode_, values, None)[0] if int(direct(_addr_ro(source), _addr_ro(scale), _addr_ro(offset), _addr(output), params)) != 1: raise ValueError('MinMaxScaler transform overflowed in Float32') else: source = values if finite else _nan_scan_fill(self.numeric_mode_, values, None)[0] binding.minmax_transform(_addr_ro(source), _addr_ro(scale), _addr_ro(offset), _addr(output), params) if not all_finite(output): raise ValueError('MinMaxScaler transform overflowed in Float32') if not finite or colnan is not None: output = _nan_keep(self.numeric_mode_, values, output, colnan) return _write_back(self.copy, copied, values, X, output)
[docs] def transform(self, X): """Scale ``X`` to ``feature_range``. Parameters ---------- X : array-like of shape (n_samples, n_features), float32 Data with the fitted feature count; NaN is kept. Returns ------- X_scaled : Array of shape (n_samples, n_features), float32 The scaled data. Raises ------ NotFittedError If the scaler is not fitted (a subclass of ``ValueError`` and ``AttributeError``). ValueError If the feature count differs or the result overflows float32. """ return self._transform(X, False)
[docs] def inverse_transform(self, X): """Undo the scaling. Parameters ---------- X : array-like of shape (n_samples, n_features), float32 Scaled data. Returns ------- X_original : Array of shape (n_samples, n_features), float32 The data in its original units. """ return self._transform(X, True)
[docs] def save(self, path): """Save the fitted scaler to ``path`` as an ``.npz`` file. A scaler restored with ``MinMaxScaler.load`` or ``mojolearn.host_model(path)`` transforms, including on a CPU-only install. Parameters ---------- path : str or path-like Destination file. Raises ------ RuntimeError If the scaler is not fitted. """ if not self.__sklearn_is_fitted__(): raise RuntimeError("this estimator is not fitted yet") arrays = { "format": _SCALER_FORMAT, "estimator": type(self).__name__, "numeric_mode": self.numeric_mode_, "data_min": self.data_min_, "data_max": self.data_max_, "data_range": self.data_range_, "scale": self.scale_, "min": self.min_, "feature_range": Array.from_list([float(v) for v in self.feature_range_], "<f8"), # glue: packs the two feature_range endpoints "meta": Array.from_list( [int(self.n_features_in_), int(self.n_samples_seen_), int(self.clip_)], "<i8"), } return _serialize.write_npz(path, arrays)
[docs] @classmethod def load(cls, path): """Load a scaler written by ``save``. The loaded scaler transforms; arrays are read at their saved dtype and never cast. Parameters ---------- path : str or path-like File written by ``MinMaxScaler.save``. Returns ------- scaler : MinMaxScaler The restored scaler. Raises ------ ValueError If the file is not a MinMaxScaler or its arrays are inconsistent. """ arrays = _serialize.read_npz(path, _SCALER_FORMAT) mode, meta = _scaler_header(arrays, path, cls, 3) bounds = _serialize.exact(arrays, "feature_range", "<f8") if bounds.size != 2: raise ValueError(f"mojolearn: {path!r} feature_range must hold two values") obj = cls(feature_range=(float(bounds[0]), float(bounds[1])), clip=bool(int(meta[2]))) d = int(meta[0]) for name in ("data_min", "data_max", "data_range", "scale", "min"): # glue: loads five saved statistic names setattr(obj, name + "_", _saved_statistic(arrays, name, d, path)) obj.n_features_in_ = d obj.n_samples_seen_ = int(meta[1]) obj.numeric_mode_ = mode obj.feature_range_ = (float(bounds[0]), float(bounds[1])) obj.clip_ = bool(int(meta[2])) return obj
# Maintainer notes (moved from the docstring): variance is a centered # population float32 fold about the float32 mean; zero variance uses scale one # (the bounded cuML exact-zero convention, not scikit-learn's float64 # near-constant error bound). A finite unweighted fit is the binding's # standard_fit (256-row pinned chunks); a fit with NaN or weights is x_prep # `scaler_stats`, one ascending weighted fold per column. partial_fit merges by # x_prep `gnb_merge` (the GaussianNB merge); its first batch is fit's bits.
[docs] class StandardScaler(_ScalerProtocol): """Standardize features by removing the mean and scaling to unit variance. Input must be a dense, nonempty two-dimensional float32 array: a float64 or integer array raises ``TypeError`` (nested Python lists of numbers are converted). Sparse input is not supported. Statistics are computed in float32, not in float64 as in scikit-learn. The variance is the population variance; a feature with exactly zero variance gets scale one, while a nearly constant feature is scaled by its (tiny) standard deviation. NaN is treated as missing: ``fit`` ignores it per feature (a feature with no values gets NaN statistics) and ``transform`` keeps it. Infinity raises ``ValueError``. The numeric mode and flags are captured by ``fit`` and kept through pickling. Parameters ---------- copy : bool, default True If False, transform in place when ``X`` is already a writable C-contiguous float32 buffer. with_mean : bool, default True Center the data before scaling. with_std : bool, default True Scale the data to unit variance. numeric_mode : {"fast", "deterministic", "identical"} or None, default None Numeric mode; None uses the library default. Attributes ---------- mean_ : Array of shape (n_features,), float32, or None Per-feature mean; None when both ``with_mean`` and ``with_std`` are False. var_ : Array of shape (n_features,), float32, or None Per-feature variance; None when ``with_std=False``. scale_ : Array of shape (n_features,), float32, or None Per-feature scale; None when ``with_std=False``. n_features_in_ : int Number of features seen during ``fit``. n_samples_seen_ : int, float or Array Rows seen. With NaN values whose counts differ across features, a per-feature array; with ``sample_weight``, the weight sum. numeric_mode_ : str The numeric mode used by the fit. Examples -------- >>> import numpy as np, mojolearn >>> X = np.random.default_rng(0).random((1000, 8), dtype=np.float32) >>> scaler = mojolearn.StandardScaler().fit(X) >>> Z = scaler.transform(X) """ _parameters = ('copy', 'with_mean', 'with_std', 'numeric_mode')
[docs] def __init__(self, *, copy=True, with_mean=True, with_std=True, numeric_mode=None): self.copy = copy self.with_mean = with_mean self.with_std = with_std self.numeric_mode = numeric_mode self._configuration()
def _configuration(self): if not is_bool(self.copy): raise ValueError('copy must be a bool') if not is_bool(self.with_mean) or not is_bool(self.with_std): raise ValueError('with_mean and with_std must be bools') if self.numeric_mode is not None and ( not isinstance(self.numeric_mode, str) or self.numeric_mode.strip().lower() not in ('fast', 'deterministic', 'identical')): raise ValueError('numeric_mode must be fast, deterministic, identical or None') def _keep(self, mean, var, scale, seen, d, mode): self.mean_ = mean if self.with_mean or self.with_std else None self.var_ = var if self.with_std else None self.scale_ = scale if self.with_std else None self.n_features_in_ = d self.n_samples_seen_ = seen self.numeric_mode_ = mode self.with_mean_ = bool(self.with_mean) self.with_std_ = bool(self.with_std)
[docs] def fit(self, X, y=None, sample_weight=None): """Compute the per-feature mean and variance. Parameters ---------- X : array-like of shape (n_samples, n_features), float32 Training data; NaN is ignored, infinity is refused. y : ignored sample_weight : array-like of shape (n_samples,), optional Weights for the mean and variance. Returns ------- self : StandardScaler The fitted scaler. Raises ------ ValueError If ``X`` holds infinity or the statistics overflow float32. """ _require_training(self) self._configuration() for name in list(self.__dict__): # glue: drops stale fitted attributes if name.endswith('_'): del self.__dict__[name] mode = (self.numeric_mode if self.numeric_mode is not None else _backend.default_mode()).strip().lower() direct = _direct_entry(self._binding(mode), "standard_fit_direct") if sample_weight is None else None values, finite = self._input(X, allow_nan=True, scan=direct is None) n, d = values.shape output = None if finite is None: output = empty((3, d), '<f4') finite = bool(int(direct(_addr_ro(values), _addr(output), [n, d, int(self.with_mean), int(self.with_std)]))) elif finite and sample_weight is None: output = empty((3, d), '<f4') self._binding(mode).standard_fit(_addr_ro(values), _addr(output), [n, d, int(self.with_mean), int(self.with_std)]) if finite and sample_weight is None: if not all_finite(output) or output[1].min() < 0 or output[2].min() <= 0: raise ValueError('StandardScaler statistics are nonfinite, variance negative, or scale nonpositive in Float32') self._keep(output[0].copy(), output[1].copy(), output[2].copy(), n, d, mode) return self weight = _sample_weight(sample_weight, n) if sample_weight is not None else None self._binding(mode) count, mean, var, scale = _standard_stats(mode, values, weight) seen = count.tolist() live = [c for c in range(d) if seen[c] != 0] # glue: live column index list from device counts (d-sized: feature count) m, v, sc = mean.tolist(), var.tolist(), scale.tolist() if any(not (math.isfinite(m[c]) and math.isfinite(v[c]) and math.isfinite(sc[c])) or v[c] < 0 or sc[c] <= 0 for c in live): # glue: raises on nonfinite device statistics (live-sized: live column indices) raise ValueError('StandardScaler statistics are nonfinite, variance negative, or scale nonpositive in Float32') self._keep(mean, var, scale, _seen(seen, weight is not None), d, mode) return self
# MOJOLEARN_X_PREP_FAST_FIT_TRANSFORM_FUSED (a fast + Apple build's # experiment, default off; lane apple-fast-round2): the binding's # `standard_fit_transform_direct` uploads X once and runs the transform on # the resident copy (preprocessing/estimator.mojo has the cost reasoning). # Routing only: the same kernels on the same words. NaN input, sample # weights, other modes and builds without the export take the two steps.
[docs] def fit_transform(self, X, y=None, **fit_params): """Fit to ``X`` and return the standardized data. Parameters ---------- X : array-like of shape (n_samples, n_features), float32 Training data. y : ignored **fit_params Passed to ``fit`` (for example ``sample_weight``). Returns ------- X_scaled : Array of shape (n_samples, n_features), float32 The standardized data. """ if not fit_params: fused = self._fit_transform_fused(X) if fused is not None: return fused return self.fit(X, y, **fit_params).transform(X)
def _fit_transform_fused(self, X): _require_training(self) self._configuration() mode = (self.numeric_mode if self.numeric_mode is not None else _backend.default_mode()).strip().lower() if mode != 'fast': return None entry = _direct_entry(self._binding(mode), "standard_fit_transform_direct") if entry is None: return None values, _, copied = self._input(X, allow_nan=True, with_copied=True, scan=False) n, d = values.shape for name in list(self.__dict__): # glue: drops stale fitted attributes if name.endswith('_'): del self.__dict__[name] stats = empty((3, d), '<f4') output = empty(values.shape, '<f4') got = int(entry(_addr_ro(values), _addr(stats), _addr(output), [n, d, int(self.with_mean), int(self.with_std)])) if got == 0: return None # X holds NaN or an infinity: fit's NaN route, then transform self._keep(stats[0].copy(), stats[1].copy(), stats[2].copy(), n, d, mode) if got < 0: raise ValueError('StandardScaler transform overflowed in Float32') return _write_back(self.copy, copied, values, X, output) # partial_fit merge (x_prep `gnb_merge`): n = n_past + n_new; mean = # (n_new mu_new + n_past mu) / n; var = (n_past var + n_new var_new + # n_new n_past / n (mu - mu_new)^2) / n; two exactly constant parts with the # same value keep it exactly (KEEP = 1); then the scale by `std_scale`.
[docs] def partial_fit(self, X, y=None, sample_weight=None): """Update the running mean and variance with a batch of rows. On an unfitted scaler this is ``fit``. The batch's count, mean and variance are merged into the running ones per feature; a feature the batch never saw keeps its values. Parameters ---------- X : array-like of shape (n_samples, n_features), float32 A batch of rows with the fitted feature count. y : ignored sample_weight : array-like of shape (n_samples,), optional Weights for this batch. Returns ------- self : StandardScaler The updated scaler. Raises ------ ValueError If ``with_mean`` or ``with_std`` changed since the first batch or the feature count differs. """ _require_training(self) if not self.__sklearn_is_fitted__(): return self.fit(X, y, sample_weight=sample_weight) if (bool(self.with_mean), bool(self.with_std)) != (self.with_mean_, self.with_std_): raise ValueError('StandardScaler partial_fit: with_mean / with_std changed since the first batch') batch = StandardScaler(with_mean=self.with_mean_, with_std=self.with_std_, numeric_mode=self.numeric_mode_).fit(X, y, sample_weight=sample_weight) d = self.n_features_in_ if batch.n_features_in_ != d: raise ValueError('StandardScaler input feature count differs from fit') weighted = (sample_weight is not None or isinstance(self.n_samples_seen_, float) or getattr(self.n_samples_seen_, "dtype", None) == "<f4") old_n, new_n = _per_feature(self.n_samples_seen_, d), _per_feature(batch.n_samples_seen_, d) if self.mean_ is None: total = [a + b for a, b in zip(old_n, new_n)] # glue: merges per-column integer sample counts self.n_samples_seen_ = _seen(total, weighted) return self P = _prep() pr = P._Prog() zeros_d = [0.0] * d oc, bc = pr.put_list(old_n), pr.put_list(new_n) om, bm = pr.put(self.mean_), pr.put(batch.mean_) ov = pr.put(self.var_) if self.var_ is not None else pr.put_list(zeros_d) bv = pr.put(batch.var_) if batch.var_ is not None else pr.put_list(zeros_d) cnt, mean, var, scale = pr.alloc(d), pr.alloc(d), pr.alloc(d), pr.alloc(d) pr.stage("gnb_merge", d, oc, om, ov, bc, bm, bv, d, 1, cnt, mean, var, 1) pr.stage("std_scale", d, var, scale) pr.run(self.numeric_mode_) if weighted: total = pr.values(cnt, d) else: # the int64 counts added on the device (x_prep c2_add_i64, lane cpu2-l3-prep) total = _add_counts(self.numeric_mode_, self.n_samples_seen_, batch.n_samples_seen_, d).tolist() self.mean_ = pr.get(mean, d) if self.with_std_: self.var_ = pr.get(var, d) self.scale_ = pr.get(scale, d) self.n_samples_seen_ = _seen(total, weighted) return self
def _transform(self, X, inverse, copy): if copy is not None and not is_bool(copy): raise ValueError('StandardScaler transform copy must be a bool or None') copy = self.copy if copy is None else copy if not self.__sklearn_is_fitted__(): raise NotFittedError('StandardScaler is not fitted') binding = self._binding(self.numeric_mode_) # `standard_transform_direct` (lane cpu2-l3-prep, every GPU binding): X up from # its own buffer, the device scans the output (and X when the output is not # finite). The host binding (the host column) has none: the List route direct = _direct_entry(binding, "standard_transform_direct") values, finite, copied = self._input(X, allow_nan=True, with_copied=True, scan=direct is None) n, d = values.shape if d != self.n_features_in_: raise ValueError('StandardScaler input feature count differs from fit') if self.with_mean_ and self.mean_ is None: raise ValueError('StandardScaler mean_ is missing for a centered transform') if self.with_std_ and self.scale_ is None: raise ValueError('StandardScaler scale_ is missing for a scaled transform') mean = self.mean_ if self.mean_ is not None else zeros((d,), "<f4") scale = self.scale_ if self.scale_ is not None else full((d,), 1, "<f4") for name, statistic in (('mean_', mean), ('scale_', scale)): # glue: checks two fitted statistic names if (not isinstance(statistic, Array) or statistic.dtype != "<f4" or statistic.shape != (d,) or not statistic.flags["C_CONTIGUOUS"]): raise ValueError(f'StandardScaler {name} must remain a finite contiguous Float32 feature vector') # a feature never seen (NaN statistics) transforms with mean 0, scale 1, then NaN # (lane cpu2-l3-prep: substituted and checked on the device, `_stat_sub`) mean, scale, colnan, nonfinite, nonpos = _stat_sub(self.numeric_mode_, mean, scale, self.with_mean_, self.with_std_, 0.0, 1.0) if nonfinite: raise ValueError('StandardScaler mean_ and scale_ must remain finite (NaN: a feature never seen)') if nonpos: raise ValueError('StandardScaler scale_ must remain positive') params = [n, d, int(inverse), int(self.with_mean_), int(self.with_std_)] output = empty(values.shape, '<f4') if direct is not None: got = int(direct(_addr_ro(values), _addr_ro(mean), _addr_ro(scale), _addr(output), params)) if got == 0: raise ValueError('StandardScaler transform overflowed in Float32') finite = got == 1 if not finite: # X holds NaN: the NaN-filled X through the same entry (an infinity is refused) source = _nan_scan_fill(self.numeric_mode_, values, None)[0] if int(direct(_addr_ro(source), _addr_ro(mean), _addr_ro(scale), _addr(output), params)) != 1: raise ValueError('StandardScaler transform overflowed in Float32') else: source = values if finite else _nan_scan_fill(self.numeric_mode_, values, None)[0] binding.standard_transform(_addr_ro(source), _addr_ro(mean), _addr_ro(scale), _addr(output), params) if not all_finite(output): raise ValueError('StandardScaler transform overflowed in Float32') if not finite or colnan is not None: output = _nan_keep(self.numeric_mode_, values, output, colnan) return _write_back(copy, copied, values, X, output)
[docs] def transform(self, X, copy=None): """Standardize ``X``. Parameters ---------- X : array-like of shape (n_samples, n_features), float32 Data with the fitted feature count; NaN is kept. copy : bool or None, default None Overrides the ``copy`` parameter for this call. Returns ------- X_scaled : Array of shape (n_samples, n_features), float32 The standardized data. Raises ------ NotFittedError If the scaler is not fitted (a subclass of ``ValueError`` and ``AttributeError``). ValueError If the feature count differs or the result overflows float32. """ return self._transform(X, False, copy)
[docs] def inverse_transform(self, X, copy=None): """Undo the standardization. Parameters ---------- X : array-like of shape (n_samples, n_features), float32 Standardized data. copy : bool or None, default None Overrides the ``copy`` parameter for this call. Returns ------- X_original : Array of shape (n_samples, n_features), float32 The data in its original units. """ return self._transform(X, True, copy)
[docs] def save(self, path): """Save the fitted scaler to ``path`` as an ``.npz`` file. A scaler restored with ``StandardScaler.load`` or ``mojolearn.host_model(path)`` transforms, including on a CPU-only install. Parameters ---------- path : str or path-like Destination file. Raises ------ RuntimeError If the scaler is not fitted. """ if not self.__sklearn_is_fitted__(): raise RuntimeError("this estimator is not fitted yet") arrays = { "format": _SCALER_FORMAT, "estimator": type(self).__name__, "numeric_mode": self.numeric_mode_, "meta": Array.from_list( [int(self.n_features_in_), int(self.n_samples_seen_) if type(self.n_samples_seen_) is int else -1, int(self.with_mean_), int(self.with_std_)], "<i8"), } if type(self.n_samples_seen_) is not int: # A per-feature count (NaN) or a weight sum: saved as its own # vector, float32 weights or int64 counts, one entry per feature. seen = self.n_samples_seen_ arrays["n_samples_seen"] = (seen if isinstance(seen, Array) else Array.from_list([float(seen)], "<f4")) for name in ("mean", "var", "scale"): # glue: saves three statistic names value = getattr(self, name + "_") if value is not None: arrays[name] = value return _serialize.write_npz(path, arrays)
[docs] @classmethod def load(cls, path): """Load a scaler written by ``save``. The loaded scaler transforms; arrays are read at their saved dtype and never cast. Parameters ---------- path : str or path-like File written by ``StandardScaler.save``. Returns ------- scaler : StandardScaler The restored scaler. Raises ------ ValueError If the file is not a StandardScaler or its arrays are inconsistent. """ arrays = _serialize.read_npz(path, _SCALER_FORMAT) mode, meta = _scaler_header(arrays, path, cls, 4) with_mean, with_std = bool(int(meta[2])), bool(int(meta[3])) obj = cls(with_mean=with_mean, with_std=with_std) d = int(meta[0]) kept = {"mean": with_mean or with_std, "var": with_std, "scale": with_std} for name, present in kept.items(): # glue: checks three saved member names if present != (name in arrays): raise ValueError(f"mojolearn: {path!r} {name} does not match with_mean and with_std") setattr(obj, name + "_", _saved_statistic(arrays, name, d, path) if present else None) obj.n_features_in_ = d if int(meta[1]) >= 0: obj.n_samples_seen_ = int(meta[1]) else: seen = arrays.get("n_samples_seen") if seen is None or seen.dtype not in ("<f4", "<i8") or seen.ndim != 1 or seen.size not in (1, d): raise ValueError(f"mojolearn: {path!r} n_samples_seen does not match n_features_in_") # One entry: a weight sum shared by every feature (a vector is # only kept when the features' counts differ, so never at d == 1). obj.n_samples_seen_ = float(seen.tolist()[0]) if seen.size == 1 else seen obj.numeric_mode_ = mode obj.with_mean_ = with_mean obj.with_std_ = with_std return obj