#!/usr/bin/env python
import inspect
import warnings
import numpy as np
from .common import (Reducer, models, REDUCERS, AUTOENCODER_NAMES,
resolve_reducer)
from ..core.model import external_stacklevel
from ..tools.format_data import format_data as formatter
def _resolve_model(model_name):
"""Look up a reduction model by name.
Covers `models` (the classic decomposition/manifold reducers) plus the
mixture/soft-clustering models (`GaussianMixture`,
`BayesianGaussianMixture`, `LatentDirichletAllocation`, `NMF` -- GH
#174) via `REDUCERS`. UMAP is resolved lazily because importing umap
triggers numba JIT compilation that adds seconds to `import hypertools`
even when UMAP is never used.
"""
with warnings.catch_warnings():
# umap-learn emits an ImportWarning about its OPTIONAL tensorflow/
# ParametricUMAP extra at import time -- noise for standard UMAP
# use (release-1.0 audit, X4-warnings-011; same pattern as the
# gensim filter in tools/text2mat).
warnings.filterwarnings('ignore', message='Tensorflow not installed')
return resolve_reducer(model_name)
def _raise_unknown_reduce_model(model_name):
"""Raise the standard unknown-reduce-model ValueError, naming the
offending value, a did-you-mean hint for case near-misses, and the
supported names (F11-reduce-describe-005; shared by the bare-string
and dict-spec paths -- the dict path used to leak a bare KeyError,
release-1.0 audit X7 residue)."""
supported_names = sorted(list(REDUCERS) + ['UMAP']
+ list(AUTOENCODER_NAMES))
match = next((name for name in supported_names
if name.lower() == str(model_name).lower()), None)
hint = f" (did you mean {match!r}?)" if match else ""
raise ValueError(
f"unknown reduce model {model_name!r}{hint}; supported "
f"names: {', '.join(supported_names)}. A scikit-learn style "
f"class or instance can also be passed directly.") from None
# main function
[docs]
def reduce(x, reduce='IncrementalPCA', ndims=None, return_model=False,
manip=None, normalize=None, align=None, cluster=None,
internal=False, format_data=True, random_state=None,
model=None):
"""
Reduces dimensionality of an array, or list of arrays
Parameters
----------
x : Numpy array, Pandas DataFrame, text (list of strings), or
list/tuple of arrays/DataFrames
The data to reduce. Lists (and tuples, treated identically) are
stacked and reduced in one SHARED space (a single model fit on the
row-concatenated data), so all datasets in a list must have the
same number of columns. `None` raises a `TypeError`.
reduce : str, dict, class, instance, fitted Reducer, False, or None
Decomposition/manifold learning model to use. Models supported: PCA,
IncrementalPCA, SparsePCA, MiniBatchSparsePCA, KernelPCA, FastICA,
FactorAnalysis, TruncatedSVD, DictionaryLearning, MiniBatchDictionaryLearning,
TSNE, Isomap, SpectralEmbedding, LocallyLinearEmbedding, MDS and UMAP,
plus the mixture (soft-clustering) models GaussianMixture,
BayesianGaussianMixture, LatentDirichletAllocation and NMF -- for
these, the returned array holds (n_samples, ndims) membership
proportions rather than a projection (GH #174). Also supports the
six torch-backed autoencoder reducers (GH #162,
`hypertools.reduce.autoencoders`): Autoencoder, DeepAutoencoder,
SparseAutoencoder, ConvolutionalAutoencoder, SequenceAutoencoder,
and VariationalAutoencoder -- these require the optional `torch`
dependency (`pip install "hypertools[torch]"`); resolving one of
these names without `torch` installed raises a friendly
`ImportError`. Can be passed as a
string, a bare (uninstantiated) scikit-learn-style class, an
already-constructed instance, the canonical dict spec
`{'model': ..., 'args': [...], 'kwargs': {...}}` (both `'args'`
and `'kwargs'` are OPTIONAL, so the minimal `{'model': 'PCA'}`
works too; passing the legacy `'params'` key alongside them warns
and ignores `'params'`), or the LEGACY
dict spec `{'model' : 'PCA', 'params' : {'whiten' : True}}`
(accepted for backward compatibility, but emits a
`DeprecationWarning`). A previously-fitted `Reducer` (as returned
by `return_model=True`) is applied via `.transform` instead of
being refit; models without an out-of-sample transform (TSNE, MDS,
SpectralEmbedding) cannot embed new data this way, so reusing their
fitted `Reducer` raises `NotImplementedError` explaining the refit.
`None` or `False` skips the reduction entirely and returns the
input unchanged. See scikit-learn specific model docs for details
on parameters supported for each model.
ndims : int or None
Number of dimensions to reduce to. If None (the default), or if
every dataset already has <= ndims columns, no model is fit and
the (formatted) input is returned unchanged -- reduce() never
expands or rotates data at full dimensionality. Requesting MORE
dimensions than the data has features also skips the reduction,
with a `UserWarning` (the input comes back unchanged). NOTE: when
no reduction runs, there is no fitted model, so
`return_model=True` pairs the unchanged data with `None` (see
`return_model` below) -- pass an explicit `ndims` (e.g. `ndims=3`)
to actually fit a model.
return_model : bool
If True, also return the fitted model: the fitted `Reducer` wrapper
when only the `reduce` stage ran, or a fitted `hypertools.Pipeline`
when `manip=`/`normalize=`/`align=`/`cluster=` made multiple stages
run (default: False). When NO reduction ran (`ndims=None` -- the
default -- with data at/below the requested dimensionality, or
`reduce=None`/`False`), the model slot of the returned
`(data, model)` tuple is `None`.
manip, normalize, align, cluster : model spec or None
Cross-module stage kwargs (GH #138): when any of these is given,
the other stages also run (via
`hypertools.core.pipeline.build_pipeline`), in the canonical order
`manip -> normalize -> reduce -> align -> cluster` (GH #153), with
this function's own `reduce=`/`ndims=` slotted in at the reduce
stage (default: None for all four, i.e. only `reduce` runs).
internal : bool
(Internal use, e.g. by `hyp.plot`/`hyp.analyze`) if True, always
return a list even when the input was a single dataset (default:
False).
format_data : bool
Whether or not to first call the format_data function (default: True).
random_state : int, RandomState, or None
Seed for reproducibility. Injected into the reduction model's
constructor when it accepts a `random_state` (UMAP, TSNE, MDS,
FastICA, the mixture models, ...); ignored for deterministic models
(PCA, IncrementalPCA, ...) and for an already-constructed model
instance you pass in (configure that yourself). An explicit
`random_state` in a dict spec's `kwargs` takes precedence
(default: None).
model : same forms as `reduce`, or None
Alias for `reduce=`, so the own-stage model spec can be spelled
`model=` here exactly as in `hyp.manip`/`hyp.impute`/`hyp.predict`/
`hyp.align` (release-1.0 audit: the sibling APIs used two different
kwarg conventions). Pass only one of `reduce=`/`model=`; passing
both (with different values) raises `ValueError` (default: None).
Returns
-------
x_reduced : Numpy array or list of arrays
The reduced data with ndims dimensionality is returned. A list is
returned when the input is a list of two or more datasets (or when
`internal=True`); a single dataset -- even inside a one-element
list -- comes back as a bare array. If `return_model=True`, an
`(x_reduced, model)` tuple is returned instead.
Examples
--------
>>> import numpy as np
>>> import hypertools as hyp
>>> x = np.cumsum(np.random.default_rng(0).standard_normal((40, 5)),
... axis=0)
>>> reduced = hyp.reduce(x, reduce='PCA', ndims=3)
>>> reduced.shape
(40, 3)
"""
from ..core.shared import require_data
# None always raises the unified dispatcher TypeError, and a tuple of
# datasets is accepted exactly like a list (2026-07 release audit,
# final wave items 9/15)
require_data(x, 'reduce')
if isinstance(x, tuple):
x = list(x)
# model= is an alias for reduce= (release-1.0 audit,
# D05-gallery-data-text-020: manip/impute/predict/align spell their
# own-stage spec `model=`, and hyp.reduce(x, model='PCA') used to die
# with a bare TypeError naming neither kwarg).
if model is not None:
if reduce != 'IncrementalPCA' and reduce is not model:
raise ValueError(
"cannot pass both reduce= and model=; they are aliases "
"for the same model spec -- pass just one (e.g. "
"reduce='PCA' or model='PCA').")
reduce = model
# validate ndims up front (QC 2026-07): a non-int silently hit a
# `TypeError: '<=' not supported between int and str`, and ndims<=0
# silently reduced to 0/negative columns. bool is an int subclass but is
# never a valid dimension count.
if ndims is not None:
if isinstance(ndims, bool) or not isinstance(ndims, (int, np.integer)):
raise ValueError(
f"ndims must be a positive integer or None; got {ndims!r}")
if ndims < 1:
raise ValueError(f"ndims must be >= 1; got {ndims}")
# False is an explicit "skip this stage", for every stage kwarg, exactly
# like None (release-audit contract) -- normalize it up front so
# plot()/analyze() can thread reduce=False (and friends) through
if reduce is False:
reduce = None
manip = None if manip is False else manip
normalize = None if normalize is False else normalize
align = None if align is False else align
cluster = None if cluster is False else cluster
# a whole already-fitted Pipeline handed back as reduce= (e.g. the model
# from an earlier cross-module return_model=True call) is reused as-is via
# .transform, BEFORE the cross-module branch below -- otherwise it would be
# wrapped in a fresh Reducer and crash (QC 2026-07). Any redundant stage
# kwargs are warned about + ignored (the Pipeline already encodes them).
from ..core.shared import is_reused_pipeline
if is_reused_pipeline(reduce, {'manip': manip, 'normalize': normalize,
'align': align, 'cluster': cluster}, 'reduce'):
result = reduce.transform(x)
return (result, reduce) if return_model else result
# cross-module kwargs (#138): assemble and run a Pipeline (in canonical
# order, #153) instead of the single-stage path below whenever another
# stage is requested. Lazy import avoids a reduce<->core.pipeline cycle
# (core.pipeline itself lazily imports reduce.reduce).
if any(stage is not None for stage in (manip, normalize, align, cluster)):
from ..core.pipeline import build_pipeline
pipeline = build_pipeline(manip=manip, normalize=normalize,
reduce=reduce, ndims=ndims,
align=align, cluster=cluster)
result = pipeline.fit_transform(x)
return (result, pipeline) if return_model else result
# if model is None (or False, normalized above), just return data
if reduce is None:
return (x, None) if return_model else x
# set by the canonical dict-spec branch below when a TSNE instance is
# constructed without a user-supplied perplexity (see the small-dataset
# clamp further down, F11-reduce-describe-002)
tsne_perplexity_unset = False
# an already-fitted Reducer (returned from an earlier
# return_model=True call) is reused via `transform`, never refit
if isinstance(reduce, Reducer) and reduce.is_fitted:
fitted_n_components = getattr(reduce.model_, 'n_components', None)
if (ndims is not None) and (fitted_n_components is not None) and (ndims != fitted_n_components):
warnings.warn('Unequal values passed to dims and n_components. Using the already-fitted model.', stacklevel=external_stacklevel())
if format_data:
x = formatter(x, ppca=True)
x_reduced, fitted = reduce_list(x, None, reuse=reduce)
result = x_reduced if (internal or len(x_reduced) > 1) else x_reduced[0]
return (result, fitted) if return_model else result
elif isinstance(reduce, str):
model_name = reduce
model_params = {
'n_components': ndims
}
elif isinstance(reduce, dict):
if 'model' not in reduce:
raise ValueError(
"invalid reduce dict spec: pass the model as the value "
"of the 'model' key, with optional constructor arguments "
"under 'args' (positional) and 'kwargs' (keyword), e.g. "
"{'model': 'PCA', 'kwargs': {'whiten': True}} (the "
"legacy 'params' key is also accepted).")
if 'args' in reduce or 'kwargs' in reduce or 'params' not in reduce:
# canonical 1.0 dict spec: {'model': ..., 'args': [...],
# 'kwargs': {...}} -- BOTH 'args' and 'kwargs' are optional, so
# the minimal {'model': 'PCA'} works too (2026-07 release audit,
# final wave item 2: it used to crash with the invalid-dict-spec
# error even though the docs called args/kwargs optional)
if 'params' in reduce:
# both the canonical and the legacy parameter keys were
# given: the canonical 'args'/'kwargs' win, but say so
# instead of silently dropping 'params' (final wave item 5,
# matching hyp.cluster's warning)
warnings.warn(
"reduce spec contains both the canonical "
"'args'/'kwargs' keys and the legacy 'params' key; "
"ignoring 'params' and using 'args'/'kwargs'",
UserWarning, stacklevel=2)
c_model = reduce['model']
c_args = list(reduce.get('args', []))
c_kwargs = dict(reduce.get('kwargs', {}))
# remember whether the user left TSNE's perplexity at its
# sklearn default: reduce() clamps it for small datasets below
# (F11-reduce-describe-002), but must never touch a value the
# user set themselves
tsne_perplexity_unset = (
(c_model == 'TSNE'
or getattr(c_model, '__name__', None) == 'TSNE'
or type(c_model).__name__ == 'TSNE')
and 'perplexity' not in c_kwargs)
if isinstance(c_model, str):
try:
c_model = _resolve_model(c_model)
except KeyError:
# an unknown name inside the DICT form used to leak a
# bare KeyError ('Bogus') instead of the unknown-model
# error the bare-string path raises (release-1.0
# audit, X7-008 residue)
_raise_unknown_reduce_model(c_model)
# inject n_components=ndims when the model accepts it and the user
# did not set it themselves. Without this, the canonical dict form
# pre-built the instance with n_components=None (its default) and
# `ndims` was silently ignored -- e.g. reduce={'model':'PCA',
# 'kwargs':{'whiten':True}}, ndims=2 returned the FULL-dim data
# (QC 2026-07). The bare-string and legacy 'params' forms already
# applied ndims; this brings the documented dict form in line.
if (ndims is not None and 'n_components' not in c_kwargs
and inspect.isclass(c_model)
and 'n_components' in inspect.signature(c_model).parameters):
c_kwargs['n_components'] = ndims
# same for a top-level random_state (QC 2026-07 reproducibility): the
# dict spec constructs its instance here, so inject before that
if (random_state is not None and 'random_state' not in c_kwargs
and inspect.isclass(c_model)
and 'random_state' in inspect.signature(c_model).parameters):
c_kwargs['random_state'] = random_state
# construct immediately; the resulting instance flows through
# the same already-constructed-instance handling below as a
# bare instance passed directly as `reduce=`. An ALREADY-
# CONSTRUCTED instance inside the dict spec is used as-is --
# its 'args'/'kwargs' entries cannot be applied, so a
# UserWarning says so instead of crashing/silently dropping
# them (final wave item 4 parity with hyp.cluster).
if inspect.isclass(c_model):
model_name = c_model(*c_args, **c_kwargs)
else:
if c_args or c_kwargs:
warnings.warn(
f"the reduce spec's 'model' is an already-"
f"constructed {type(c_model).__name__} instance "
"(used as-is), so the spec's 'args'/'kwargs' "
"entries are ignored; configure the instance "
"directly, or pass the class (or its name) to "
"apply constructor parameters",
UserWarning, stacklevel=2)
model_name = c_model
model_params = {
'n_components': ndims
}
else:
model_name = reduce['model']
model_params = reduce['params']
# LEGACY form (dev-1.0/fork): accepted for backward
# compatibility, but deprecated in favor of the canonical
# {'model', 'args', 'kwargs'} triple above.
warnings.warn(
"{'model': ..., 'params': {...}} is deprecated; use "
"{'model': ..., 'args': [...], 'kwargs': {...}} instead",
DeprecationWarning, stacklevel=external_stacklevel())
else:
# handle other possibilities below: a bare (uninstantiated) custom
# model class, or an already-constructed custom model instance
model_name = reduce
model_params = {
'n_components': ndims
}
supported_names = sorted(list(REDUCERS) + ['UMAP'] + list(AUTOENCODER_NAMES))
# if the model passed is a string, make sure it's one of the supported options
if isinstance(model_name, str):
try:
model = _resolve_model(model_name)
except KeyError:
# name the offending value and, for near-misses like 'umap',
# suggest the correctly-cased registry name
# (F11-reduce-describe-005)
_raise_unknown_reduce_model(model_name)
# otherwise check any custom object for necessary methods
else:
model = model_name
if not (hasattr(model, 'fit_transform')
# mixture-style estimators (GaussianMixture, etc.) have no
# fit_transform; fit + predict_proba is an equally valid
# reducer-like interface (GH #174)
or (hasattr(model, 'fit') and hasattr(model, 'predict_proba'))):
raise ValueError(
f"invalid reduce model {model!r} (type "
f"{type(model).__name__}): a reduce spec must be a supported "
f"model name, a dict spec, a fitted Reducer, or a "
f"scikit-learn style class/instance with fit_transform (or "
f"fit + predict_proba) and n_components. Supported names: "
f"{', '.join(supported_names)}.")
# a bare class won't have n_components until it's constructed;
# only already-constructed instances are expected to have it
if not inspect.isclass(model) and not hasattr(model, 'n_components'):
raise ValueError(
f"invalid reduce model {model!r} (type "
f"{type(model).__name__}): the instance has no n_components "
f"attribute; construct it with an n_components (e.g. "
f"PCA(n_components=3)) or pass the bare class together with "
f"ndims=.")
# an already-constructed instance is used as-is: it's already configured,
# so we must not re-construct it or clobber its params below
model_is_instance = not inspect.isclass(model)
# check for multiple values from n_components & ndims args
if model_is_instance:
instance_n_components = getattr(model, 'n_components', None)
if (ndims is not None) and (instance_n_components is not None) and (ndims != instance_n_components):
warnings.warn('Unequal values passed to dims and n_components. Using the already-configured model instance.', stacklevel=external_stacklevel())
model_params['n_components'] = instance_n_components if instance_n_components is not None else ndims
elif 'n_components' in model_params:
if (ndims is None) or (ndims == model_params['n_components']):
pass
else:
warnings.warn('Unequal values passed to dims and n_components. Using ndims parameter.', stacklevel=external_stacklevel())
model_params['n_components'] = ndims
else:
model_params['n_components'] = ndims
# convert to common format
if format_data:
x = formatter(x, ppca=True)
# if ndims/n_components is not passed or all data is < ndims-dimensional, just return it
# (unwrap a single-dataset input to a bare array, matching the reduced-data
# path below and the fitted-model reuse path above -- QC 2026-07: this early
# return used to hand back a 1-element LIST for a single array, so the return
# type flipped between ndarray and list depending on ndims).
if model_params['n_components'] is None or all([i.shape[1] <= model_params['n_components'] for i in x]):
# requesting MORE dimensions than the data has used to skip the
# reduction silently -- even for explicitly-requested nonlinear
# embeddings, whose k-D output is not the same thing as k-D raw
# data (release-1.0 audit, D04-gallery-models-008). Internal
# display-path calls (which legitimately no-op on already-low-D
# data) stay silent.
if (not internal and model_params['n_components'] is not None
and any(i.shape[1] < model_params['n_components']
for i in x)):
warnings.warn(
f"ndims/n_components was set to "
f"{model_params['n_components']} but the data only has "
f"{max(i.shape[1] for i in x)} feature(s), so no reduction "
"was performed -- the input is returned unchanged. Request "
"at most as many dimensions as the data has features to "
"compute an actual embedding.", UserWarning, stacklevel=external_stacklevel())
result = x if (internal or len(x) > 1) else x[0]
return (result, None) if return_model else result
# Handle empty arrays and type conversion
if isinstance(x, list):
_require_equal_columns(x)
stacked_x = np.vstack([np.asarray(arr, dtype=np.float64) for arr in x])
if stacked_x.shape[0] == 1:
warnings.warn('Cannot reduce a single observation (row) of data; '
'returning a zero vector of length ndims instead. '
'The plotted/returned values do NOT reflect the '
'input -- pass at least 2 observations to compute a '
'real embedding.', stacklevel=external_stacklevel())
result = [np.zeros((1, model_params['n_components']), dtype=np.float64)]
result = result if (internal or len(x) > 1) else result[0]
return (result, None) if return_model else result
elif stacked_x.shape[0] < model_params['n_components']:
warnings.warn('The number of rows in your data is less than ndims.'
' The data will be reduced to the number of rows.', stacklevel=external_stacklevel())
model_params['n_components'] = stacked_x.shape[0]
if model_is_instance:
model.n_components = stacked_x.shape[0]
# reproducibility (QC 2026-07): inject a top-level `random_state` into the
# model's constructor kwargs when the model accepts it and the user did not
# set it themselves -- so `hyp.reduce(x, reduce='UMAP', random_state=1)`
# gives repeatable embeddings. Already-constructed instances are left as the
# user configured them.
if (random_state is not None and not model_is_instance
and 'random_state' not in model_params
and 'random_state' in inspect.signature(model).parameters):
model_params['random_state'] = random_state
# sklearn TSNE's default perplexity (30) requires n_samples > 30, so
# small datasets crashed on a parameter the user never set
# (F11-reduce-describe-002). When hypertools constructed (or is about to
# construct) the TSNE itself and the user left perplexity unset, clamp
# it to a workable value; user-supplied perplexities are never touched.
n_rows = stacked_x.shape[0]
if n_rows <= 30:
clamped_perplexity = max(1.0, (n_rows - 1) / 3.0)
tsne_warning = (
f"TSNE's default perplexity (30) must be less than the number "
f"of observations ({n_rows}); using "
f"perplexity={clamped_perplexity:g} instead. Set it yourself "
"with reduce={'model': 'TSNE', 'kwargs': {'perplexity': ...}}.")
if (not model_is_instance
and getattr(model, '__name__', None) == 'TSNE'
and 'perplexity' not in model_params):
warnings.warn(tsne_warning, UserWarning, stacklevel=external_stacklevel())
model_params['perplexity'] = clamped_perplexity
elif (model_is_instance and tsne_perplexity_unset
and type(model).__name__ == 'TSNE'
and getattr(model, 'perplexity', 0) >= n_rows):
# the canonical dict spec constructed this instance above,
# before the data size was known
warnings.warn(tsne_warning, UserWarning, stacklevel=external_stacklevel())
model.perplexity = clamped_perplexity
# pin MDS's changing sklearn defaults (n_init: 4 -> 1 in sklearn 1.9;
# init: 'random' -> 'classical_mds' in 1.10) to today's values so
# default MDS results stay stable across sklearn upgrades and default
# use is FutureWarning-free (F11-reduce-describe-016)
if not model_is_instance and getattr(model, '__name__', None) == 'MDS':
mds_params = inspect.signature(model).parameters
if 'n_init' in mds_params:
model_params.setdefault('n_init', 4)
if 'init' in mds_params:
model_params.setdefault('init', 'random')
# initialize model: bare classes are constructed with model_params;
# already-configured instances are used as-is
if not model_is_instance:
model = model(**model_params)
# reduce data
x_reduced, fitted = reduce_list(x, model)
# return data
if internal or len(x_reduced) > 1:
result = x_reduced
else:
result = x_reduced[0]
return (result, fitted) if return_model else result
# sub functions
def _require_equal_columns(x):
"""Raise a hypertools-level error for ragged dataset lists.
The stack-once-fit-once recipe (`reduce_list`) row-concatenates every
dataset and fits ONE model, which requires a shared feature space;
without this check, ragged lists died inside `numpy.vstack` with a raw
concatenation error that never mentioned datasets or columns
(F11-reduce-describe-007).
"""
widths = [np.atleast_2d(np.asarray(xi)).shape[1] for xi in x]
if len(set(widths)) > 1:
raise ValueError(
f"cannot reduce a list of datasets with different numbers of "
f"columns (got column counts {widths}): the datasets are "
f"stacked and fit in one SHARED space, which requires the same "
f"columns in every dataset. Bring them to a common set of "
f"columns first (e.g. hyp.align, or pad/trim the features).")
def reduce_list(x, model, reuse=None):
"""Helper function to reduce a list of arrays.
Stacks `x` (row-concatenated) into one array, fits (or reuses an
already-fitted `Reducer` on) that single stacked array -- so the
reduction is comparable across datasets -- and splits the result back
into a list matching `x`'s per-dataset row counts.
Parameters
----------
x : list of numpy.ndarray
Datasets to stack, reduce, and split back apart.
model : object or None
An already-configured (unfitted) scikit-learn-style reducer
instance to fit. Ignored when `reuse` is given.
reuse : Reducer or None
An already-fitted `Reducer` to `transform` `x` with instead of
fitting a new model (the `return_model=True` reuse path -- an
already-fitted model is applied via `.transform`, never refit).
Returns
-------
(list of numpy.ndarray, Reducer)
The reduced (or reused-transform) pieces, split back to match `x`'s
structure, and the (newly fitted, or reused) `Reducer` wrapper.
"""
# Ensure all arrays are float64 for consistent handling
x = [np.asarray(arr, dtype=np.float64) for arr in x]
_require_equal_columns(x)
split = np.cumsum([len(xi) for xi in x])[:-1]
stacked = np.vstack(x)
# Handle potential NaN values
if np.any(np.isnan(stacked)):
warnings.warn('NaN values detected in input data. These may affect the reduction results.', stacklevel=external_stacklevel())
if reuse is not None:
fitted = reuse
transformed = np.asarray(fitted.transform(stacked))
else:
fitted = Reducer(model)
transformed = np.asarray(fitted.fit_transform(stacked))
x_r = np.vsplit(transformed, split)
if len(x) > 1:
return [xi for xi in x_r], fitted
else:
return [x_r[0]], fitted