#!/usr/bin/env python
"""hyp.cluster dispatcher: resolve a cluster spec and fit_transform it.
Follows the same stack-once-fit-once recipe as `hyp.reduce`
(`hypertools.reduce.reduce`): the input is stacked into one 2D array, a
single `Clusterer` is fit on it (so cluster assignments are comparable
across input datasets), and hard-clustering models return a list of labels
while mixture/soft-clustering models return an (n_samples, n_components)
membership-proportion matrix (GH #174).
"""
import inspect
import warnings
import numpy as np
from .common import Clusterer, CLUSTERERS, MIXTURES, mixture_proportions, normalize_membership_rows
from ..core.model import external_stacklevel
from ..tools.format_data import format_data as formatter
# backward-compatible aliases: `hypertools.cluster.cluster.models` and
# `.mixture_models` were the pre-1.0 registries (plain dicts of name ->
# class). `hypertools.reduce.common` and `hypertools.core.pipeline` still
# import these names from here and must keep working unchanged.
models = CLUSTERERS
mixture_models = MIXTURES
def _resolve_cluster_spec(cluster, n_clusters, random_state=None,
n_clusters_explicit=False):
"""Resolve a `cluster=` spec into an unfitted `Clusterer`.
Accepts the full model-spec grammar: a registry name (string), a bare
(uninstantiated) scikit-learn-style class, an already-constructed
instance, the canonical dict spec `{'model': ..., 'args': [...],
'kwargs': {...}}` (both keys optional; positional `'args'` are bound
to the constructor's parameters by position, with `'kwargs'` winning
on a conflict -- final wave item 3), or the LEGACY dict spec
`{'model': ..., 'params': {...}}` (accepted for backward
compatibility, but emits a `DeprecationWarning`).
The `n_clusters=` convenience is preserved exactly as the pre-1.0 API
behaved: it is injected into the constructor only when the resolved
model's `__init__` signature accepts an `n_clusters` parameter
(density/bandwidth clusterers that discover their own cluster count --
HDBSCAN, MeanShift, DBSCAN, OPTICS, AffinityPropagation -- are left
alone); mixture models always get `n_components` instead. A cluster
count carried by the spec itself (an already-constructed instance's
own setting, a dict spec's kwargs, or a dict's top-level 'n_clusters')
always wins over the `n_clusters=` argument; when
`n_clusters_explicit` is True and the two conflict, a `UserWarning`
names the ignored value (F13-cluster-008/-009).
Parameters
----------
cluster : str, class, instance, or dict
The cluster spec (see above).
n_clusters : int
Number of clusters/components, used as described above.
random_state : int, RandomState, or None
Seed injected into the constructor when the model accepts one and
the spec did not set it (default: None).
n_clusters_explicit : bool
Whether `n_clusters` was explicitly provided by the caller (rather
than being the dispatcher default). Only explicit values trigger
conflict warnings (default: False).
Returns
-------
Clusterer
An unfitted `Clusterer` wrapping the resolved model.
"""
# already-resolved wrapper: pass an existing Clusterer through UNWRAPPED
# (idempotent). Wrapping it again in a fresh Clusterer double-wraps, and
# Clusterer.fit_transform then calls `.fit`/`.labels_` on the inner
# Clusterer, which has no such attributes (QC 2026-07: this crashed when a
# fitted Clusterer was reused alongside cross-module reduce=/manip=).
if isinstance(cluster, Clusterer):
return cluster
if isinstance(cluster, dict):
try:
model_name = cluster["model"]
except KeyError:
raise ValueError("If passing a dictionary, pass the model as the "
"value of the 'model' key and a dictionary of "
"custom parameters as the value of the 'kwargs' "
"key (the legacy 'params' key is also accepted).")
if "args" in cluster or "kwargs" in cluster:
# canonical 1.0 dict spec: {'model': ..., 'args': [...], 'kwargs': {...}}
if "params" in cluster:
# both the canonical and the legacy parameter keys were
# given: the canonical 'args'/'kwargs' win, but say so
# instead of silently dropping 'params' (F13-cluster-008)
warnings.warn(
"cluster spec contains both the canonical "
"'args'/'kwargs' keys and the legacy 'params' key; "
"ignoring 'params' and using 'args'/'kwargs'",
UserWarning, stacklevel=3)
model_params = dict(cluster.get("kwargs", {}))
model_args = list(cluster.get("args", []))
elif "params" in cluster:
# 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())
model_params = dict(cluster["params"])
model_args = []
else:
# e.g. {'model': ..., 'n_clusters': k} with no params/kwargs at all
model_params = {}
model_args = []
if "n_clusters" in cluster:
# top-level convenience: cluster={'model': ..., 'n_clusters': k}
if n_clusters_explicit and cluster["n_clusters"] != n_clusters:
warnings.warn(
f"n_clusters={n_clusters} conflicts with the cluster "
f"spec's own 'n_clusters' entry "
f"({cluster['n_clusters']}); using the spec's value",
UserWarning, stacklevel=3)
n_clusters = cluster["n_clusters"]
else:
model_name = cluster
model_params = {}
model_args = []
# bare classes and already-constructed instances are accepted anywhere
# a name string is
if not isinstance(model_name, str):
if not inspect.isclass(model_name):
# already-constructed instance: params ignored, used as-is
# (mirrors hypertools.reduce.common.Reducer)
if not (hasattr(model_name, 'fit')
or hasattr(model_name, 'fit_predict')):
# e.g. cluster=42: fail here with the accepted spec forms
# instead of a downstream AttributeError (F13-cluster-011)
raise ValueError(
f"invalid cluster model {model_name!r} (type "
f"{type(model_name).__name__}): it has no fit or "
f"fit_predict method. Pass one of the supported model "
f"names "
f"({', '.join(sorted(list(CLUSTERERS) + list(MIXTURES)))}), "
f"a scikit-learn style clusterer class or instance, a "
f"dict spec like {{'model': 'KMeans', 'kwargs': "
f"{{...}}}}, or a fitted Clusterer.")
if model_params or model_args:
# an already-constructed instance inside a dict spec cannot
# absorb the spec's 'args'/'kwargs'; warn instead of
# silently dropping them (final wave item 4, matching the
# instance + top-level n_clusters warning below)
dropped = [k for k, v in (("'args'", model_args),
("'kwargs'", model_params)) if v]
warnings.warn(
f"the cluster spec's 'model' is an already-constructed "
f"{type(model_name).__name__} instance (used as-is), so "
f"the spec's {' and '.join(dropped)} entries are "
"ignored; configure the instance directly, or pass the "
"class (or its name) to apply constructor parameters",
UserWarning, stacklevel=3)
if n_clusters_explicit:
# the instance's own configuration always wins; say so when
# it visibly conflicts with n_clusters= (F13-cluster-008)
inst_k = getattr(model_name, 'n_clusters',
getattr(model_name, 'n_components', None))
if inst_k is not None and inst_k != n_clusters:
warnings.warn(
f"cluster= is an already-constructed "
f"{type(model_name).__name__} instance (used as-is), "
f"so n_clusters={n_clusters} is ignored in favor of "
f"the instance's own setting ({inst_k}); configure "
f"the instance directly to change it",
UserWarning, stacklevel=3)
return Clusterer(model_name)
model_cls = model_name
registry_name = getattr(model_name, "__name__", str(model_name))
elif model_name in MIXTURES:
model_cls = MIXTURES[model_name]
registry_name = model_name
elif model_name in CLUSTERERS:
model_cls = CLUSTERERS[model_name]
registry_name = model_name
else:
raise ValueError(
f"unknown cluster model {model_name!r}; supported names: "
f"{', '.join(sorted(list(CLUSTERERS) + list(MIXTURES)))} (or "
f"pass a scikit-learn style instance directly)")
if model_args:
# honor a dict spec's positional 'args' (final wave item 3: they
# used to be silently DISCARDED, so cluster={'model': 'KMeans',
# 'args': [5]} quietly clustered with the default n_clusters=3).
# Bind each positional value to its parameter NAME so it
# participates in the documented precedence rules below
# (spec kwargs win over 'args' on a conflict, and a spec-carried
# cluster count wins over the n_clusters= argument).
try:
positional = [
p for p in inspect.signature(model_cls).parameters.values()
if p.kind in (inspect.Parameter.POSITIONAL_ONLY,
inspect.Parameter.POSITIONAL_OR_KEYWORD)]
except (TypeError, ValueError):
positional = []
if len(model_args) > len(positional):
raise TypeError(
f"the cluster spec's 'args' entry has {len(model_args)} "
f"value(s) ({model_args!r}) but {registry_name} accepts at "
f"most {len(positional)} positional argument(s); pass the "
"extra parameters by name in the spec's 'kwargs' instead.")
for value, p in zip(model_args, positional):
if p.name in model_params:
warnings.warn(
f"the cluster spec sets {p.name!r} both positionally "
f"(in 'args': {value!r}) and by name (in 'kwargs': "
f"{model_params[p.name]!r}); using the 'kwargs' value",
UserWarning, stacklevel=3)
else:
model_params[p.name] = value
if registry_name in MIXTURES:
if (n_clusters_explicit and "n_components" in model_params
and model_params["n_components"] != n_clusters):
warnings.warn(
f"n_clusters={n_clusters} conflicts with the cluster spec's "
f"n_components={model_params['n_components']}; using the "
f"spec's value", UserWarning, stacklevel=3)
model_params.setdefault("n_components", n_clusters)
elif "n_clusters" in inspect.signature(model_cls).parameters:
# only inject n_clusters if the resolved model actually accepts it --
# density/bandwidth clusterers (HDBSCAN, DBSCAN, MeanShift, OPTICS,
# AffinityPropagation) discover the number of clusters themselves
# and have no such __init__ parameter
if (n_clusters_explicit and "n_clusters" in model_params
and model_params["n_clusters"] != n_clusters):
warnings.warn(
f"n_clusters={n_clusters} conflicts with the cluster spec's "
f"n_clusters={model_params['n_clusters']}; using the spec's "
f"value", UserWarning, stacklevel=3)
model_params.setdefault("n_clusters", n_clusters)
# silence sklearn >= 1.8's FutureWarning about HDBSCAN's changing `copy`
# default by pinning today's effective value (False) explicitly
# (F13-cluster-014). Results are identical either way: `copy` only
# controls whether the input array may be modified in place, and
# cluster() always fits on a freshly stacked array.
if (registry_name == "HDBSCAN"
and "copy" in inspect.signature(model_cls).parameters):
model_params.setdefault("copy", False)
# reproducibility (QC 2026-07): inject a top-level random_state when the
# model accepts it and the user did not set it (KMeans, GaussianMixture,
# SpectralClustering, ...); density clusterers without one are left alone.
if (random_state is not None and 'random_state' not in model_params
and 'random_state' in inspect.signature(model_cls).parameters):
model_params['random_state'] = random_state
return Clusterer(model_cls, params=model_params)
[docs]
def cluster(x, cluster="KMeans", n_clusters=None, return_model=False,
manip=None, normalize=None, reduce=None, ndims=None, align=None,
format_data=True, random_state=None, model=None):
"""
Performs clustering analysis and returns a list of cluster labels
Parameters
----------
x : A Numpy array, Pandas Dataframe or list/tuple of arrays/dfs
The data to be clustered. You can pass a single array/df or a
list (a tuple of datasets is treated exactly like a list).
If a list is passed, the arrays will be stacked and the clustering
will be performed across all lists (i.e. not within each list).
All datasets in a list must have the same number of columns (the
stacked data shares one feature space); reduce or align them to a
common dimensionality first if they differ. `None` raises a
`TypeError`.
cluster : str, class, instance, dict, fitted Clusterer, False, or None
Model to use to discover clusters. Supported algorithms are: KMeans,
MiniBatchKMeans, AgglomerativeClustering, Birch, FeatureAgglomeration,
SpectralClustering, HDBSCAN, MeanShift, DBSCAN, OPTICS and
AffinityPropagation (default: KMeans), plus the mixture
(soft-clustering) models GaussianMixture, BayesianGaussianMixture,
LatentDirichletAllocation and NMF. 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;
positional `'args'` are bound to the model's constructor
parameters by position -- e.g. `{'model': 'KMeans', 'args': [5]}`
asks for 5 clusters -- with `'kwargs'` winning over `'args'` on a
conflict, and a spec-carried cluster count winning over
`n_clusters=`), or the LEGACY dict spec `{'model' : 'KMeans',
'params' : {'max_iter' : 100}}` (accepted for backward
compatibility, but emits a `DeprecationWarning`). A
previously-fitted `Clusterer` (as returned by `return_model=True`)
is applied via `.transform`/`.predict` instead of being refit;
no-predict models (e.g. AgglomerativeClustering) can only recover
their fit-time labels this way -- reusing them on different data
warns or raises rather than silently mislabeling. `None` or `False`
skips clustering entirely and returns the input unchanged. See
scikit-learn specific model docs for details on parameters
supported for each model. Note: LatentDirichletAllocation and NMF
require non-negative data, and FeatureAgglomeration clusters
features (columns), not observations -- it returns one label per
column of the input (with a `UserWarning`), not one per row.
n_clusters : int or None
Number of clusters to discover (default: None, which means 3).
Must be an integer >= 1; anything else raises a `ValueError`
naming the kwarg (rather than leaking scikit-learn's internal
parameter-validation error). Not
used for models that discover the number of clusters automatically
(HDBSCAN, MeanShift, DBSCAN, OPTICS, AffinityPropagation). For
mixture models this sets the number of components. If the cluster
spec itself carries a cluster count (an already-constructed
instance's own setting, or `n_clusters`/`n_components` in a dict
spec's kwargs), the spec's value wins and a `UserWarning` notes the
conflict.
return_model : bool
If True, also return the fitted model: the fitted `Clusterer`
wrapper when only the `cluster` stage ran, or a fitted
`hypertools.Pipeline` when `manip=`/`normalize=`/`reduce=`/`align=`
made multiple stages run (default: False).
manip, normalize, reduce, align : model spec, False, 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 `cluster=`/`n_clusters=` slotted in at the
cluster stage (default: None for all four, i.e. only `cluster`
runs). `False` skips a stage, exactly like None.
ndims : int or None
Passed through to the `reduce` stage (as `ndims=`) when `reduce=`
is also given. Without `reduce=` it has no effect, and a
`UserWarning` says so (the pre-1.0 `cluster(ndims=...)` shortcut
that reduced before clustering was removed).
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 clustering model's
constructor when it accepts a `random_state` (KMeans,
SpectralClustering, GaussianMixture, ...) and the spec did not set
one itself; density clusterers without a `random_state` parameter,
and already-constructed instances you pass in, are left alone
(default: None).
model : same forms as `cluster`, or None
Alias for `cluster=`, 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 `cluster=`/`model=`; passing
both (with different values) raises `ValueError` (default: None).
Returns
-------
cluster_labels : list or numpy.ndarray
For hard-clustering models, a list of plain Python `int` cluster
labels (one per
observation; FeatureAgglomeration instead returns one label per
COLUMN of the input -- it clusters features). For mixture models,
an (n_samples, n_components) array of membership proportions whose
rows sum to 1. If `return_model=True`, a `(cluster_labels, model)`
tuple is returned instead.
Examples
--------
>>> import numpy as np
>>> import hypertools as hyp
>>> rng = np.random.default_rng(0)
>>> x = np.vstack([rng.standard_normal((20, 4)),
... rng.standard_normal((20, 4)) + 10.0])
>>> labels = hyp.cluster(x, n_clusters=2, random_state=0)
>>> len(labels), len(set(labels))
(40, 2)
"""
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, 'cluster')
if isinstance(x, tuple):
x = list(x)
# model= is an alias for cluster= (release-1.0 audit,
# D05-gallery-data-text-020: manip/impute/predict/align spell their
# own-stage spec `model=`, and hyp.cluster(x, model='KMeans') used to
# die with a bare TypeError naming neither kwarg).
if model is not None:
if cluster != 'KMeans' and cluster is not model:
raise ValueError(
"cannot pass both cluster= and model=; they are aliases "
"for the same model spec -- pass just one (e.g. "
"cluster='HDBSCAN' or model='HDBSCAN').")
cluster = model
# False is an explicit "skip this stage", for every stage kwarg, exactly
# like None (F13-cluster-007 contract) -- normalize it up front so
# plot()/analyze() can thread cluster=False (and friends) through
if cluster is False:
cluster = None
manip = None if manip is False else manip
normalize = None if normalize is False else normalize
reduce = None if reduce is False else reduce
align = None if align is False else align
# n_clusters=None (the signature default) means 3; only an explicit
# value participates in spec-conflict warnings (F13-cluster-008/-009)
n_clusters_explicit = n_clusters is not None
if n_clusters is None:
n_clusters = 3
elif (isinstance(n_clusters, bool)
or not isinstance(n_clusters, (int, np.integer)) or n_clusters < 1):
# pre-validate the documented hypertools kwarg instead of leaking
# sklearn's private InvalidParameterError class (release-1.0 audit,
# X2-error-quality-012) -- matching hypertools' own validated
# params (ndims/t/resample all raise ValueError).
raise ValueError(
f'n_clusters must be an integer >= 1 (the number of clusters/'
f'mixture components to discover); got {n_clusters!r}.')
else:
n_clusters = int(n_clusters)
# a whole already-fitted Pipeline handed back as cluster= (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 Clusterer whose fit_transform reads `.labels_` off the
# Pipeline and crashes (QC 2026-07). Redundant stage kwargs are warned +
# ignored (the Pipeline already encodes them).
from ..core.shared import is_reused_pipeline
if is_reused_pipeline(cluster, {'manip': manip, 'normalize': normalize,
'reduce': reduce, 'align': align}, 'cluster'):
result = cluster.transform(x)
return (result, cluster) if return_model else result
# the pre-1.0 cluster(ndims=...) shortcut (reduce, then cluster) was
# removed: in 1.0, ndims= is only a passthrough to an explicitly
# requested reduce stage. Say so instead of silently no-oping the
# argument for migrating 0.x users (F13-cluster-019).
if ndims is not None and reduce is None:
warnings.warn(
f"cluster()'s ndims= is a passthrough to the reduce stage and "
f"has no effect unless reduce= is also given; ignoring "
f"ndims={ndims}. Pass e.g. reduce='IncrementalPCA' to reduce "
f"before clustering.", UserWarning,
stacklevel=external_stacklevel())
# 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 cluster<->core.pipeline cycle
# (core.pipeline itself lazily imports cluster.cluster).
if any(stage is not None for stage in (manip, normalize, reduce, align)):
from ..core.pipeline import build_pipeline
# bake n_clusters into the cluster-stage spec up front: build_pipeline
# has no n_clusters= kwarg of its own (mirrors how it threads ndims=
# through to the reduce stage), so the resolved (unfitted) Clusterer
# is what gets passed to the cluster stage instead of the raw spec
cluster_spec = (_resolve_cluster_spec(cluster, n_clusters, random_state,
n_clusters_explicit)
if cluster is not None else None)
pipeline = build_pipeline(manip=manip, normalize=normalize,
reduce=reduce, ndims=ndims,
align=align, cluster=cluster_spec)
result = pipeline.fit_transform(x)
return (result, pipeline) if return_model else result
if cluster is None:
return (x, None) if return_model else x
if format_data:
x = formatter(x, ppca=True)
# give ragged lists a real error instead of numpy's raw concatenation
# message (F13-cluster-012): the stack-once-fit-once recipe needs every
# dataset in one shared feature space
if isinstance(x, list) and len(x) > 1:
widths = [np.atleast_2d(np.asarray(xi)).shape[1] for xi in x]
if len(set(widths)) > 1:
raise ValueError(
f"cannot cluster a list of datasets with different numbers "
f"of columns (got column counts {widths}): the datasets are "
f"stacked and clustered in one shared feature space. Reduce "
f"or align them to a common dimensionality first, e.g. "
f"cluster(x, reduce='IncrementalPCA', ndims=k) or "
f"cluster(x, align='HyperAlign').")
stacked = np.vstack(x)
# an already-fitted Clusterer (returned from an earlier
# return_model=True call, or built above for the cross-module pipeline
# path) is reused via `transform`, never refit; an unfitted one is
# fit_transform'd directly, skipping spec re-resolution
if isinstance(cluster, Clusterer):
if cluster.is_fitted:
result = _plain_int_labels(cluster.transform(stacked))
else:
result = _plain_int_labels(cluster.fit_transform(stacked))
return (result, cluster) if return_model else result
clusterer = _resolve_cluster_spec(cluster, n_clusters, random_state,
n_clusters_explicit)
result = _plain_int_labels(clusterer.fit_transform(stacked))
return (result, clusterer) if return_model else result
def _plain_int_labels(result):
"""Convert a hard-clustering label list of numpy integer SCALARS to
plain Python ints (release-1.0 audit, X1-api-consistency-022: cluster()
was the one API spot handing back np.int32 scalars, which confuse
downstream serialization/comparison idioms). Mixture-model membership
matrices (numpy arrays) pass through unchanged."""
if isinstance(result, list):
return [int(v) if isinstance(v, np.integer) else v for v in result]
return result