#!/usr/bin/env python
"""hyp.plot: the main HyperTools visualization entry point.
Contains the `plot()` dispatcher (input normalization, the
manip/normalize/reduce/align/cluster analysis pipeline, hue/cluster/
MultiIndex regrouping, color/legend/colorbar resolution, streaming
dispatch, and save/return handling) plus its private helpers. Low-level
drawing lives in `matplotlib_backend` and `plotly_backend`.
"""
import copy
import inspect
import os
import warnings
import matplotlib.pyplot as plt
import pandas as pd
from .._shared.helpers import *
from .._shared.params import default_params
from ..core.model import external_stacklevel
from ..tools.analyze import analyze
from ..cluster.cluster import cluster as clusterer, mixture_models, \
models as hard_cluster_models
from .colors import mat2colors, colors2groups, get_palette_colors, continuous_colormap
from ..reduce.reduce import reduce as reducer
from ..tools.format_data import format_data
from .matplotlib_backend import _draw
from .backend import manage_backend
from .plotly_backend import resolve_backend
from .animate import _save_animation
from .surface import broadcast_surface, normalize_surface_arg
from .density import broadcast_density, normalize_density_arg
from .trails import broadcast_trail_flag
from .multiindex import expand_multiindex, build_multiindex_styles
from .morph import resolve_morph_rotations
from .fonts import resolve_font, sans_serif_stack
# GH #206: the subset of mpl_kwargs keys `plotly_backend.plotly_draw` (and
# its trail/forecast helpers) actually reads and maps onto a plotly trace
# property, via the existing `_resolve_fmt`/`_trace_name` machinery -- see
# every `tkwargs.get(...)` call in `hypertools/plot/plotly_backend.py`.
# Any OTHER kwarg (arbitrary matplotlib-style passthrough, e.g. `zorder=`,
# `markeredgecolor=`, `dashes=`) has no plotly equivalent and is silently
# unusable there; `plot()` warns (once, naming every such kwarg) rather
# than silently dropping it with no feedback at all.
_PLOTLY_MAPPED_KWARGS = frozenset(
{'color', 'alpha', 'linewidth', 'markersize', 'marker', 'linestyle',
'label'})
def _apply_extra_kwargs(kwargs_list, extra):
"""Merge arbitrary extra matplotlib-style kwargs (GH #206's `**kwargs`
passthrough) into every per-dataset dict in `kwargs_list`, IN PLACE.
Deliberately bypasses `parse_kwargs`'s per-dataset list/tuple
broadcasting entirely: `extra`'s values are applied VERBATIM,
identically, to every dataset, whatever their type -- including a
list/tuple value (e.g. `dashes=(4, 2)`), which is a single matplotlib
property VALUE, not a per-dataset list of separate values. Running
such a value through `parse_kwargs`'s broadcast machinery (designed
for hypertools' own per-dataset style kwargs -- color/marker/
linestyle/etc., where a list genuinely means "one value per dataset")
would either misinterpret it (if its length happened to equal the
dataset count) or now raise a spurious ``ValueError`` (since
`parse_kwargs` was fixed, also for GH #206, to raise rather than
silently drop a length that does NOT match the dataset count) -- both
wrong for a kwarg whose natural value just happens to be tuple/list
shaped. Callers needing genuine PER-DATASET control over an arbitrary
property can already reach for the dedicated, per-dataset-aware kwargs
(`color`/`marker`/`linestyle`/`markersize`/`linewidth`).
A key already present in a given dataset's dict (set by a named
parameter, e.g. `color=`, or by internal styling logic, e.g.
MultiIndex/mixture-cluster `alpha`, `legend=`'s `label`, `explore=`'s
`picker`) is left untouched -- named/internal styling always wins over
a same-named extra kwarg.
"""
if not extra:
return
for d in kwargs_list:
for k, v in extra.items():
if k not in d:
d[k] = v
# target vertex count for STATIC line smoothing -- matches the historical
# default density (frame_rate=30 * duration=30 = ~900 interpolated
# vertices), but as a fixed constant so animation kwargs no longer alter
# static rendering (release-1.0 audit, F01-007).
_STATIC_LINE_TARGET_VERTICES = 900
# de-emphasized color for UNLABELED points (the None entries of a
# partially-labeled categorical hue; release-1.0 audit, F02-013) -- the
# same neutral gray `colors.NAN_COLOR` uses for non-finite hue values, so
# "no information" reads consistently across the library.
_UNLABELED_HUE_COLOR = (0.75, 0.75, 0.75)
def _seaborn_palette_arg(palette, n_colors):
"""`palette` in a form seaborn's `color_palette`/`set_palette` accept.
plot() documents palette= as a name, a list of colors, or a matplotlib
`Colormap` (F02-011); seaborn handles the first two natively but not a
Colormap INSTANCE, so that one is pre-sampled to `n_colors` RGB tuples
via `get_palette_colors` (the same resolution `mat2colors`/the colorbar
use, keeping every path's colors identical)."""
from matplotlib.colors import Colormap
if isinstance(palette, Colormap):
return [tuple(c) for c in get_palette_colors(palette, n_colors)]
return palette
def _fmt_draws_line(fmt):
"""True if `fmt` draws a line for ANY trace (so the data must be
segmented into contiguous runs rather than globally merged by category;
GH #291). A per-trace fmt LIST counts if any entry has a line
component; a bare string/None uses `has_line_component` directly."""
if isinstance(fmt, (list, tuple, np.ndarray)):
return any(has_line_component(f) for f in fmt)
return has_line_component(fmt)
def _categorical_color_label_maps(hue, palette, explicit_colors,
group_labels, sort_numeric):
"""Map each hue category -> (colour, legend label), in the SAME drawn
order the marker/global grouping would use, so a LINE plot's per-category
colours match an equivalent marker plot.
`explicit_colors`/`group_labels` are the per-category values already
resolved by the special hue sub-cases (partially-labeled hue, quantized
matrix/mixture colours, cluster labels); when the colour was left to the
palette (plain string/int categorical, hard clustering) it is resolved
here from `palette`. `sort_numeric` selects sorted-numeric drawn order
(integer hue / cluster ids) over first-appearance order (string hue)."""
import seaborn as sns
appear = list(dict.fromkeys(hue))
if sort_numeric:
try:
drawn = sorted(appear)
except TypeError:
drawn = appear
else:
drawn = appear
if (isinstance(explicit_colors, (list, tuple))
and len(explicit_colors) == len(drawn)):
cat_color = {c: explicit_colors[i] for i, c in enumerate(drawn)}
else:
pal = sns.color_palette(
_seaborn_palette_arg(palette, len(drawn)), len(drawn))
cat_color = {c: tuple(pal[i]) for i, c in enumerate(drawn)}
if (isinstance(group_labels, (list, tuple))
and len(group_labels) == len(drawn)):
cat_label = {c: group_labels[i] for i, c in enumerate(drawn)}
else:
cat_label = {c: str(c) for c in drawn}
return cat_color, cat_label
def _regroup_categorical_lines(xform, hue, labels, cat_color, cat_label):
"""Regroup LINE data by categorical `hue` into contiguous runs (GH #291).
Splits each input dataset into maximal same-category runs (preserving
order + dataset identity), colours each run by its category, bridges only
runs adjacent WITHIN one input dataset, and gives each category exactly
ONE legend entry (the first run carries the label; later runs of the same
category get ``'_nolegend_'``). Returns
``(segments, seg_labels, run_colors, run_group_labels, seg_dataset)``;
`seg_dataset` is each run's source input-dataset index (for propagating
per-dataset styles via `_expand_styles_to_runs`)."""
segments, seg_labels, seg_cat, seg_bridge, seg_dataset = segment_by_run(
xform, hue, labels)
breaks = {i + 1 for i in range(len(segments) - 1) if not seg_bridge[i]}
segments = patch_lines(segments, breaks=breaks)
run_colors = [cat_color[c] for c in seg_cat]
seen = set()
run_group_labels = []
for c in seg_cat:
if c in seen:
run_group_labels.append('_nolegend_')
else:
seen.add(c)
run_group_labels.append(cat_label.get(c, str(c)))
return segments, seg_labels, run_colors, run_group_labels, seg_dataset
def _expand_styles_to_runs(fmt, mpl_kwargs, seg_dataset, n_datasets):
"""Propagate per-INPUT-DATASET styles across run segmentation (GH #291
follow-up).
Contiguous-run segmentation turns N input datasets into >= N drawn runs,
so a caller's per-dataset style list (`fmt` plus the NAMED styling kwargs
that reach `mpl_kwargs` -- ``color``/``marker``/``linestyle``/
``markersize``/``linewidth``) would otherwise fail the later one-value-
per-trace length checks. Any such list/tuple whose length equals the
INPUT-dataset count is expanded to run length by repeating each dataset's
value across the runs it produced; a list already at run length is left
untouched (explicit per-run styling). Generic ``**kwargs`` passthrough
values (e.g. ``alpha=``) never reach `mpl_kwargs` -- they are applied
verbatim per trace, never broadcast -- so they are unaffected here.
Returns the (possibly expanded) `fmt`; mutates `mpl_kwargs` in place.
`seg_dataset` gives each run's source-dataset index. When there is one
run per dataset (``len(seg_dataset) == n_datasets``) the two layouts
coincide and nothing needs changing."""
n_runs = len(seg_dataset)
if n_runs == n_datasets:
return fmt
def _expand(val):
if isinstance(val, (list, tuple)) and len(val) == n_datasets:
return [val[d] for d in seg_dataset]
return val
if isinstance(fmt, (list, tuple)) and len(fmt) == n_datasets:
fmt = [fmt[d] for d in seg_dataset]
for key in list(mpl_kwargs):
mpl_kwargs[key] = _expand(mpl_kwargs[key])
return fmt
def _interp_static_line(arr):
"""PCHIP-smooth a trajectory for STATIC line drawing, data-faithfully.
Subdivides each segment between consecutive samples with an equal
number of interpolated points so the result has roughly
`_STATIC_LINE_TARGET_VERTICES` vertices, while keeping EVERY original
sample (including the final one) as an exact vertex of the drawn line
(release-1.0 audit, F01-001: the historical `np.arange`-based grid
never reached the final sample -- lines stopped short of their true
endpoint, and for n > 900 samples the "interpolation" silently became
decimation). Interpolation only ever ADDS points between samples;
trajectories already at/above the target density are returned
unchanged (never decimated).
"""
from scipy.interpolate import PchipInterpolator as pchip
arr = np.asarray(arr)
n = arr.shape[0]
if n < 2 or n >= _STATIC_LINE_TARGET_VERTICES:
return arr
# interpolated points to ADD per segment
k = int(np.ceil((_STATIC_LINE_TARGET_VERTICES - n) / (n - 1)))
seg = np.linspace(0.0, 1.0, k + 2)[:-1] # left knot + k interior points
xx = np.concatenate([i + seg for i in range(n - 1)]
+ [np.array([n - 1.0])])
out = pchip(np.arange(n), arr)(xx)
# PCHIP passes through its knots up to floating error; enforce
# exactness so the drawn line provably contains every input sample.
out[::k + 1] = arr
return out
def _interp_anim_line(arr, n_frames):
"""Resample one trajectory onto the animation's exact frame grid.
PCHIP-interpolates (the same monotone interpolant the static path uses)
onto ``np.linspace(0, n - 1, n_frames)``: exactly `n_frames` rows -- one
per animation frame -- for EVERY dataset (release-1.0 audit: the
historical ``np.arange``-step grid was computed from the FIRST dataset
only, so later datasets of a different length were silently truncated or
ran out mid-animation, F04-003, and floating-point step error produced
901/41 frames where the docstring promises exactly
``frame_rate * duration``, F04-004). Endpoints are exact, so the
animation provably reaches the final sample.
"""
from scipy.interpolate import PchipInterpolator as pchip
arr = np.asarray(arr)
n = arr.shape[0]
if n < 2:
return arr
grid = np.linspace(0.0, n - 1.0, max(2, int(n_frames)))
out = pchip(np.arange(n), arr)(grid)
out[0] = arr[0]
out[-1] = arr[-1]
return out
def _require_finite_for_line(xi, dataset_index):
"""Fail fast, with a hypertools-level message, when a line-styled
trajectory still contains non-finite values after preprocessing.
PCHIP interpolation (static smoothing and animation frame gridding)
raises scipy's bare "`y` must contain only finite values." for NaN/inf
input. Rows with ALL features missing are the usual cause: the default
PPCA imputation cannot reconstruct them (it already warned), and the
raw scipy traceback named neither the problem nor the fix
(release-1.0 audit, F05-011).
"""
if not np.isfinite(np.asarray(xi, dtype=float)).all():
raise ValueError(
f"dataset {dataset_index} still contains non-finite values "
"(NaN/inf) after preprocessing, so its line cannot be smoothed/"
"animated. This usually means some rows had ALL features "
"missing -- the default PPCA imputation cannot fill those. "
"Drop those rows, impute them first (e.g. hyp.impute(data, "
"model='Kalman')), or plot markers only (fmt='.')."
)
def _normalize_save_path(save_path):
"""Validate/normalize ``save_path=`` up front (release-1.0 audit,
F09-004/F09-007).
Accepts any path-like (``pathlib.Path`` included -- downstream writers
call ``.lower()``/string slicing), expands ``~``, and fails fast --
BEFORE the expensive analyze/reduce/align pipeline runs and before any
figure is created -- on the misuses that previously surfaced as cryptic
deep-stack errors or silent misbehavior: a non-path type (was
``AttributeError: 'int' object has no attribute 'write'``), an empty
string (silently wrote a hidden ``'.png'`` file), an existing directory,
and a missing parent directory.
Returns
-------
str
The normalized filesystem path.
"""
try:
sp = os.fspath(save_path)
except TypeError:
raise TypeError(
"save_path must be a str or a path-like object (e.g. "
f"pathlib.Path); got {type(save_path).__name__}: {save_path!r}."
) from None
if isinstance(sp, bytes):
sp = os.fsdecode(sp)
if not sp.strip():
raise ValueError(
"save_path is an empty string; pass a real file path (e.g. "
"save_path='figure.png')."
)
sp = os.path.expanduser(sp)
if os.path.isdir(sp):
raise ValueError(
f"save_path points to an existing directory ({sp!r}); include "
f"a file name, e.g. save_path={os.path.join(sp, 'figure.png')!r}."
)
parent = os.path.dirname(os.path.abspath(sp))
if not os.path.isdir(parent):
raise FileNotFoundError(
f"save_path directory does not exist: {parent!r}. Create it "
"first (e.g. os.makedirs) or point save_path at an existing "
"directory."
)
return sp
def _is_numeric_matrix(x):
"""True when `x` is a plain python "matrix": a non-empty list whose
entries are all non-empty lists/tuples of scalars, with equal row
lengths -- e.g. ``[[1., 2.], [3., 4.]]``. Such input is ONE dataset
(exactly like the equivalent ``np.array``), not a nested list of
scalar "datasets" (release-1.0 audit, F01-004/F08-001)."""
if not (isinstance(x, list) and x):
return False
for row in x:
if not (isinstance(row, (list, tuple)) and len(row) > 0):
return False
if not all(isinstance(v, (int, float, np.number))
and not isinstance(v, bool) for v in row):
return False
return len({len(row) for row in x}) == 1
def _validate_labels_length(labels, dataset_lengths):
"""Raise a clear ValueError when `labels=` does not carry exactly one
entry per observation (release-1.0 audit, F01-010/F10-011: a short
list crashed with a bare IndexError; a long one was silently
truncated). Accepts flat lists or lists nested per dataset."""
n_obs = int(sum(dataset_lengths))
if any(isinstance(el, (list, tuple)) for el in labels):
n_labels = sum(len(el) if isinstance(el, (list, tuple)) else 1
for el in labels)
else:
n_labels = len(labels)
if n_labels != n_obs:
raise ValueError(
f"labels has {n_labels} entr{'y' if n_labels == 1 else 'ies'} "
f"but the data has {n_obs} observations; labels must have "
"exactly one entry per observation (use None entries for "
"points that should not be labeled).")
def _valid_line2d_kwargs():
"""The set of keyword-argument names matplotlib line artists accept
(full property names plus their aliases, e.g. both 'linewidth' and
'lw'), used to validate the GH #206 ``**kwargs`` passthrough up
front."""
from matplotlib.lines import Line2D
from matplotlib.artist import ArtistInspector
insp = ArtistInspector(Line2D)
valid = set(insp.get_setters())
for prop, aliases in getattr(insp, "aliasd", {}).items():
valid.add(prop)
valid.update(aliases)
return valid
def _validate_extra_plot_kwargs(extra_kwargs):
"""Fail fast, BEFORE the analyze/reduce pipeline runs, on extra kwargs
that no backend can use (release-1.0 audit, F01-012/F03-005):
previously a renamed 0.x kwarg (``group=``) or a misspelled stage
kwarg (``n_dims=``) ran the whole pipeline and then died with a
cryptic ``AttributeError: Line2D.set() got an unexpected keyword
argument ...``. Raises ``TypeError`` naming the kwarg, with a
did-you-mean hint where one exists."""
if not extra_kwargs:
return
if "group" in extra_kwargs:
raise TypeError(
"plot() got an unexpected keyword argument 'group'; group= "
"was renamed to hue= in hypertools 1.0 -- pass hue= instead.")
valid = _valid_line2d_kwargs() | set(_PLOTLY_MAPPED_KWARGS)
unknown = [k for k in extra_kwargs if k not in valid]
if unknown:
import difflib
import inspect
param_names = set(inspect.signature(plot).parameters) - {"x", "kwargs"}
candidates = sorted(param_names | valid)
k = unknown[0]
match = difflib.get_close_matches(k, candidates, n=1, cutoff=0.6)
hint = f"; did you mean {match[0]!r}?" if match else ""
raise TypeError(
f"plot() got an unexpected keyword argument {k!r}{hint} "
"(extra keyword arguments are passed through to matplotlib "
"line artists -- see the **kwargs entry in plot's docstring).")
def _resolve_animate_mode(animate, n_datasets):
"""Resolve ``animate=`` for ``animate='morph'`` support (Hungarian
point-cloud morphs between datasets, maintainer request): `animate` may
be a single GLOBAL mode (``False``/``True``/``'parallel'``/``'spin'``/
``'serial'``/``'morph'``, unchanged from before) OR, ONLY for morph, a
per-dataset list with ``'morph'``/``None``/``False`` entries (one per
FINAL -- post cluster/hue-reshape -- dataset, matching `n_datasets`):
``'morph'``-tagged datasets join the morph sequence IN LIST ORDER;
untagged datasets render as static (unanimated) backdrops.
Returns
-------
(mode, morph_tags)
`mode` is what every backend actually receives: the raw scalar
`animate` unchanged, or ``'morph'`` if a list was given. `morph_tags`
is ``None`` for every non-morph mode, or a list of `n_datasets` bool
(``True`` where that dataset joins the morph sequence) whenever
`mode` is ``'morph'`` (scalar ``animate='morph'`` tags every
dataset).
Raises
------
ValueError
A list entry is not ``'morph'``/``None``/``False``; a list's length
doesn't match `n_datasets`; or fewer than 2 datasets end up tagged
``'morph'`` (scalar or list form).
"""
if isinstance(animate, (list, tuple)):
tags = []
for item in animate:
if item in (None, False):
tags.append(False)
elif item == "morph":
tags.append(True)
else:
raise ValueError(
"animate list entries must be 'morph' or None/False "
"(per-dataset animate lists only support tagging "
f"datasets for animate='morph'); got {item!r}."
)
if len(tags) != n_datasets:
raise ValueError(
f"animate list has {len(tags)} entries but there are "
f"{n_datasets} datasets to plot; pass a single mode to "
"apply it to every dataset, or a list matching the "
"dataset count."
)
if sum(tags) < 2:
raise ValueError(
"animate='morph' (per-dataset list form) requires at "
f"least 2 datasets tagged 'morph'; got {sum(tags)}."
)
return "morph", tags
if animate == "morph":
if n_datasets < 2:
raise ValueError(
"animate='morph' requires at least 2 datasets to morph "
f"between; got {n_datasets}."
)
return "morph", [True] * n_datasets
return animate, None
[docs]
@manage_backend
def plot(
x,
fmt="-",
marker=None,
markers=None,
markersize=None,
linewidth=None,
linestyle=None,
linestyles=None,
color=None,
colors=None,
palette="hls",
hue=None,
color_reduce=None,
labels=None,
names=None,
legend=None,
colorbar=None,
title=None,
size=None,
elev=10,
azim=-60,
ndims=3,
reduce="IncrementalPCA",
cluster=None,
align=None,
normalize=None,
manip=None,
pipeline=None,
impute=None,
resample=None,
n_clusters=None,
random_state=None,
predict=None,
t=10,
save_path=None,
animate=False,
duration=30,
tail_duration=2,
rotations=1,
zoom=1,
chemtrails=False,
precog=False,
bullettime=False,
frame_rate=30,
focused=None,
morph_samples=None,
interactive=False,
explore=False,
backend="auto",
mpl_backend="auto",
show=True,
transform=None,
vectorizer="CountVectorizer",
semantic="LatentDirichletAllocation",
corpus="wiki",
ax=None,
frame_kwargs=None,
stream_init=10000,
stream_chunk=100,
stream_max=None,
stream_window=None,
return_model=False,
surface=None,
density=None,
font=None,
label_alpha=None,
xlabel=None,
ylabel=None,
zlabel=None,
**kwargs,
):
"""
Plots dimensionality reduced data and parses plot arguments
Parameters
----------
x : Numpy array, DataFrame, String, or mixed list
Data for the plot. The form should be samples (rows) by features
(cols). A plain python list of equal-length numeric lists (e.g.
``[[1., 2.], [3., 4.]]``) is treated as ONE dataset, exactly like
the equivalent ``np.array``. A bare scalar (e.g. ``hyp.plot(5)``)
is likewise accepted and treated as a single one-column
observation, drawn as a single point. When a list of several datasets is
given, every dataset must have the same number of columns
(features); to combine datasets with different feature counts,
bring them into a shared space first (e.g.
``hyp.plot(hyp.align(data, align='hyper'), ...)``).
Display space: static plots do NOT draw the input values in their
original units. The (possibly reduced/aligned) coordinates are
mean-centered and rescaled into ``[-1, 1]`` (a single shared
affine transform across all datasets) to fit hypertools' unitless
square/cube frame -- so coordinates read off the returned Figure
are an affine image of the analyzed data, not the raw values, and
scales are not comparable across separately-created figures. Use
``return_model=True`` to retrieve the analyzed (pre-rescale) data.
A DataFrame with a row **MultiIndex** (``x.index.nlevels >= 2``) is
handled specially (GH #95): it is expanded, BEFORE the format_data/
analyze/reduce pipeline runs, into one "leaf" dataset per unique full
index combination (level order as given), so leaves flow through
normalize/reduce/align exactly like any other list of datasets. AFTER
that pipeline transforms them, one MEAN trajectory is computed (in
the transformed/reduced space) for every unique value-combination of
each non-leaf level -- from the deepest such level up to the top
(outermost) level -- and appended as additional traces. For levels
numbered 0 (top) through L-1 (leaf), where L = ``x.index.nlevels``,
a trace whose deepest represented level is ``level_idx`` (``L - 1``
for a leaf; ``k`` for a mean over the prefix ``levels[0:k+1]``)
gets:
- ``linewidth = 1 + (L - 1 - level_idx)`` -- i.e. 1 plus the number
of levels averaged over: leaves are always 1, and each level
higher up is one point thicker, so the TOP-level means are the
thickest (``L``).
- ``alpha = min(1.0, 1 / (level_idx + 1) + 0.2)`` -- leaves are the
most transparent, the top-level mean is fully opaque (1.0), with
intermediate levels smoothly in between.
- ``color`` assigned purely by the trace's TOP-level index value
(from `palette`, in order of that value's first appearance) --
every leaf and every mean sharing the same top-level value shares
one color.
Example (2 levels, e.g. ``(condition, subject)``): leaves get
lw=1, alpha=0.7; the condition-means (the only non-leaf level, which
is also the top level here) get lw=2, alpha=1.0, and are the only
traces with a legend label. Example (3 levels, e.g.
``(group, condition, subject)``): leaves lw=1, alpha=1/3+0.2≈0.533;
(group, condition)-means lw=2, alpha=0.7; group-means (top level)
lw=3, alpha=1.0.
`legend` is automatically populated with one entry per unique
top-level index value: only each top-level mean trace carries that
label; every other trace (all leaves, and any intermediate-level
means) is drawn with ``label='_nolegend_'`` (excluded from the
legend, matching the convention `predict=`'s forecast overlay
already uses). If `linestyle`/`linestyles` is given as a list, its
length MUST equal the number of unique top-level index values (one
style per top-level group, applied to every trace in that group);
a mismatched length raises ``ValueError``. Any `color`/`colors`/
`linewidth` kwarg is ignored (with a ``UserWarning``) since
MultiIndex grouping owns those. `hue=` is superseded with a
``UserWarning`` (MultiIndex grouping takes precedence); `cluster=`/
`n_clusters=` raise ``ValueError`` (both would fight the MultiIndex
color assignment) -- reset the index first
(``df.reset_index(drop=True)``) to cluster instead. `predict=` also
raises ``ValueError`` when combined with MultiIndex expansion:
forecasts are computed one-per-leaf BEFORE the per-level mean
traces are appended, so the leaf count no longer matches the final
trace count -- reset the index first to use `predict=`. Row
averaging assumes member leaves align by row POSITION at each
timepoint; leaves of unequal length are averaged over their
overlapping prefix (the shortest member's length), with a single
``UserWarning`` per affected group (deduplicated even when a
3+-level tree causes multiple groupings to share members). Works
with both static and animated plots and both rendering backends,
since the expansion happens upstream of drawing. A MultiIndex on
the COLUMNS (as opposed to the row index) is unrelated to this and
is unaffected -- it is handled by the existing column-formatting
pipeline in `hypertools.tools.format_data`/`hypertools.tools.df2mat`.
A single-level (or default `RangeIndex`) DataFrame, or a plain
array/list input, is completely unaffected by any of the above.
Expansion is ONLY applied when a single bare DataFrame is passed as
`x`. If `x` is a LIST containing one or more MultiIndex DataFrames
(whether alone or mixed with arrays/other DataFrames), the
MultiIndex is silently treated as a flat index on each such element
by the normal list-of-datasets pipeline -- a ``UserWarning`` is
raised naming each offending element's position in the list.
fmt : str or list of strings
A list of format strings. All matplotlib format strings are
supported, including color letters (e.g. ``'ro-'`` draws red
markers joined by a red line, exactly as in matplotlib; an
explicit `color=`/`colors=` kwarg wins over a fmt color letter).
A single fmt string is broadcast to every drawn trace. A fmt LIST is
distributed one-entry-per-DRAWN-TRACE, and normally there is one
trace per input dataset. ``hue=``/``cluster=``/``n_clusters=`` (and a
MultiIndex) regroup the data so the drawn-trace count can differ from
the input-dataset count; in the ONE reconciled case -- a categorical
(or cluster) LINE, which splits each dataset into one trace per
contiguous same-category run so lines never join separate
trajectories (GH #291) -- a fmt list given at INPUT-dataset length is
automatically propagated to each dataset's runs, so
``hyp.plot([A, B], hue=h, fmt=['.', '-'])`` draws every run of A with
markers and every run of B as a line. Otherwise (marker-only
grouping, MultiIndex) the fmt list must match the drawn-trace count.
A list matching neither the input-dataset count nor the drawn-trace
count raises a ``ValueError`` naming fmt and both counts. A fmt tuple
is accepted and treated exactly like the equivalent list.
Static line rendering is DATA-FAITHFUL: line styles are smoothed
by PCHIP interpolation, which only ever ADDS points between
samples -- every original sample (including the final one) is
always among the drawn line vertices, and trajectories with ~900+
samples are drawn as-is (never decimated).
A format string combining a LINE style with a MARKER (e.g. 'o-',
's--') gets the SAME connecting-line smoothing/interpolation a
pure line style (e.g. '-') gets (GH #141 follow-up; previously
marker+line combos silently skipped interpolation, drawing
straight/unsmoothed segments between raw points). The line and
markers are drawn as two separate artists on the STATIC (non-
animated) matplotlib backend: the smoothed/interpolated line, plus
markers at the TRUE (pre-interpolation) sample points -- so
markers never drift onto the dense interpolated curve. Pure line-
only and pure marker-only styles are unaffected (still one
artist, as before). For ANIMATED matplotlib plots, and for the
plotly backend (static or animated -- it always draws a marker+
line combo as a single 'lines+markers' trace), a marker+line
combo's line is likewise now smoothed (the interpolation gate fix
is backend-agnostic), but its markers currently render at the
same (interpolated) points as the line rather than only the
original samples -- splitting those into separate artists/traces
for every animated style and for plotly is a follow-up.
linestyle(s) : str or list of str
A list of line styles
marker(s) : str or list of str
A list of marker types
markersize : int or float
Size of the markers in points (default: matplotlib's 6.0). Applies
to both backends.
linewidth : int or float
Width of plotted lines in points (default: matplotlib's 1.5 for
static plots, 1 for animations). Applies to both backends.
color(s) : str or list of str
A list of colors
**kwargs : any other matplotlib-style keyword argument
GH #206: any keyword argument that isn't one of `plot()`'s own
named parameters above is passed straight through to each drawn
artist -- e.g. `zorder=3`, `alpha=0.5`, `dashes=(4, 2)`,
`markeredgecolor='k'`. Applied VERBATIM, identically, to every
drawn dataset -- unlike `color`/`marker`/`linestyle`/etc. (see
below), an extra kwarg's value is NEVER interpreted as "one entry
per dataset" even if it happens to be a list/tuple (e.g.
`dashes=(4, 2)` is a single dash-pattern VALUE, not per-dataset
values `4` and `2`) -- so there is no per-dataset form for an
extra kwarg; use one of the dedicated per-dataset-aware kwargs
(`color`/`marker`/`linestyle`/`markersize`/`linewidth`) for that.
Merged in AFTER the named style kwargs are resolved, so an
explicit named kwarg (or internal styling logic, e.g. MultiIndex/
mixture-cluster `alpha`, `legend=`'s `label`, `explore=`'s
`picker`) always wins on a naming collision. A kwarg that no
backend can use (not a matplotlib line-artist property or alias,
nor a plotly-mappable name) raises ``TypeError`` naming it -- with
a did-you-mean hint for near-misses of plot's own parameters
(e.g. ``n_dims`` -> ``ndims``) -- BEFORE the pipeline runs, rather
than surfacing a cryptic matplotlib internals error after it
(release-1.0 audit; the legacy 0.x ``group=`` kwarg gets a
dedicated "renamed to hue=" message). On the plotly backend, only
a small subset maps onto an actual trace property (`color`,
`alpha`, `linewidth`, `markersize`, `marker`, `linestyle`,
`label`); anything else is ignored with a ``UserWarning`` naming
every unmapped kwarg (rather than raising, since plotly's trace
objects were never going to support the same kwarg surface as
matplotlib).
Every list/tuple-valued NAMED styling kwarg `plot()` itself
broadcasts (`color`/`colors`, `marker`/`markers`, `linestyle`/
`linestyles`, `linewidth`, `markersize` -- NOT the generic `**kwargs`
passthrough above, which is applied verbatim and never broadcast, so
`alpha=`, `zorder=`, etc. must be a single value) is distributed
one-entry-per-DRAWN-TRACE and its length is validated against the
FINAL drawn-trace count (GH #206); a mismatch raises a ``ValueError``
naming the kwarg, the length given, and that count (previously it
silently degraded to `None` for every trace).
`cluster=`/`hue=`/`n_clusters=`/MultiIndex regroup the data, so the
final drawn-trace count can differ from the number of INPUT datasets.
In ONE case the two layouts are reconciled for you: a categorical
(or cluster) LINE splits each dataset into one trace per contiguous
same-category run (GH #291), and a style list given at INPUT-dataset
length is automatically propagated to every run that dataset produced
-- so ``hyp.plot([A, B], hue=h, linewidth=[1, 3])`` draws all of A's
runs at width 1 and all of B's at width 3 (a list already at run
length is used verbatim). For every OTHER regrouping -- marker-only
hue/cluster grouping (which merges observations across datasets into
one per-category trace, so a per-dataset style is not even well
defined), and MultiIndex expansion -- a style list must match the
resulting drawn-trace count, not the input-dataset count.
palette : str, list of colors, or matplotlib.colors.Colormap
A matplotlib or seaborn color palette (name), an explicit list of
colors (hex strings like '#ff0000', named colors like 'red', or
RGB(A) tuples -- usable on every path: categorical, continuous,
matrix hue, and the colorbar), or a matplotlib `Colormap` instance
(sampled evenly). For a CONTINUOUS `hue`, a short color list is
blended into a smooth gradient using the listed colors as anchors
(seaborn ``blend_palette`` semantics); for categorical/matrix hue
the list must supply at least one color per category/component.
Note the default 'hls' (like 'husl') is CYCLIC: for a continuous
`hue` mapping, hypertools samples only ~5/6 of its hue circle so
the minimum and maximum hue values stay visually distinguishable;
categorical palettes are used as-is.
hue : list, numpy array, pandas Series/Index/Categorical, or 2D matrix
Values used to color the plot, one per observation, matched to the
observations POSITIONALLY (a pandas Series' index is ignored).
Accepts categorical labels (one per observation; grouped and
colored by category), continuous numeric values (mapped through
the palette; combined with a line format this produces
multicolored lines whose color varies continuously along each
trajectory, and a marker+line combo format like ``'o-'`` keeps
BOTH components -- the multicolored line plus per-point-colored
markers at the true sample points), or a 2D matrix with one row
per observation (e.g.
mixture proportions or model weights; colors are blended per
observation). Non-finite (NaN/inf) continuous/matrix hue values
are drawn in a neutral light gray (with a warning) and are
excluded from the color mapping, so the remaining observations
keep their full color range. To label a subset of points
categorically, use None entries (i.e. ['a', None, 'b', 'a']):
the None-labeled points are drawn in the same de-emphasized
neutral gray, get no legend entry, and do not consume a palette
slot (the named categories keep the first palette colors, in
first-appearance order).
The categorical-vs-continuous choice: string labels always take
the CATEGORICAL path (one trace per category, legend-able,
categories in first-appearance order). A 1-D numeric hue takes
the CONTINUOUS path (per-point palette-mapped colors, no
legend), EXCEPT that integer (or boolean) values with at most 12
unique values -- and fewer unique values than observations --
are treated as categorical group ids (e.g. the cluster labels
``hyp.cluster`` returns): one trace per id, palette-colored and
legend-labeled in sorted numeric order. Float-valued or
higher-cardinality integer hues are always continuous. To force
grouping, pass the ids as strings (``hue=[str(g) for g in
ids]``); to force a continuous mapping, cast to float
(``hue=np.asarray(ids, dtype=float)``).
A SCALAR `hue` (a single string or number, e.g. ``hue='red'``) is
broadcast to one group covering every observation -- a single
color -- and emits a `UserWarning`, since this is usually a
mistake (e.g. a DataFrame column NAME passed seaborn-style; pass
the column's values, ``hue=df['col']``, instead).
When the data is a list of datasets, `hue` may mirror that nesting --
one hue sub-sequence per dataset, each matching that dataset's length
(e.g. ``hyp.plot([d0, d1], hue=[h0, h1])``); it is flattened to one
value (or matrix row) per observation.
A 2D matrix hue with MORE than 3 columns (or any matrix, if
`color_reduce=` is given) is first reduced to 3 columns and mapped
directly to (r, g, b) -- see `color_reduce`.
color_reduce : str, dict, class, instance, or None
How to reduce an arbitrary high-dimensional matrix `hue` to the 3
columns used as (r, g, b). Any `hyp.reduce` spec (default: None ->
'IncrementalPCA'). Only applies when `hue` is a 2D matrix; the three
reduced dimensions are min-max scaled to [0, 1] per column and used as
the red/green/blue channels, so an arbitrary per-observation feature
matrix becomes a continuous RGB coloring. A matrix `hue` with <=3
columns is left on the palette-blend path unless `color_reduce=` is
given explicitly.
names : list or None
Per-DATASET names, one per dataset in a list input (default: None).
Distinct from `labels` (per-POINT text call-outs) and `hue` (per-
observation coloring): each name labels its dataset's trace and turns
the legend on, so `hyp.plot([raw, a, b], names=['raw', 'a', 'b'])`
shows a legend naming the three datasets. Must have exactly one entry
per dataset; mutually exclusive with passing a `legend=` list (use one
or the other). Rendered on both the matplotlib and plotly backends.
Incompatible with a CATEGORICAL `hue` (which regroups the data by
category, so the drawn traces are no longer the named datasets);
that combination raises ``ValueError`` -- label the hue categories
with ``legend=[...]`` instead.
labels : list
A list of point labels: exactly one entry per OBSERVATION (row)
across all datasets, or a nested list with one sub-list per
dataset; a length mismatch raises ``ValueError`` naming labels and
both counts. If no label is wanted for a particular point, input
None for that entry.
In an ANIMATION whose frame grid is coarser than the data (fewer
than one frame per sample), each label is attached to the nearest
drawn frame point, so labels are never silently dropped.
Supported on BOTH backends (GH #205/#F3): matplotlib draws these as
`ax.annotate` call-outs; plotly draws the same points as
`layout.scene.annotations` (3D) or `layout.annotations` (2D), at
the same data coordinates, honoring the resolved `font=` (see
below) the same way the legend/colorbar/title do.
label_alpha : float or None
Opacity of the translucent background box drawn behind each
`labels=` point annotation (GH #103). `None` (default) keeps the
historical opacity, 0.5, on both backends. Must be a number in
``[0, 1]``; any other value raises `ValueError`. On matplotlib
this sets the annotation `bbox`'s `alpha`; on plotly it sets the
alpha channel of the annotation's `bgcolor`
(``'rgba(255,255,255,<label_alpha>)'``). Works for both static
and animated plots (labels are drawn once, at the original data
coordinates, and persist across every frame on both backends).
legend : list, str, or bool
If set to True, legend is implicitly computed from data. Passing a
list will add string labels to the legend (one for each list
item); the list must have exactly one entry per drawn dataset/
group (``ValueError`` naming legend otherwise). A bare string is
treated as a single-entry list (valid only for a single dataset).
colorbar : bool or dict
If True, draws a colorbar reflecting the color mapping in use
(GH #100). For a continuous 1D `hue` (or continuous `hue` combined
with a line format, which produces multicolored lines), the
colorbar is a continuous `ScalarMappable` spanning the ACTUAL
`hue` value range, using the SAME palette as the lines/markers.
For discrete groups (categorical `hue`, `cluster`/`n_clusters`, or
a plain list of datasets with no `hue`/`cluster`), the colorbar is
segmented (one BoundaryNorm-style block per group), with tick
labels taken from an explicit ``legend=[...]`` list if given, else
the categorical `hue`'s own category names (no ``legend=True``
needed), else ``1..n``. Pass a dict for finer control:
``{'label': str, 'ticks': [...], 'location': 'right'|'left'|'top'|
'bottom'}`` (all keys optional; ``location`` defaults to
``'right'``, the same side as the legend -- when both a legend and
a right-side colorbar are shown, the figure is widened so neither
is clipped or overlaps the other). Raises ``ValueError`` if
requested with no color mapping available at all (e.g. a single
dataset with no `hue`/`cluster`). Default None (no colorbar).
title : str
A title for the plot
font : None, str, or matplotlib.font_manager.FontProperties
Controls the font used for every text surface hypertools draws,
on BOTH backends (GH #205): point annotations (`labels=`), the
legend, colorbar tick labels/axis label, and the plot title -- on
matplotlib via `ax.annotate`/`ax.legend`/etc.; on plotly via
`layout.scene.annotations`/`layout.annotations`, the legend,
colorbar title/ticks, and the plot title.
- `None` (default): hypertools uses its own sans-serif FALLBACK
STACK, led by the Noto Sans face bundled with the package (SIL
OFL 1.1, in ``hypertools/external/fonts``). matplotlib is handed
that font FILE, so the MATPLOTLIB backend renders in the bundled
Noto Sans identically on every platform. The PLOTLY backend can
only pass a family NAME to the rendering browser (it cannot use a
font file), so it *prefers* Noto Sans but falls back to the next
installed system face when Noto isn't present -- plotly typography
may therefore vary by platform. Both backends resolve their stack
PER GLYPH (matplotlib walks a ``font.family`` list; a browser walks
a CSS stack), so text mixing scripts renders completely from
several faces (Latin from Noto Sans, Japanese from an installed
CJK face, math symbols from DejaVu Sans) instead of showing tofu
for whatever the primary face lacks -- and, crucially, the primary
face stays Noto Sans, so a stray accent or Greek letter does NOT
swap the whole plot onto some other font. Only when the stack has
a genuine COVERAGE GAP (a script no stack family can draw) does
hypertools scan for an installed font covering that gap and ADD it
as an extra fallback (Noto stays primary) -- to matplotlib's
``font.family`` list and, appended near the end, to the plotly CSS
stack (the latter still needs that family installed in the browser
to take effect; see the backend note below). A ``UserWarning`` is
raised only for characters NOTHING available can draw, naming them.
Bundling every script is infeasible (a pan-CJK face alone is
~16 MB), so for full CJK coverage install a pan-Unicode font --
``apt-get install fonts-noto-cjk`` on most Linux distros;
macOS/Windows usually already ship one (Hiragino Sans/Yu Gothic).
- `str`: either the name of an installed font FAMILY (e.g.
``'Noto Sans CJK JP'``), or a path to a ``.ttf``/``.otf``/
``.ttc`` font FILE (existing paths are detected automatically,
relative or absolute). Raises ``ValueError`` if the string is
neither a resolvable family name nor an existing file.
- `matplotlib.font_manager.FontProperties`: used as-is.
Backend semantics differ because matplotlib and plotly resolve
fonts differently: matplotlib accepts a font FILE and sets a
`FontProperties` object on each `Text` artist individually
(exact glyph outlines, embedded at save time). plotly (rendered
by a browser, or by Chromium via kaleido for static image export)
only understands FAMILY NAMES -- there is no way to point it at a
specific font file -- so hypertools takes the resolved font's
family name (`FontProperties.get_name()`) and puts it at the FRONT
of hypertools' curated sans-serif CSS stack (e.g.
``'"<name>", "Noto Sans", "Helvetica Neue", ..., sans-serif'``),
and sets it as `layout.font.family`, which every plotly text
surface hypertools creates inherits unless it overrides its own
`font.family` (none do, after this change). Static plotly image
export (`save_path=...png/.jpg` etc., via kaleido) still depends
on the exporting machine's OS having a font that actually covers
the requested family/characters -- unlike matplotlib, hypertools
cannot embed a specific font file into a plotly export.
xlabel, ylabel, zlabel : str or None
Axis labels, on BOTH backends, for STATIC and ANIMATED plots, in
2-D and 3-D (round17 #7). `None` (default): no label, EXCEPT that
when a single DataFrame with named (non-default, non-duplicate)
columns is plotted and the drawn axes correspond 1:1 to its
(df2mat-transformed) columns -- a 2- or 3-column DataFrame drawn
with no real dimensionality reduction -- the column names become
the default axis labels (release-1.0 audit, F08-016). Explicitly
passed labels always win (pass e.g. ``xlabel=''`` to suppress an
inferred label), and nothing is inferred when `transform=` or
`pipeline=` replace the standard analysis pipeline. matplotlib:
`ax.set_xlabel`/`ax.set_ylabel`/`ax.set_zlabel`; hypertools draws
its own cube/square frame in place of matplotlib's default axes
box (ticks/spines/panes are hidden), so whenever any of these
three is given, only the specific label Text artist(s) are kept
visible rather than the whole axis (ticks/spines/gridlines/3-D
panes stay hidden either way). plotly: `layout.scene.xaxis.title`/
`.yaxis.title`/`.zaxis.title` for 3-D, `layout.xaxis.title`/
`.yaxis.title` for 2-D -- again with only that axis's title shown
(ticks/gridlines/zero-line stay hidden). `zlabel` on a 2-D plot
(`ndims` < 3, or data that is intrinsically lower-dimensional)
raises `ValueError` (no z-axis to label) -- pass `ndims=3` (the
default) to use `zlabel=`, or use `xlabel=`/`ylabel=` for 2-D
data.
size : list
A [width, height] pair of numbers, in inches, to resize the figure
(anything else raises ``ValueError`` naming size)
elev : int or float
The camera elevation angle, in degrees, for 3-D plots: the angle
above (positive) or below (negative) the x-y plane (default: 10,
matplotlib's `Axes3D.view_init` convention). Must be a number;
ignored for 2-D/1-D plots.
azim : int or float
The camera azimuth angle, in degrees, for 3-D plots: the rotation
of the viewpoint about the z axis (default: -60, matplotlib's
`Axes3D.view_init` convention). Must be a number; ignored for
2-D/1-D plots. For every rotating 3-D animation style ('spin',
'parallel'/True, 'window', 'serial', 'morph') this is the STARTING
azimuth; the camera sweeps `rotations` full turns from it, so
rotations=0 gives a fixed camera at exactly this angle.
normalize : str, False, or None
If set to 'across', the columns of the input data are z-scored
across lists. If set to 'within', the columns are z-scored within
each list that is passed. If set to 'row', each row of the input
data is z-scored. If set to False or None, no normalization is
applied (default: None).
manip : model spec or None
A `hypertools.manip` spec (a registry name, dict spec, class/
instance, or a `list` chaining several -- see
`hypertools.manip.manip.manip`), run at the canonical `manip` stage
position (GH #153): FIRST, before `normalize`/`reduce`/`align`/
`cluster` -- e.g. ``hyp.plot(data, manip=[{'model': 'Smooth',
'kwargs': {'kernel_width': 25}}, {'model': 'Resample', 'kwargs':
{'n_samples': 1000}}], align={'model': 'HyperAlign'},
reduce='UMAP')`` runs the whole cross-module pipeline in one call
(GH #275). `resample=` (below) is independent sugar for a single
`Resample` step applied BEFORE this stage's data reaches it (so
resample sugar always runs first when both are given). Mutually
exclusive with `pipeline=` (default: None).
pipeline : hypertools.Pipeline or None
A previously-FITTED `Pipeline` (e.g. from
``hyp.analyze(data, ..., return_model=True)`` or this function's
own `return_model=True` bundle's `'pipeline'` key) to apply to `x`
via `.transform` instead of fitting new `manip`/`normalize`/
`reduce`/`align`/`cluster` models (GH #227) -- e.g. fit on dataset
A via ``p = hyp.analyze(A, manip='Smooth', reduce='PCA',
align='HyperAlign', return_model=True)[1]`` and reuse those exact
fitted parameters on a structurally-identical dataset B via
``hyp.plot(B, pipeline=p)``. Mutually exclusive with `manip=`/
`normalize=`/`reduce=`/`ndims=`/`align=`/`cluster=` (each must be
left at its default) -- passing both raises `ValueError` naming the
conflicting kwarg(s). `resample=` is still applied (as sugar, before
`pipeline.transform` runs) since it is not one of the stage kwargs
the fitted `Pipeline` itself covers (default: None).
reduce : str, dict, class, instance, or fitted Reducer
Decomposition/manifold learning model to use (default:
'IncrementalPCA'). Models supported: PCA, IncrementalPCA, SparsePCA,
MiniBatchSparsePCA, KernelPCA, FastICA, FactorAnalysis, TruncatedSVD,
DictionaryLearning, MiniBatchDictionaryLearning, TSNE, Isomap,
SpectralEmbedding, LocallyLinearEmbedding, MDS, and UMAP; the mixture
(soft-clustering) models GaussianMixture, BayesianGaussianMixture,
LatentDirichletAllocation and NMF (which return per-observation
membership proportions, GH #174); and the torch-backed autoencoders
Autoencoder, DeepAutoencoder, SparseAutoencoder,
ConvolutionalAutoencoder, SequenceAutoencoder and
VariationalAutoencoder (GH #162, `pip install "hypertools[torch]"`).
Can be passed as a string, or for finer control of the model
parameters as a dictionary, e.g.
reduce={'model': 'PCA', 'kwargs': {'whiten': True}}. See scikit-learn
specific model docs for details on parameters supported for each model.
A model INSTANCE (including an already-FITTED reducer, which is
applied via `.transform` without refitting) is also accepted; if
its output still has more than 3 dimensions (e.g.
``PCA(n_components=5)``), a second display-only reduction with the
default reducer projects it to 3 dimensions for plotting. If None,
no reduction is applied -- valid only when the data already has at
most 3 (or `ndims`) dimensions; otherwise a ``ValueError``
explains that the data cannot be drawn unreduced.
ndims : int
An `int` representing the number of dims to reduce the data x
to. If ndims > 3, the data is analyzed at that dimensionality but
plotted in 3 dimensions (a second, display-only reduction with the
default reducer); use ``return_model=True`` to retrieve the
higher-dimensional analyzed data. Default is 3 (plot in 3
dimensions).
align : str, dict, False, or None
Alignment model to bring a list of datasets into a shared space.
If str, 'hyper' (hyperalignment) or 'SRM' (shared response model).
You can also pass a dictionary for finer control, where the 'model'
key specifies the model and 'kwargs' holds its parameters, e.g.
align={'model': 'HyperAlign', 'kwargs': {'n_iter': 10}}. If False or
None, no alignment is applied (default: None).
cluster : str, dict, class, instance, False, or None
If cluster is passed, HyperTools will perform clustering using the
specified clustering model (a registry name, dict spec, model
class, or sklearn-API model instance -- an instance's own
parameters are used, so `n_clusters=` is ignored, with a warning,
alongside one). Supported algorithms are: KMeans,
MiniBatchKMeans, AgglomerativeClustering, Birch,
SpectralClustering, MeanShift, DBSCAN, OPTICS, AffinityPropagation and
HDBSCAN, plus the mixture (soft-clustering) models GaussianMixture,
BayesianGaussianMixture, LatentDirichletAllocation and NMF. Can be
passed as a string, or for finer control of the model parameters as a
dictionary, e.g. cluster={'model': 'KMeans', 'kwargs': {'max_iter':
100}}. See scikit-learn specific model docs for details on parameters
supported for each model. If no parameters are specified a default set
of parameters will be used: 3 clusters/components for most models
(the same default as `hyp.cluster`), 20 components for
LatentDirichletAllocation and NMF (default: None). Clustering runs
on the REDUCED (post `normalize=`/`reduce=`/`align=`) scores, not
the raw input -- so LatentDirichletAllocation and NMF, which
require non-negative data, fail here even when the raw data is
non-negative (the reduced scores are signed); run `hyp.cluster`
on the raw data instead to use those models. FeatureAgglomeration
raises a ``ValueError``: it clusters features (columns), not
observations, so its labels cannot group the plotted rows -- use
`hyp.cluster(data, cluster='FeatureAgglomeration')` directly.
Cluster labels are drawn as one trace per cluster (palette
colors, legend entries in sorted label order).
n_clusters : int
If n_clusters is passed, HyperTools will perform clustering with
the cluster count set to n_clusters, using k-means unless
`cluster=` selects another model. The resulting clusters are
plotted in different colors according to the color palette.
Default None: each model's default count is used (3, matching
`hyp.cluster`; 20 components for LatentDirichletAllocation/NMF).
Ignored, with a ``UserWarning``, for models that discover the
number of clusters themselves (HDBSCAN, MeanShift, DBSCAN,
OPTICS, AffinityPropagation). If the `cluster=` spec itself
carries a cluster count (an 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 -- the same
precedence `hyp.cluster` applies.
random_state : int, RandomState, or None
Seed for reproducibility, threaded to the reduce/cluster stages: it is
injected into any stage model whose constructor accepts a
`random_state` (UMAP, TSNE, KMeans, GaussianMixture, ...), so e.g.
`hyp.plot(x, reduce='UMAP', random_state=0)` gives a repeatable
embedding. Deterministic models and pre-constructed instances are
unaffected (default: None).
impute : str or dict or class or class instance or None
Overrides the default PPCA fill for missing (NaN) values with a
different `hypertools.impute` model, e.g. 'Kalman', 'KNNImputer'
(default: None, i.e. PPCA -- observed values are preserved
exactly and only the NaN entries are reconstructed; see
`hypertools.impute.ppca`). See `hypertools.impute.impute` for
accepted forms.
resample : int or False/None
If set to an integer `N` (GH #94), each input dataset is
PCHIP-resampled to exactly `N` rows via the existing
`hypertools.manip` ``Resample`` manipulator, applied right after
`hypertools.tools.format_data` (so it sees whatever `x` has been
normalized into -- a plain list of per-dataset numpy arrays) and
BEFORE the normalize/reduce/align pipeline. `resample=500` on a
100-row dataset produces per-dataset arrays with exactly 500 rows
going into normalize/reduce/align/cluster/hue -- and the SAME
values `hyp.manip(data, model='Resample', n_samples=500)` produces
on that same input, since it is the identical manipulator call
under the hood. This is independent
of, and happens well before, the later line-smoothing
interpolation (GH #141) applied for animation/line-drawing
purposes. Default `None` (no resampling, unchanged pre-existing
behavior); `False` is equivalent to `None`. Raises ``ValueError``
if `resample` is anything other than `False`/`None` or an integer
``>= 2``.
predict : str or dict or class or class instance or None
If set, forecasts `t` new rows per input dataset (in the plotted,
post normalize/reduce/align space) using the specified
`hypertools.predict` model, e.g. 'Kalman', 'ARIMA', 'GaussianProcess'
(see `hypertools.predict.predict` for accepted forms), and overlays
one dashed, low-opacity (alpha 0.6) forecast trace per dataset in the
SAME color as its source line (no separate legend entry). The
drawn overlay prepends the last observed row so the dashed trace
connects to the trajectory (`t + 1` drawn vertices); the forecast
DATA itself -- e.g. in the ``return_model=True`` bundle -- has
exactly `t` rows, matching `hyp.predict`. Only
supported for STATIC plots (default: None; raises
``NotImplementedError`` if combined with ``animate``).
t : int or datetime-like
Forecast horizon passed to `predict` (see
`hypertools.predict.common.resolve_t`); ignored unless `predict` is
set (default: 10).
save_path : str or path-like
Path to save the image/movie; the format is chosen by the file
extension, which must be included (e.g.
save_path='/path/to/file/image.png'). ``pathlib.Path`` objects work
everywhere a str does, a leading ``~`` is expanded, and the target
directory must already exist (a missing directory/empty path/
non-path value fails fast with a clear error before the plot is
computed). Supported formats: STATIC matplotlib plots accept any
`matplotlib.pyplot.savefig` format (.png, .pdf, .svg, .eps, .jpg,
...); ANIMATED matplotlib plots accept .gif, .png/.apng (animated
PNG), and .svg (animated vector graphics) with no extra
dependencies, plus the video formats .mp4/.mov/.avi/.m4v/.mkv,
which -- and ONLY which -- require FFmpeg (https://ffmpeg.org;
e.g. ``brew install ffmpeg`` on macOS with Homebrew
(https://brew.sh) or ``apt-get install ffmpeg`` on Debian/Ubuntu).
The plotly backend saves .html natively (static or animated);
its static image export (.png/.jpg/.svg/.pdf, via kaleido) and
animated .gif/.png/.apng/video export render each frame through
kaleido. Note on ANIMATION export cost: every frame is rendered
and encoded, and the default `duration=30` x `frame_rate=30`
yields 900 frames -- roughly a minute of encoding and a
multi-MB file even for small datasets; encoding time and file
size scale linearly with `duration * frame_rate`, so pass a
shorter `duration=` (e.g. 2-5 seconds) for quick exports.
animate : bool, 'parallel', 'spin', 'serial', 'window', 'morph', or list
If True or 'parallel', plots the data as an animated trajectory, with
each dataset plotted simultaneously. If 'spin', all the data is plotted
at once but the camera spins around the plot. If 'serial', datasets
appear ONE AT A TIME in list order: each grows point-by-point into
place while all previous datasets stay fully drawn, and datasets are
never connected to each other -- useful for e.g. conversation turns
accumulating in a shared embedding space (default: False). This MODE
is always GLOBAL -- there is exactly one camera and one frame loop
driving every dataset in the animation, so it cannot vary per
dataset (unlike `chemtrails`/`precog`/`bullettime` below, which CAN).
2-D animations (round17 #9, GH #123): every style EXCEPT `'spin'`
works for `ndims=2` as well as `ndims=3`, in both backends, using a
FIXED (non-rotating) viewport -- there is simply no camera-angle
bookkeeping to do in 2-D. `'spin'` rotates the camera and nothing
else, so it is meaningless for 2-D data and raises `ValueError`
(naming the other styles) instead of silently doing nothing.
`rotations=`/`zoom=` are 3-D camera controls with no 2-D
equivalent; passing either as a non-default value alongside a
2-D `animate` warns once (`UserWarning`) that it is ignored, in
both backends -- including `animate='morph'`, whose `rotations`
doubles as a per-segment PACING control in 3-D (not purely a
camera control there -- see the note under `'morph'` below), but
which is ignored the SAME way for consistency with every other
2-D style: 2-D morphs always use even segment timing.
If 'window' (round17 #8, GH #275 -- Jeremy's own definition:
"like bullettime, but without the precog and chemtrail parts"), a
sliding, FULLY-OPAQUE window of length `focused` (seconds; see
`focused` below) moves along each trajectory -- nothing outside the
window is drawn at all, not even a faded trail (unlike `bullettime`/
`chemtrails`/`precog`, which paint a low-opacity backdrop outside
their own in-focus window). In 3-D, the camera still rotates at a
constant speed per `rotations`, exactly like `True`/`'parallel'`; in
2-D there is no camera, so only the window itself moves. Any of
`chemtrails`/`precog`/`bullettime` passed alongside `animate='window'`
is ignored (`UserWarning`, naming the ignored flag(s) and dataset
indices -- see the note under `bullettime` below), since 'window' has
no trail artist/trace to configure. Both 2-D and 3-D, in both
backends.
If 'morph' (maintainer request, 2026-07-06), every dataset is
treated as a POINT CLOUD (not a trajectory, regardless of `fmt`) and
morphed ds_1 -> ds_2 -> ... -> ds_N through a hold/morph/hold/...
schedule (``2N - 1`` segments: ``[hold_1, morph_1->2, hold_2, ...,
hold_N]``). Every dataset keeps its FULL point count (maintainer
request, 2026-07-06 follow-up): the target count `n` is the
LARGEST morphing dataset's own size (after the optional
`morph_samples` cap below), and any dataset with `m < n` points is
padded up to `n` by duplicating `n - m` of its OWN points, chosen
at random (seeded) -- no real data point is ever dropped. The
duplicated (padding) points are hidden during that dataset's own
HOLD segments (so semi-transparent markers alpha-composite exactly
like a plain plot of that dataset's true points) and shown, like
every other point, during MORPH segments. Consecutive (now equal-
sized, `n`-point) clouds are chain-matched point-for-point with the
Hungarian algorithm (`scipy.optimize.linear_sum_assignment` on
pairwise distances, so each point travels the shortest total
distance to its partner in the next cloud -- exactly
`examples/plot_shape_morph.py`'s original hand-rolled algorithm,
now built into the library), and eased between clouds with
smoothstep interpolation. A SINGLE point artist/trace is drawn
(one per plot, not one per dataset): its color linearly (RGB)
interpolates between the two datasets' own colors during a morph
segment and is solid during a hold. Requires at least 2 datasets;
raises `ValueError` otherwise. Both 2-D and 3-D data are supported
(round17 #9, GH #123 -- previously 2-D raised `NotImplementedError`);
`surface=True` recomputes that one artist's hull every frame from
its current interpolated positions (unaffected by which points are
duplicates -- a duplicate is an exact copy of an existing point, so
it never changes a convex hull's shape), but this hull-tracking is
still 3-D only -- `surface=` is silently a no-op for an animated
2-D `'morph'` (or any other 2-D animate style; see `surface`'s own
docstring).
`animate` may ALSO be a per-dataset LIST (length = the number of
FINAL, post `cluster`/`hue`-reshape datasets), with each entry
`'morph'`, `None`, or `False`: `'morph'`-tagged datasets join the
morph sequence IN LIST ORDER; untagged datasets are drawn as STATIC
(unanimated) backdrops, present in every frame. At least 2 entries
must be `'morph'` (`ValueError` otherwise); any other mode string
inside a list raises `ValueError` (list form only supports tagging
datasets for `animate='morph'` -- 'spin'/'serial'/etc. cannot vary
per dataset, see above). A scalar `animate='morph'` is equivalent to
tagging every dataset `'morph'`.
`animate` may ALSO be a `dict` (GH #154 resolution): a mega-dict
SPEC for the animation, mirroring the model-spec grammar used
elsewhere in hypertools -- `'style'` plays the role of `model`
(REQUIRED; the value is any of the scalar `animate` forms above,
e.g. `'spin'`) and every OTHER key maps onto one of the flat
animation kwargs below (`duration`, `tail_duration`, `rotations`,
`zoom`, `chemtrails`, `precog`, `bullettime`, `frame_rate`,
`focused`, `morph_samples`) -- e.g. ``animate={'style': 'spin', 'rotations':
2, 'duration': 15}`` is exactly equivalent to
``animate='spin', rotations=2, duration=15``. The dict is unpacked
into the flat kwargs at the very top of `plot()`, before anything
else runs, so every downstream code path only ever sees the flat
form. Raises `ValueError` if `'style'` is missing (message shows
an example dict), if the dict has any key that isn't `'style'` or
one of the flat animation kwargs above (message lists the valid
keys), or if a dict key's value CONFLICTS with that same flat
kwarg passed explicitly (a different value) -- naming the
conflicting key and both values. This mega-dict form is additive
sugar, not a new pipeline concept -- flat kwargs remain the
primary/documented direction (GH #154); note that a `style=`/
`labels=` mega-dict covering EVERY `plot()` kwarg (not just
animation) was considered and explicitly rejected as unnecessary
churn.
backend : str
Rendering backend: 'matplotlib' (the classic renderer),
'plotly' (interactive; requires plotly -- install with
`pip install hypertools[interactive]`), or 'auto' (default), which
uses plotly on Google Colab / Kaggle notebooks where interactivity
matters most and matplotlib everywhere else. With the plotly backend,
the return value is a plotly Figure (any animation frames are
embedded directly in it, so no separate animation object is
returned).
duration (animation only) : float
Length of the animation in seconds (default: 30 seconds). Has no
effect on static plots (static line smoothing uses a fixed
density, independent of the animation kwargs). Note: when saving
with `save_path=`, every frame is rendered and encoded
(`duration * frame_rate` frames -- 900 at the defaults), so
export time and file size scale linearly with `duration`; use a
short duration (e.g. 2-5 seconds) for quick exports.
tail_duration (animation only) : float
Sets the length of the tail of the data (default: 2 seconds)
rotations (animation only) : float or list
Number of rotations around the box over the course of the
animation (default: 1 -- with the default 30-second duration,
one revolution every 30 seconds). Identical pacing on both
backends. A list is ONLY valid with `animate='morph'`: it must have
exactly ``2N - 1`` entries (`N` = the number of morphing datasets),
one per hold/morph segment (``[hold_1, morph_1->2, hold_2, ...,
hold_N]``) -- e.g. `rotations=[1, 0.25, 2, 0.25, 1]` for 3 morphing
datasets spins 1 full rotation during the first hold, a quarter
rotation during the first morph, 2 rotations during the second
hold, etc. Camera rotation speed (degrees/frame) is CONSTANT across
the whole animation: each segment's SCREEN TIME (frame count) is
proportional to its own rotation count -- not split evenly across
segments -- so a segment with more rotations gets more time, never
faster spinning (see :func:`hypertools.plot.morph.segment_frame_counts`
and its `ZERO_ROTATION_FLOOR`: a segment with 0 rotations still gets
a small amount of screen time so it stays visible). Within a
segment, that segment's own rotation count is spread uniformly over
its own frames, and the camera azimuth accumulates CONTINUOUSLY
across segment boundaries (no jump). `N` is the number
of morphing datasets AFTER the reduce/align/cluster/hue pipeline
(the FINAL, drawn dataset count), which can differ from the number
of datasets originally passed in. `ValueError` if a list is given
with any `animate` mode other than `'morph'` (checked immediately,
before the pipeline runs -- this only depends on `animate` itself),
or if the list length doesn't match ``2N - 1`` (names the expected
length; only knowable once `N` is, so checked after the pipeline
runs).
zoom (animation only) : float
How far to zoom into the plot, positive numbers will zoom in (default: 1)
chemtrails (animation only) : bool or list of bool
A low-opacity trail is left behind the trajectory (default: False).
Pass a list of bool (one entry per drawn dataset -- i.e. the FINAL
count after any `cluster`/`hue`/`n_clusters` regrouping) for
per-dataset control (GH #127): e.g. `chemtrails=[True, False]` turns
chemtrails on for dataset 0 only. A bare bool is broadcast to every
dataset. Raises `ValueError` if a list's length does not match the
number of drawn datasets (naming both counts). Trail styles
(`chemtrails`/`precog`/`bullettime`) only apply when
`animate=True`/`'parallel'` -- see the note under `bullettime` below.
precog (animation only) : bool or list of bool
A low-opacity trail is plotted ahead of the trajectory (default:
False). Accepts a per-dataset list exactly like `chemtrails` above,
and the two may be mixed per dataset (e.g. dataset 0 chemtrails,
dataset 1 precog, dataset 2 bullettime).
bullettime (animation only) : bool or list of bool
A low-opacity trail is plotted ahead and behind the trajectory
(default: False). Accepts a per-dataset list exactly like
`chemtrails` above. For any single dataset, `bullettime=True` (or
`chemtrails=True` AND `precog=True` together) shows the FULL trail;
`chemtrails` alone shows only the past window; `precog` alone shows
only the future window; none of the three shows just the moving
window (no separate trail artist/trace at all for that dataset).
GH #127: trail styles apply ONLY to `animate=True`/`'parallel'`.
`'spin'` has no "current position" for a trail to lead/follow (only
the camera moves), and `'serial'`'s point-by-point reveal already
communicates elapsed time, so `animate='spin'`/`'serial'` ignore
`chemtrails`/`precog`/`bullettime` entirely (no trail artist/trace
is created) and emit a `UserWarning` naming the mode, the ignored
flag(s), and which dataset indices had them set.
frame_rate (animation only) : int or float
Frame rate for animation in frames per second (default: 30).
Both backends generate exactly frame_rate * duration frames, so
matplotlib and plotly animations play at identical speed,
duration, and framerate. Has no effect on static plots (static
line smoothing uses a fixed density, independent of the animation
kwargs).
focused (animation only) : float or None
Round17 #8 (GH #275): the length, in SECONDS -- the SAME unit as
`tail_duration` -- of the "in-focus" (fully-opaque) window: the
portion of a trajectory drawn opaque by default under `chemtrails`/
`precog`/`bullettime`, or the sliding window size for the new
`animate='window'` (see `animate` above). Default `None`: resolves
to `tail_duration`'s own value -- today's hardcoded/`tail_duration`-
derived focus length -- so omitting `focused=` never changes
existing behavior; pass an explicit `focused=` to decouple the
in-focus window's length from `tail_duration` (e.g. a wide
`chemtrails` fade with a narrow opaque head, or vice versa).
Silently ignored (no error, no warning -- this is the documented,
expected no-op case) for `animate='spin'`/`'parallel'` (or `True`)
with NO `chemtrails`/`precog`/`bullettime` flag set on any dataset,
and for `animate='morph'` -- none of these has a separate "in-focus
window distinct from the whole trajectory" concept for `focused` to
control. Must be a non-negative number if given; raises `ValueError`
otherwise.
morph_samples (``animate='morph'`` only) : int or None
An OPTIONAL cap on morphing-dataset size, applied BEFORE the
duplicate-padding described under `animate` above: any morphing
dataset larger than `morph_samples` is first downsampled (without
replacement, seeded) to exactly `morph_samples` points. Default
`None`: no cap -- every dataset keeps its full point count, and the
target count is simply the largest dataset's own size. Since the
Hungarian assignment's cost is roughly ``O(n^3)`` in the (post-cap)
target point count, `morph_samples` is RECOMMENDED for clouds
larger than ~2000 points (e.g. `morph_samples=1000`) -- the
uncapped default can be slow, or memory-heavy, for very large
datasets. Must be a positive integer (or None); anything else
raises ``ValueError``. Ignored for every other `animate` mode.
interactive : bool
If True, display the plot using an interactive matplotlib
backend. Useful for inspecting and manipulating static plots. If
animate=True, an interactive backend is required and this
argument has no effect (default: False).
explore : bool
If True, hovering over a data point displays that point's
user-defined label (from `labels=`); if no labels were passed, the
point's index and coordinates are shown instead. Explore mode is
currently only supported for 3D static plots (``ValueError``
otherwise), and is an experimental feature (i.e. it may not yet
work properly). Hover labels require an interactive matplotlib
backend: under a non-interactive backend (e.g. Agg in scripts,
CI, or the docs build) the figure is drawn as a static plot and a
``UserWarning`` explains that hover labels are unavailable.
mpl_backend : str
The matplotlib backend used to create interactive and animated
plots. May be 'auto' (default), 'disable', or a backend key
accepted by matplotlib. If 'auto', hypertools will use a backend
determined automatically based on your environment
(`from hypertools.plot.backend import HYPERTOOLS_BACKEND`). If
'disable',
experimental backend-switching is disabled and the current global
matplotlib backend (`matplotlib.get_backend()`) is used.
Otherwise, try to use the backend specified. NOTES: *This
feature is experimental*. For a list of interactive matplotlib
backends, see `matplotlib.rcsetup.interactive_bk`. For a list of
backends available in IPython, run `%matplotlib --list`. Set the
`$HYPERTOOLS_BACKEND` environment variable or use
`hypertools.set_interactive_backend()` to override the backend
used by 'auto' in non-IPython environments. If `animate=False`
and `interactive=False`, this argument has no effect. Within the
`hypertools.set_interactive_backend(backend)` context manager,
the value of `backend` is prioritized over this argument.
show : bool
If set to False, the figure will not be displayed, but it is still
returned (and remains valid/savable; see Returns). With show=False,
hypertools also closes/deregisters its pyplot figure once drawing
(and any `save_path=` export) is done -- including animated figures
on non-GUI backends -- so batch-export loops never accumulate open
figures. Note that show=True displays the figure in notebook/
IPython contexts (and the plotly backend calls its own renderer in
scripts); in a plain non-interactive Python script the matplotlib
backend registers the figure with pyplot but does not itself call
``plt.show()`` -- call ``plt.show()`` yourself to open a window.
Default: True.
transform : list of numpy arrays or None
The transformed data, bypasses transformations if this is set
(default : None).
vectorizer : str, dict, class or class instance
The vectorizer to use. Built-in options are 'CountVectorizer' or
'TfidfVectorizer'. To change default parameters, set to a dictionary
e.g. {'model' : 'CountVectorizer', 'kwargs' : {'max_features' : 10}}
(the legacy {'model', 'params'} form is also still accepted). See
https://scikit-learn.org/stable/api/sklearn.feature_extraction.html
for details. You can also specify your own vectorizer model as a class,
or class instance. With either option, the class must have a
fit_transform method (see https://scikit-learn.org/stable/data_transforms.html).
To set parameters, use the dict form (or a configured class
instance); a bare class is instantiated with its defaults.
semantic : str, dict, class or class instance
Text model to use to transform text data. Built-in options are
'LatentDirichletAllocation' or 'NMF' (default: LDA). To change default
parameters, set to a dictionary e.g. {'model' : 'NMF', 'kwargs' :
{'n_components' : 10}} (the legacy {'model', 'params'} form is also
still accepted). See
https://scikit-learn.org/stable/api/sklearn.decomposition.html
for details on the two model options. You can also specify your own
text model as a class, or class instance. With either option, the class
must have a fit_transform method (see
https://scikit-learn.org/stable/data_transforms.html).
To set parameters, use the dict form (or a configured class
instance); a bare class is instantiated with its defaults.
corpus : list (or list of lists) of text samples or 'wiki', 'nips', 'sotus'.
Text to use to fit the semantic model (optional). If set to 'wiki', 'nips'
or 'sotus' and the default semantic and vectorizer models are used, a
pretrained model will be loaded which can save a lot of time.
ax : matplotlib.Axes
Axis handle to plot the figure
frame_kwargs : dict
Keyword arguments for styling the frame drawn around the plot.
For 3D plots, the frame is a cube and `frame_kwargs` are
forwarded to `mpl_toolkits.mplot3d.axes3d.Axes3D.plot_wireframe`.
For 2D plots, the frame is a square and `frame_kwargs` are
forwarded to `matplotlib.patches.Rectangle`.
stream_init : int
Streaming data only (iterators/generators and Hugging Face
``datasets.IterableDataset`` are detected automatically): number of
initial samples used to estimate the normalization and reduction
parameters (default: 10000). Those fitted models are then *applied*
to all future samples, which are added to the plot dynamically.
Only a subset of `plot`'s parameters applies to streaming inputs:
`fmt`, the four `stream_*` parameters, `ndims`, `reduce`,
`normalize`, `align`/`cluster`/`n_clusters` (rejected with a
``ValueError`` -- not yet supported for streams -- but accepted at
their defaults), `save_path`, `show`, `frame_rate`, `markersize`,
`linewidth`, `color`, `palette`, `title`, `size`, `elev`, `azim`,
and `ax`. Any other parameter explicitly set alongside a
streaming input is ignored, with a ``UserWarning`` naming it. In
particular, streaming plots are always drawn with the matplotlib
backend: a `backend=` request (e.g. ``backend='plotly'``) is
ignored with that warning, and the return value is a matplotlib
``Figure`` even when the plotly backend was requested.
stream_chunk : int
Streaming data only: number of new samples fetched from the stream
per update (default: 100). Each fetched chunk is projected through
the fitted models and rendered as one animation frame / live
redraw, so this sets both the download batch size and the temporal
resolution of the resulting animation.
stream_max : int or None
Streaming data only: stop streaming after this many samples.
Exactly `stream_max` samples are consumed from the stream (never
more), and the returned figure's ``stream_info['truncated']`` is
then True -- it means streaming was stopped (by `stream_max`, an
interrupt, or an error) before the stream was observed to end.
Default None streams continually until the stream is exhausted or
the user interrupts (Ctrl-C); infinite streams render incoming
data indefinitely, and any animation being saved via `save_path`
is finalized whenever streaming stops (including on interrupt).
For streams, `save_path` supports .gif/.png/.apng (Pillow) and,
with FFmpeg installed, .mp4/.mov/.avi/.m4v/.mkv; other extensions
raise ``ValueError`` before any samples are consumed.
stream_window : int or None
Streaming data only: if set, only the most recent `stream_window`
samples are displayed (comet style) while older samples scroll off;
all consumed samples are still retained on the returned figure's
``stream_info`` dict (its ``'data'``/``'xform_data'`` entries).
Default None displays the full accumulated trajectory.
surface : bool, dict, or list of bool/dict, or None
If set, overlays a smooth, lit surface over each dataset's convex
hull (GH #109): a filled smooth outline for 2D data, or a shaded
3D "blob" (inflated, subdivided, and Taubin-smoothed hull -- see
`hypertools.plot.meshutil.smooth_hull_3d`) for 3D data. Pass
``True`` for the defaults below, a dict to override specific keys
(unset keys use their default), or a list of bool/dict (one per
*drawn* dataset, matching the final -- post `cluster`/`hue`
regrouping -- dataset count) for per-dataset control; a bare
``False``/``None`` entry in the list disables that dataset's
surface. Raises ``ValueError`` for 1D data (no hull concept), for
an unrecognized dict key or an out-of-range dict value (see the
per-key constraints below), or if a list's length does not match
the number of drawn datasets. A dataset with too few points to form a
hull (< 3 for 2D, < 4 for 3D) or whose points are exactly
collinear/coplanar has its surface silently skipped with a
``UserWarning`` (never a crash). Default None (no surfaces).
Accepted dict keys, with defaults:
- ``alpha`` (float, default 0.6): surface opacity; must be in
(0, 1]. A translucent (< 1.0) surface shows the enclosed data
points through the hull on BOTH backends. Note that a
translucent 3D matplotlib surface REQUIRES the built-in
backface culling (always applied) to avoid interior-face
"cracks" showing through; plotly renders a translucent surface
as a genuinely translucent ``Mesh3d`` (its doubled-winding mesh
gets per-layer opacity ``1 - sqrt(1 - alpha)``, compositing to
exactly ``alpha`` total), which keeps the full mesh but may show
per-triangle depth-sorting noise (a known WebGL/plotly
limitation -- plotly.py issue #3554 -- not a hypertools bug) --
prefer ``alpha=1.0`` if this is objectionable: at ``alpha >=
0.999`` the plotly mesh instead renders through an artifact-free
fully-opaque path (the alpha is baked into the surface color),
and data points enclosed by their own opaque surface are hidden
from that dataset's trace (they would be invisible behind it
anyway, and hiding them avoids a WebGL "punch-through" defect).
- ``color`` (color spec or None, default None): surface base
color. ``None`` inherits the dataset's own drawn line/marker
color (resolved from `color`/`colors` if given, else the
`palette` color cycle).
- ``lighting`` (dict, default ``{}``): overrides the two-light
Blinn-Phong lighting model BOTH backends use identically (see
`hypertools.plot.meshutil.blinn_phong_colors`/
`blinn_phong_vertex_colors`) -- matplotlib shades per-FACE;
plotly shades per-VERTEX (precomputed and handed to
``go.Mesh3d`` as ``vertexcolor``, with plotly's own lighting
engine forced to the identity so it reproduces those colors
verbatim -- needed so the double-sided winding workaround below
doesn't render dark self-shaded patches) -- so every key below
visibly affects both backends the same way. Accepted keys:
- ``ambient`` (float, default 0.45): flat, direction-independent
base brightness; higher values flatten/wash out shading
(matte look), 0 makes unlit faces fully black.
- ``diffuse`` (float, default 0.55): key-light (Lambertian)
contribution; scales how strongly faces facing the key light
brighten relative to those facing away.
- ``fill`` (float, default 0.25): weaker opposite-side fill-light
contribution, so faces angled away from the key light are not
rendered fully flat/black.
- ``specular`` (float, default 0.30): strength of the glossy
highlight; 0 gives a fully matte surface, higher values (e.g.
0.9) give a glossy/wet look.
- ``shininess`` (float, default 48): specular exponent -- higher
values (e.g. 128) tighten the highlight into a small glossy
spot; lower values spread it into a broad sheen.
- ``lightdir`` (3-vector ``(x, y, z)`` or None, default None):
explicit key-light direction in scene/data coordinates
(need not be normalized; must not be the zero vector). ``None``
(default) derives the key light automatically from the current
camera view (offset above and to the side), matching each
backend's own default camera-relative lighting.
plotly's light position (for its own, identity-forced lighting
engine, unrelated to the vertex-color computation above) is
fixed at ``(2.5, -1.5, 3.0)`` in scene coordinates. Ignored for
2D surfaces (flat fills have no lighting). Unrecognized keys
(e.g. the pre-GH-109-round-3 plotly-only ``roughness``/
``fresnel``, which no longer affect either backend's rendering)
raise ``ValueError`` rather than being silently accepted.
- ``smoothing`` (int, default 3): number of interleaved
[subdivide, Taubin-smooth] rounds for a 3D hull (face count
scales as ``4 ** smoothing``); must be in [0, 6] (beyond 6 the
face count -- 4096x the raw hull's at 6 -- is a memory/time
footgun with no visible smoothness gain); ignored for 2D.
- ``pre_inflate`` (float, default 1.0): scale factor applied to
the 3D hull about its centroid before smoothing (default: no
blanket inflation); must be a positive, finite number. Any
shrinkage smoothing introduces is instead recovered by a
minimal, grow-only post-hoc rescale targeting ~99% containment
of the actual input points, so the surface hugs the data rather
than ballooning past it. The rescale is mathematically bounded
(hard-capped at 3.0x growth): well-sampled clouds typically need
at most ~1.25x, and only tiny (4-5 point) hulls -- whose coarse
meshes lose proportionally far more of their bulge to smoothing
-- approach the cap (see
`hypertools.plot.meshutil.smooth_hull_3d`). Ignored for 2D.
- ``keep_points`` (bool, default True): if False, hides that
dataset's own line/marker (only the surface is shown). Note
that on plotly, points enclosed by their own FULLY-OPAQUE
(``alpha >= 0.999``) surface are hidden even when
``keep_points=True`` -- see the ``alpha`` entry above;
translucent surfaces always show their points.
Out-of-range values for any key above raise an eager
``ValueError`` (naming the key, the constraint, and the received
value) BEFORE the analyze/reduce pipeline runs, exactly like
`density`'s validation.
Animated plots (matplotlib and plotly, 3D only -- round17 #9, GH
#123: 2-D `animate` is now supported, but per-frame hull tracking
is not, so `surface=` is silently a no-op on an animated 2-D plot,
in both backends) recompute each dataset's hull every frame from
its CURRENTLY VISIBLE window:
the revealed portion for ``animate='serial'``, the sliding
head/tail window for ``animate=True``/``'parallel'`` (matching the
window drawn by `chemtrails`/`tail_duration`), or the full,
precomputed-once dataset for ``animate='spin'`` (only the camera
orbits, so only per-frame shading/backface-culling -- not the mesh
itself -- needs recomputing). Animated surfaces keep the same
per-vertex `hue` coloring static surfaces use (each frame's hull
is colored from its currently-visible points' own hue colors) on
both backends. Surfaces never gain a legend entry
(``label='_nolegend_'`` / ``showlegend=False``) in either backend.
density : bool, dict, or None
If set, overlays a subtle KDE (kernel density estimate) "glow"
behind the data (GH #108, #191): a 2-D alpha-ramped heatmap, or a
3-D volumetric cloud, showing where each dataset's points are
concentrated. Pass ``True`` for the defaults below, or a dict to
override specific keys (unset keys use their default). Unlike
`surface`, `density` has no per-dataset list form and no `color`
override -- every density layer always inherits its dataset's own
drawn color (or, with ``per_group=False``, a single neutral-gray
layer is drawn for the pooled data). Raises ``ValueError`` for 1D
data (no 2-D/3-D density concept) or an unrecognized dict key. A
dataset with too few points (< 3) or degenerate (singular
covariance -- e.g. exactly duplicated/collinear/coplanar points)
has its density silently skipped with a ``UserWarning`` (never a
crash). Default None (no density shading).
Accepted dict keys, with defaults:
- ``alpha`` (float, default 0.2): base opacity, kept subtle by
design so the density layer never dominates the actual data.
matplotlib's 2-D layer ramps linearly from fully transparent up
to exactly this alpha at the KDE's peak; matplotlib's 3-D
iso-surface/fog alphas and both plotly layers' opacities scale
proportionally with it (see the backend-specific notes below).
- ``levels`` (int, default 3): number of nested 3-D iso-surface
shells. Wired into BOTH 3-D backends: matplotlib draws one
`Poly3DCollection` per level, at density-fraction thresholds
spaced evenly across ``[0.10, 0.65]`` via `numpy.linspace`
(`levels=3`, the default, reproduces the original hand-tuned
thresholds -- 10%/35%/65% of peak density, alphas 0.03/0.05/0.07
-- EXACTLY, since evenly-spaced ``linspace(0.10, 0.65, 3)`` would
instead give a 37.5%-not-35% middle shell); plotly's
``go.Volume`` layer uses ``surface_count=5*levels`` (15 at the
default). **2-D density has no ``levels`` concept at all** --
the 2-D layer is a single continuous alpha/heatmap ramp with no
discrete shells, so ``levels`` is silently ignored for 2-D data
(no error; the key is still valid, it's just a no-op there).
- ``grid`` (int, default None): KDE evaluation grid resolution per
axis. ``None`` auto-resolves to 200 for 2-D data or 50 for 3-D
data (a 3-D grid is `grid**3` KDE evaluations, so much coarser by
default).
- ``per_group`` (bool, default True): fit and draw one density
layer per drawn dataset. ``False`` pools every dataset's points
into a single combined KDE, drawn as one neutral-gray layer
instead.
Backend rendering: matplotlib's 2-D layer is an alpha-ramped
``imshow`` (a `LinearSegmentedColormap` from transparent to the
dataset's color at `alpha`, bilinear-interpolated, drawn below the
data) -- not `contourf`, whose hard per-level boundaries read as
banding rather than a smooth glow. matplotlib's 3-D layer is nested
translucent iso-surfaces via `skimage.measure.marching_cubes`
(`levels` shells spanning 10%-65% of peak density, alphas ramping
0.03-0.07, both scaled by `alpha / 0.2`; see the `levels` entry
above for the exact spacing) when scikit-image is installed
(``pip install hypertools[density3d]``); otherwise it falls back to
a translucent scatter "fog" (4000 points resampled from the KDE,
`alpha` 0.03 scaled the same way) and emits a `UserWarning`
suggesting the extra or ``backend='plotly'`` (which always renders
a full volumetric `go.Volume`, no extra required). plotly's 2-D
layer is a `go.Contour` heatmap (`coloring='heatmap'`, no
contour lines, an alpha-ramped colorscale to `1.5 * alpha` --
note this peak alpha is deliberately 1.5x the mpl 2-D layer's
`alpha`, a documented cross-backend visibility difference, not a
bug: plotly's heatmap reads fainter than mpl's `imshow` at the same
alpha value, so the ramp is boosted to compensate); its
3-D layer is a `go.Volume` with, for a scene-filling dataset
(boost=1), `isomin=0.05`, `isomax=1.0`, `surface_count=5*levels`,
`opacity=min(2 * alpha, 0.4)`, and an `opacityscale` ramp tuned so
the volume stays visible at plotly's 3-D scene scale, over a solid
per-dataset colorscale. For a dataset SMALL relative to the scene
(e.g. widely-separated clusters), the auto-boost shifts all of
these together -- `opacity` and `surface_count` scale up (opacity
capped at 0.75), `isomin` drops (down to 0.01), and the
`opacityscale` breakpoints and the KDE grid's padding widen to
expose more of the KDE's outer tail -- see
`hypertools.plot.density.resolve_plotly_volume_params` for the
exact formulas. Density layers never gain a legend entry in
either backend.
3-D static-export caveat (both backends): when `per_group=True`
(the default) draws more than one dataset's translucent 3-D density
layer, the overlapping surfaces/volumes can composite unevenly in
STATIC exports (PNG/SVG via matplotlib's Agg renderer or plotly's
`kaleido`-based ``write_image``/``to_image``) -- a WebGL/rasterizer
alpha-blending-order limitation, not a data or fitting bug. The
interactive view (a live matplotlib window or plotly's browser/
notebook widget) renders correctly; only static snapshots of
multi-dataset 3-D density can look off.
Animated plots (both backends, any `animate` style): the density
is computed ONCE from the FULL dataset and drawn as a static
background -- a single KDE evaluation is far too slow
(~500ms at a 50^3 grid) to redo every animation frame, so, unlike
`surface`, the density layer does not track the currently-visible
window and is never touched by per-frame updates.
return_model : bool
If True, return a dict bundle
``{'fig': ..., 'xform_data': ..., 'animation': ..., 'pipeline': ...,
'models': ..., 'predict': ...}`` instead of the bare figure, where
``xform_data`` is the normalized/reduced/aligned data, ``animation``
is the ``matplotlib.animation.Animation`` handle (``None`` unless
``animate=True`` with the matplotlib backend), ``pipeline`` is a
fitted `hypertools.Pipeline` covering whichever of `manip=`/
`normalize=`/`reduce=`/`align=`/`cluster=` ran (the SAME `pipeline=`
object passed in, if any; `None` when `transform=` was used, since
then there is no raw data to have fit one on) -- pass it back in as
`hyp.plot(new_data, pipeline=bundle['pipeline'])` to reuse these
exact fitted parameters (GH #227), ``models`` holds the
reduce/align/cluster/impute specs, and ``predict`` is ``None`` unless
`predict` was set, in which case it is
``{'model': ..., 'params': {'t': t}, 'forecasts': [...]}`` (one
forecast array per input dataset, in the analyzed/plotted --
pre-center/scale -- space). Each bundled forecast has exactly `t`
rows, matching what ``hyp.predict(xform_data, model=..., t=t)``
returns; the DRAWN dashed overlay additionally prepends the last
observed row as a connector, so the drawn trace has `t + 1`
vertices. Default False.
Returns
-------
fig : matplotlib.figure.Figure or plotly Figure
The rendered figure. Static plot coordinates are drawn in the
centered/rescaled ``[-1, 1]`` display space described under `x`
above. For animated matplotlib plots a ``HyperAnimation`` is
returned instead: a ``(fig, animation)`` tuple subclass (so
``fig, anim = hyp.plot(...)`` unpacking works) that also exposes
``.figure``/``.to_html5_video()``/``.to_jshtml()``/``.save()`` and
auto-plays inline in notebooks -- keep a reference to it so the
underlying ``matplotlib.animation.FuncAnimation`` stays alive.
When ``return_model=True``, a dict
``{'fig': ..., 'xform_data': ..., 'animation': ..., 'pipeline': ...,
'models': ..., 'predict': ...}`` is returned (``animation`` included
so the handle isn't dropped for animated plots; ``pipeline`` is the
fitted `hypertools.Pipeline` covering the stages that ran, reusable
via ``hyp.plot(new_data, pipeline=...)``).
Examples
--------
Plot a single high-dimensional dataset as a static 3-D trajectory (the
data is reduced to 3 dimensions with the default reducer):
>>> import numpy as np
>>> import hypertools as hyp
>>> x = np.cumsum(np.random.default_rng(0).standard_normal((50, 8)),
... axis=0)
>>> fig = hyp.plot(x, show=False)
>>> fig.axes[0].name
'3d'
Plot two datasets as labeled point clouds (one legend entry each):
>>> fig = hyp.plot([x, x + 10], '.', names=['a', 'b'], show=False)
>>> [t.get_text() for t in fig.axes[0].get_legend().get_texts()]
['a', 'b']
Color a trajectory continuously by time, in a 2-D projection:
>>> fig = hyp.plot(x, ndims=2, hue=np.arange(50), show=False)
>>> fig.axes[0].name
'rectilinear'
"""
# early kwarg validation (release-1.0 audit): catch renamed/misspelled/
# unknown keyword arguments HERE, with a clear TypeError naming the
# kwarg (plus a did-you-mean hint), BEFORE the expensive analyze/
# reduce/align pipeline runs.
_validate_extra_plot_kwargs(kwargs)
# fmt: accept plain-bytes format strings like np.bytes_ (F01-017) --
# decoded here once so every downstream fmt consumer sees str.
if isinstance(fmt, bytes):
fmt = fmt.decode("utf-8")
# a fmt TUPLE is normalized to a list up front so every downstream
# consumer (which tests `isinstance(fmt, list)`) handles it identically
# -- previously a tuple passed the list/tuple validation below but then
# fell through the list-only branches and surfaced an unrelated internal
# error (e.g. "'<=' not supported between 'int' and 'str'") whose
# occurrence depended on the hue pattern (reviewer follow-up).
if isinstance(fmt, tuple):
fmt = list(fmt)
if isinstance(fmt, list):
fmt = [f.decode("utf-8") if isinstance(f, bytes) else f for f in fmt]
# fmt must be a format string (or a per-dataset list of them):
# fmt=123 used to run the whole pipeline and die in a bare
# "object of type 'int' has no len()" that never named the kwarg
# (release-1.0 audit, X2-error-quality-015).
if fmt is not None and not isinstance(fmt, str):
if not (isinstance(fmt, (list, tuple))
and all(isinstance(f, str) for f in fmt)):
raise TypeError(
f"fmt must be a matplotlib format string (e.g. '-', '.', "
f"'o:') or a list of format strings (one per dataset); "
f"got {fmt!r}.")
# transform= is pre-transformed DATA (bypassing the analysis pipeline),
# not a model spec: a bad value used to crash later with "'str' object
# has no attribute 'shape'" (release-1.0 audit, X2-error-quality-015).
if transform is not None:
_xf_items = transform if isinstance(transform, (list, tuple)) \
else [transform]
for _xf in _xf_items:
if not (hasattr(_xf, 'shape') or hasattr(_xf, '__array__')):
raise TypeError(
f"transform= must be already-transformed data (a numpy "
f"array/DataFrame, or a list of them), or None; got "
f"{type(_xf).__name__}: {_xf!r}. To choose a "
"dimensionality-reduction model, pass reduce= instead "
"(transform= bypasses the analysis pipeline entirely).")
# elev=/azim= must be numbers (degrees). Previously a bad value ran the
# whole pipeline and only crashed at DRAW time with a message that never
# named the kwarg (F10-014).
for _angle_name, _angle_value in (("elev", elev), ("azim", azim)):
if isinstance(_angle_value, bool) or not isinstance(
_angle_value, (int, float, np.integer, np.floating)):
raise TypeError(
f"{_angle_name}= must be a number (the camera "
f"{'elevation' if _angle_name == 'elev' else 'azimuth'} in "
f"degrees); got {_angle_value!r}.")
# size= must be a [width, height] pair of numbers; the raw matplotlib
# unpack error never mentioned size= (F10-012).
if size is not None:
_size_ok = (not isinstance(size, (str, bytes))
and hasattr(size, "__len__") and len(size) == 2
and all(isinstance(v, (int, float, np.integer,
np.floating))
and not isinstance(v, bool) for v in size))
if not _size_ok:
raise ValueError(
"size= must be a [width, height] pair of numbers (the "
f"figure size in inches); got {size!r}.")
# ax= must be a matplotlib Axes; a bad value crashed deep inside the
# backend with "'str' object has no attribute 'name'" (F10-015).
if ax is not None:
import matplotlib.axes as _mpl_axes
if not isinstance(ax, _mpl_axes.Axes):
raise TypeError(
"ax= must be a matplotlib Axes (2-D) or Axes3D (3-D) "
f"instance; got {type(ax).__name__!r}.")
# a bare scalar is plotted as a single 1-D point -- warn rather than
# doing so silently (D11-014).
if isinstance(x, (int, float, np.integer, np.floating)) \
and not isinstance(x, bool):
warnings.warn(
"x is a single scalar value; hypertools will plot it as a "
"single 1-D point. Pass an array/list of observations for a "
"meaningful plot.", stacklevel=external_stacklevel())
# align=False / cluster=False are documented as "no alignment" / "no
# clustering" (same as None); normalize them here so the stage
# dispatchers below never see a bare False (F03-003/F03-004).
if align is False:
align = None
if cluster is False:
cluster = None
# a bare string for names=/legend= is ONE name, not a sequence of
# single-character names (F10-009: names='ab' silently became
# ['a', 'b']); wrap it so the per-dataset length checks below apply.
if isinstance(names, str):
names = [names]
if isinstance(legend, str):
legend = [legend]
# legend= must be a bool, a label string, or a list of labels: any
# other scalar (e.g. legend=7) was silently treated as truthy
# (release-1.0 audit, X2-error-quality-016).
if legend is not None and not isinstance(
legend, (bool, np.bool_, list, tuple, np.ndarray, pd.Series,
pd.Index)):
raise TypeError(
f"legend= must be True/False, a label string, or a list of "
f"labels (one per drawn trace/group); got "
f"{type(legend).__name__}: {legend!r}.")
# animate= dict form (GH #154 resolution): unpacked into the flat
# animation kwargs HERE, at the very top of the function, before
# anything else runs -- so every downstream code path (all of which
# predates this feature) only ever sees the flat kwargs it already
# understands; `animate` itself becomes the resolved style string/
# bool/list from here on. `'style'` plays the role of `model` in
# hypertools' usual spec-dict grammar; every other key must be one of
# the flat animation kwargs below. A dict key CONFLICTING with the
# same flat kwarg passed explicitly (a different value) is almost
# certainly a mistake -- raise rather than silently pick one; compared
# against each flat kwarg's own LITERAL default (mirroring the
# pipeline=/stage-kwarg conflict check below) since there is no other
# way to tell "explicitly passed, coincidentally equal to the default"
# from "never passed" from inside the function body.
if isinstance(animate, dict):
_ANIMATE_DICT_DEFAULTS = {
'duration': 30,
'tail_duration': 2,
'rotations': 1,
'zoom': 1,
'chemtrails': False,
'precog': False,
'bullettime': False,
'frame_rate': 30,
'focused': None,
'morph_samples': None,
}
if 'style' not in animate:
raise ValueError(
"animate= dict form requires a 'style' key naming the "
"animation style (e.g. animate={'style': 'spin', "
"'rotations': 2, 'duration': 15}); got a dict with keys "
f"{sorted(animate.keys())}."
)
_animate_dict = dict(animate)
_animate_style = _animate_dict.pop('style')
_unknown_animate_keys = set(_animate_dict) - set(_ANIMATE_DICT_DEFAULTS)
if _unknown_animate_keys:
raise ValueError(
f"animate= dict got unknown key(s) "
f"{sorted(_unknown_animate_keys)}; valid keys are 'style' "
f"plus {sorted(_ANIMATE_DICT_DEFAULTS)}."
)
_animate_flat_locals = {
'duration': duration,
'tail_duration': tail_duration,
'rotations': rotations,
'zoom': zoom,
'chemtrails': chemtrails,
'precog': precog,
'bullettime': bullettime,
'frame_rate': frame_rate,
'focused': focused,
'morph_samples': morph_samples,
}
for _key, _dict_value in _animate_dict.items():
_default_value = _ANIMATE_DICT_DEFAULTS[_key]
_flat_value = _animate_flat_locals[_key]
if _flat_value != _default_value and _flat_value != _dict_value:
raise ValueError(
f"animate= dict specifies {_key}={_dict_value!r} but "
f"{_key}={_flat_value!r} was also passed explicitly as "
f"a flat kwarg with a different value; pass {_key}= in "
"only one place (either inside animate= or as its own "
"kwarg)."
)
duration = _animate_dict.get('duration', duration)
tail_duration = _animate_dict.get('tail_duration', tail_duration)
rotations = _animate_dict.get('rotations', rotations)
zoom = _animate_dict.get('zoom', zoom)
chemtrails = _animate_dict.get('chemtrails', chemtrails)
precog = _animate_dict.get('precog', precog)
bullettime = _animate_dict.get('bullettime', bullettime)
frame_rate = _animate_dict.get('frame_rate', frame_rate)
focused = _animate_dict.get('focused', focused)
morph_samples = _animate_dict.get('morph_samples', morph_samples)
animate = _animate_style
# animate='chemtrails'/'precog'/'bullettime' sugar (QC 2026-07): these are
# trail EFFECTS, not animation styles. Historically, passing one as the
# animate STYLE silently produced a STATIC plot (the style whitelist in the
# matplotlib backend did not recognize them). Map each to animate='parallel'
# with the matching trail flag on -- what the effect actually needs (trails
# apply to animate=True/'parallel').
if isinstance(animate, str) and animate in ('chemtrails', 'precog',
'bullettime'):
if animate == 'chemtrails':
chemtrails = True
elif animate == 'precog':
precog = True
else:
bullettime = True
animate = 'parallel'
# validate the animate style: an unrecognized string used to fall through
# to a silent static plot (QC 2026-07). Fail fast with a clear message.
if isinstance(animate, str) and animate not in ('parallel', 'spin',
'serial', 'morph', 'window'):
raise ValueError(
f"unknown animate style {animate!r}; valid styles are 'parallel', "
"'spin', 'serial', 'morph', 'window' (or True/False). The trail "
"effects 'chemtrails'/'precog'/'bullettime' are boolean kwargs, not "
"styles -- e.g. animate='parallel', chemtrails=True.")
# non-bool/non-string scalars (release-1.0 audit, F04-006/F05-004):
# anything ==True/==False (np.True_, 1, 0, ...) is normalized to a real
# bool; every OTHER scalar (e.g. animate=2 -- perhaps meant as
# "2 rotations"?) used to slip past the string whitelist above, silently
# render a STATIC plot (the backend dispatch is `animate in [True,
# 'parallel', ...]`), and then crash with `AttributeError: 'NoneType'
# object has no attribute 'save'` if save_path= was also set.
if isinstance(animate, np.ndarray):
animate = animate.tolist() # per-dataset morph tags as an array
if animate is not None and not isinstance(animate, (bool, str, dict,
list, tuple)):
if animate == True or animate == False: # noqa: E712 (np.bool_/0/1)
animate = bool(animate)
else:
raise ValueError(
f"animate={animate!r} is not a recognized animate value; "
"use True/False, a style string ('parallel', 'spin', "
"'serial', 'morph', 'window', or the sugar styles "
"'chemtrails'/'precog'/'bullettime'), the dict form "
"(animate={'style': ..., ...}), or a per-dataset list for "
"animate='morph'. For extra camera rotations, pass "
"rotations= instead.")
# animations need a positive duration and frame rate (QC 2026-07: duration=0
# or frame_rate=0 raised ZeroDivisionError, and a negative duration a cryptic
# "zero-size array to reduction" error, from the frame-count math; release-1.0
# audit F04-007: duration=None slipped through to a bare TypeError in the
# frame-count multiplication, and F05-009: a negative tail_duration silently
# suppressed the opaque head for the whole animation).
if animate:
if (duration is None or isinstance(duration, bool)
or not isinstance(duration, (int, float, np.integer,
np.floating))
or duration <= 0):
raise ValueError(
f"duration must be a positive number of seconds for an "
f"animation; got {duration!r}.")
if (frame_rate is None or isinstance(frame_rate, bool)
or not isinstance(frame_rate, (int, float, np.integer,
np.floating))
or frame_rate <= 0):
raise ValueError(
f"frame_rate must be a positive number; got {frame_rate!r}.")
if not isinstance(tail_duration, (list, tuple)):
if (tail_duration is None or isinstance(tail_duration, bool)
or not isinstance(tail_duration, (int, float, np.integer,
np.floating))
or tail_duration < 0):
raise ValueError(
f"tail_duration must be a non-negative number of "
f"seconds (the trail/head-window length); got "
f"{tail_duration!r}.")
# morph_samples=-5 used to leak numpy's internal 'negative
# dimensions are not allowed' from the downsampling RNG without
# ever naming the kwarg (release-1.0 audit, D03-gallery-basics-007)
_ms_ok = (morph_samples is None
or (not isinstance(morph_samples, bool)
and isinstance(morph_samples, (int, float, np.integer,
np.floating))
and float(morph_samples) >= 1
and float(morph_samples).is_integer()))
if not _ms_ok:
raise ValueError(
f"morph_samples must be a positive integer (the "
f"per-dataset point cap for animate='morph') or None; got "
f"{morph_samples!r}.")
# rotations='two' used to be accepted silently and only crash at
# SAVE time, deep inside matplotlib ("IndexError: list index out
# of range") with no mention of the kwarg; zoom=-1 was silently
# accepted despite the documented positive-zooms-in contract
# (release-1.0 audit, X2-error-quality-014). Validate both eagerly,
# next to the duration/frame_rate checks above.
if isinstance(rotations, (list, tuple)):
# per-segment morph pacing weights: each entry must be a
# non-negative number (the list-only-with-morph check below
# handles WHICH modes allow a list)
if not all(isinstance(r, (int, float, np.integer, np.floating))
and not isinstance(r, bool) and r >= 0
for r in rotations):
raise ValueError(
f"rotations, when given as a per-segment list (for "
f"animate='morph'), must contain only non-negative "
f"numbers; got {rotations!r}.")
elif (rotations is None or isinstance(rotations, bool)
or not isinstance(rotations, (int, float, np.integer,
np.floating))):
raise ValueError(
f"rotations must be a number (of full camera rotations "
f"over the animation; rotations=0 fixes the camera), or a "
f"per-segment list with animate='morph'; got "
f"{rotations!r}.")
if (zoom is None or isinstance(zoom, bool)
or not isinstance(zoom, (int, float, np.integer,
np.floating))
or zoom <= 0):
raise ValueError(
f"zoom must be a positive number (the camera zoom factor; "
f"larger values zoom in, default 1); got {zoom!r}.")
# save_path misuse fail-fast (F09-004/F09-007): normalize path-likes to
# str (animated matplotlib and plotly writers do string operations on
# it), expand ~, and reject non-paths/empty strings/missing directories
# BEFORE the expensive pipeline runs or any figure exists.
if save_path is not None:
save_path = _normalize_save_path(save_path)
# focused= resolution (GH #275 round17 #8): the length, in SECONDS (the
# same unit as `tail_duration`), of the opaque "in-focus" window for
# `animate='window'` and for any dataset with a `chemtrails`/`precog`/
# `bullettime` trail. `None` (default) resolves to `tail_duration`'s own
# value -- today's hardcoded/tail_duration-derived focus length -- so
# omitting `focused=` never changes existing behavior. Silently ignored
# (no error) for `animate='spin'`/`'parallel'` (with no trail flags set)/
# `'morph'`, where the concept of a separate "in-focus" window distinct
# from `tail_duration` doesn't apply -- see `matplotlib_backend
# .animate_plot3D`/`plotly_backend._add_animation` for exactly where
# `focused` vs. `tail_duration` is used.
if focused is not None:
if (isinstance(focused, bool)
or not isinstance(focused, (int, float))
or focused < 0):
raise ValueError(
f"focused= must be a non-negative number, or None (default: "
f"tail_duration's value, {tail_duration!r}); got {focused!r}."
)
resolved_focused = focused
else:
resolved_focused = tail_duration
# predict= + animate: forecast overlays are static-plot only in v1
# (animating a growing/appended forecast trace is follow-up work).
if predict is not None and animate:
raise NotImplementedError(
"predict= is not yet supported with animate: forecast traces "
"are static-plot only in this release. Pass animate=False (the "
"default) to use predict=, or omit predict= for an animated plot."
)
# rotations= as a per-SEGMENT list is only meaningful for
# animate='morph' (every other mode has exactly one continuous camera
# sweep, with no segment boundaries to assign rotations to). Whether
# `animate` is IN morph mode at all is fully determined by the RAW
# `animate` argument -- a scalar `'morph'`, or ANY list/tuple (which
# `_resolve_animate_mode` below only ever uses to per-dataset-tag a
# morph sequence) -- never by how many datasets end up being plotted,
# so this mismatch is checked here, fail-fast, before the (expensive)
# analyze/reduce/align/cluster/hue pipeline runs, mirroring the
# colorbar=/surface=/density= early validation just below. The
# COMPLEMENTARY checks that DO depend on the FINAL (post cluster/hue-
# reshape) dataset count -- rotations' exact ``2N - 1`` length and the
# "at least 2 morph-tagged datasets" minimum -- cannot be resolved yet
# here and are still checked later, once `xform` (and so the final
# dataset count) is known; see `_resolve_animate_mode`/
# `resolve_morph_rotations` below.
if isinstance(rotations, (list, tuple)) and not (
animate == "morph" or isinstance(animate, (list, tuple))
):
raise ValueError(
"rotations as a list is only supported with animate='morph' "
f"(got animate={animate!r}); pass a scalar rotations= for "
"this animate mode."
)
# colorbar= kwarg validation (GH #100): fail fast with a clear message
# before any of the (expensive) analyze/reduce/align pipeline runs.
_VALID_COLORBAR_LOCATIONS = ('right', 'left', 'top', 'bottom')
_VALID_COLORBAR_KEYS = {'label', 'ticks', 'location'}
if colorbar is not None and colorbar is not False:
if colorbar is True:
colorbar = {}
elif isinstance(colorbar, dict):
unknown = set(colorbar) - _VALID_COLORBAR_KEYS
if unknown:
raise ValueError(
f"colorbar dict got unknown key(s) {sorted(unknown)}; "
f"valid keys are {sorted(_VALID_COLORBAR_KEYS)}."
)
loc = colorbar.get('location', 'right')
if loc not in _VALID_COLORBAR_LOCATIONS:
raise ValueError(
f"colorbar['location'] must be one of "
f"{_VALID_COLORBAR_LOCATIONS}; got {loc!r}."
)
else:
raise ValueError(
"colorbar must be True, False, None, or a dict with keys "
f"a subset of {sorted(_VALID_COLORBAR_KEYS)}; got "
f"{colorbar!r}."
)
else:
colorbar = None
# font= resolution (GH #205): resolved ONCE, here, from every piece of
# text hypertools might draw that is knowable before the (expensive)
# analyze/reduce/align pipeline runs -- labels=, a literal legend=
# list, title=, colorbar['label']/['ticks'] if given, and `hue=` (when
# `legend=True`, matplotlib's auto-legend draws one entry per unique
# CATEGORICAL hue value, so non-ASCII hue values need to be scanned
# here too -- their exact codepoints match what ends up in the legend
# even though the deduplicated/sorted unique-value list itself isn't
# known until the hue-grouping pipeline runs below). A continuous
# (all-numeric) `hue` contributes no strings and is silently skipped
# by the text-flattening helper. Resolving once up front means every
# text surface `_draw`/`_add_colorbar` touches later shares the exact
# same FontProperties, rather than each independently re-scanning
# installed fonts.
_font_texts = [labels, legend, title, hue]
if colorbar is not None:
_font_texts.append(colorbar.get('label'))
_font_texts.append(colorbar.get('ticks'))
resolved_font = resolve_font(font, _font_texts)
# Font applied DIRECTLY to individual text artists (title/labels/legend/
# colorbar). Only an EXPLICIT font= is applied that way -- it is the
# caller's stated choice for every surface. An AUTO-detected font
# (`resolved_font` set while `font is None`) merely fills a coverage GAP
# and is added to the fallback STACK instead (see the rc_context below),
# so the primary face stays the bundled Noto Sans and per-glyph fallback
# supplies only the characters the stack lacks (maintainer font review).
_artist_font = resolved_font if font is not None else None
# The plotly backend has no rcParams stack, so an AUTO-detected gap family
# must be handed to it EXPLICITLY (as a family name appended near the end
# of its CSS stack) -- otherwise a character matplotlib renders via the
# discovered font would silently show as tofu on plotly (maintainer font
# review). `None` for the explicit-font and no-gap cases.
_plotly_font_extra = (resolved_font.get_name()
if (resolved_font is not None and font is None)
else None)
# label_alpha= resolution (GH #103): resolved ONCE, here, exactly like
# font= above -- `None` (default) keeps the historical hardcoded 0.5
# opacity on both backends; any other value must be a real alpha
# (a number in [0, 1]), validated fail-fast before the expensive
# analyze/reduce/align pipeline runs.
if label_alpha is None:
resolved_label_alpha = 0.5
elif (isinstance(label_alpha, bool)
or not isinstance(label_alpha, (int, float))
or not (0 <= label_alpha <= 1)):
raise ValueError(
f"label_alpha= must be a number in [0, 1], or None (default: "
f"0.5); got {label_alpha!r}."
)
else:
resolved_label_alpha = label_alpha
# surface= kwarg validation (GH #109): fail fast (unknown dict keys)
# before the expensive analyze/reduce pipeline runs. `_surface_norm` is
# either None (disabled), a single validated dict (broadcast to every
# dataset once the final dataset count is known), or a list of
# dict-or-None (length-checked against that same final count below).
_surface_norm = normalize_surface_arg(surface)
# density= kwarg validation (GH #108/#191): fail fast (unknown dict
# keys) before the expensive analyze/reduce pipeline runs, mirroring
# `surface=`'s validation above. `_density_norm` is either None
# (disabled) or a single validated dict, broadcast to every dataset (or
# pooled into one layer, per its `per_group` key) once the final
# dataset count is known.
_density_norm = normalize_density_arg(density)
# resample= kwarg validation (GH #94): fail fast before the expensive
# analyze/reduce pipeline runs, mirroring colorbar=/surface=/density=
# above. A valid `resample` is False/None (disabled, default) or an
# int >= 2 (PCHIP interpolation -- what `hypertools.manip.Resample`
# uses under the hood, same as the interpolation this module already
# does for line smoothing -- needs at least 2 points to fit a curve).
if resample is not None and resample is not False:
if (isinstance(resample, bool)
or not isinstance(resample, (int, np.integer))
or resample < 2):
raise ValueError(
"resample= must be an integer >= 2 (the target sample "
"count per dataset) or False/None to disable resampling; "
f"got {resample!r}."
)
# pipeline= kwarg validation (GH #227): mutually exclusive with the
# stage kwargs it replaces -- fail fast before the expensive analyze/
# reduce pipeline runs, mirroring resample=/colorbar=/surface=/
# density= above. Compared against plot()'s own LITERAL defaults
# (reduce="IncrementalPCA", ndims=3) rather than `is not None` (unlike
# hyp.analyze's own pipeline= check, whose stage kwargs all default to
# None) since plot() always has a reduce=/ndims= value.
if pipeline is not None:
_conflicting = [name for name, default_value, value in (
('manip', None, manip),
('normalize', None, normalize),
('reduce', 'IncrementalPCA', reduce),
('ndims', 3, ndims),
('align', None, align),
('cluster', None, cluster),
) if value != default_value]
if _conflicting:
raise ValueError(
"pipeline= is mutually exclusive with the stage kwarg(s) "
f"{', '.join(_conflicting)} (a fitted Pipeline already "
"encodes which stages run and their fitted parameters); "
"pass pipeline= alone (resample= is still applied first, "
"as sugar -- see pipeline='s docstring)."
)
# A whole already-fitted Pipeline belongs in pipeline=, not a single stage
# kwarg: passing one as reduce=/manip=/etc would apply it as that ONE stage
# and then plot re-applies the reduce stage to enforce ndims, double-applying
# it (QC 2026-07: this produced a cryptic "X has N features, but PCA is
# expecting M features" error). Point the user at the dedicated reuse path.
from ..core.pipeline import Pipeline as _HypPipeline
for _stage_name, _stage_value in (('manip', manip), ('normalize', normalize),
('reduce', reduce), ('align', align),
('cluster', cluster)):
if isinstance(_stage_value, _HypPipeline) and _stage_value.is_fitted:
raise ValueError(
f"{_stage_name}= received an already-fitted hypertools Pipeline. "
"A whole fitted Pipeline encodes all of its own stages -- reuse "
"it via hyp.plot(x, pipeline=<that Pipeline>), not as a single "
"stage kwarg.")
# streaming inputs (issue #101): iterators/generators and Hugging Face
# IterableDatasets are detected from the structure of the input -- no
# flag needed. Models are fitted on the first `stream_init` samples and
# every subsequent sample is projected through the fitted models and
# added to the plot dynamically (fetched in chunks of `stream_chunk`),
# continuing until the stream ends, `stream_max` samples have been
# consumed, or the user interrupts.
from ..io.streaming import is_stream, plot_stream
if is_stream(x):
# only the parameters forwarded below have a streaming
# implementation. Any OTHER parameter the caller explicitly set is
# named in a UserWarning instead of being silently dropped
# (F22-004) -- detected by comparing each plot() parameter's
# current value against its signature default (`cluster=False`
# etc. were already normalized to their None defaults above, so
# documented no-op spellings do not warn).
_stream_forwarded = {
'x', 'fmt', 'stream_init', 'stream_chunk', 'stream_max',
'stream_window', 'ndims', 'reduce', 'normalize', 'align',
'cluster', 'n_clusters', 'save_path', 'show', 'frame_rate',
'markersize', 'linewidth', 'color', 'palette', 'title',
'size', 'elev', 'azim', 'ax'}
_local_vals = locals()
_stream_dropped = []
for _pname, _param in inspect.signature(plot).parameters.items():
if (_pname in _stream_forwarded
or _param.kind is inspect.Parameter.VAR_KEYWORD):
continue
_val = _local_vals.get(_pname, _param.default)
try:
_diff = not (_val is _param.default
or _val == _param.default)
except Exception:
_diff = True
if _diff:
_stream_dropped.append(_pname)
_stream_dropped.extend(kwargs)
if _stream_dropped:
warnings.warn(
"streaming input: the following plot() parameter(s) have "
"no streaming implementation and will be ignored: "
f"{', '.join(sorted(_stream_dropped))}. Parameters "
"honored for streams: fmt, stream_init, stream_chunk, "
"stream_max, stream_window, ndims, reduce, normalize, "
"save_path, show, frame_rate, markersize, linewidth, "
"color, palette, title, size, elev, azim, ax (see the "
"stream_init docstring).", UserWarning, stacklevel=external_stacklevel())
return plot_stream(
x, fmt, stream_init=stream_init, stream_chunk=stream_chunk,
stream_max=stream_max, stream_window=stream_window,
ndims=ndims, reduce=reduce,
normalize=normalize, align=align, cluster=cluster,
n_clusters=n_clusters, save_path=save_path, show=show,
frame_rate=frame_rate, markersize=markersize,
linewidth=linewidth, color=color, palette=palette, title=title,
size=size, elev=elev, azim=azim, ax=ax)
# remember whether the USER supplied an axis before `_draw` reassigns the
# local `ax` to the axis it created (used by the GH #148 close below).
_user_supplied_ax = ax is not None
if ax is not None:
if ndims > 2:
if ax.name != "3d":
raise ValueError(
"If passing ax and the plot is 3D, ax must " "also be 3d"
)
text_args = {"vectorizer": vectorizer, "semantic": semantic, "corpus": corpus}
# a plain python "matrix" -- a list of equal-length rows of numbers,
# e.g. [[1., 2.], [3., 4.]] -- is ONE dataset (exactly like the
# equivalent np.array), NOT a nested list of scalar "datasets"
# (F01-004/F08-001: the flattening below used to treat every NUMBER as
# a leaf, then crash with a nonsensical error about a color= kwarg the
# caller never passed). Ragged all-numeric rows are left as-is:
# format_data treats each numeric list as its own (column-vector)
# dataset.
if _is_numeric_matrix(x):
x = np.asarray(x, dtype=float)
# nested lists (e.g. [[a, b], [c]]) are flattened into a flat list of
# datasets while recording each leaf's outermost-group index and nesting
# depth; these drive multilevel styling below (color by outer group,
# thinner/fainter lines per deeper level)
nested_groups = nested_depths = None
if isinstance(x, list) and any(isinstance(el, list) for el in x) \
and not all(isinstance(el, str) for el in x) \
and not all(isinstance(el, (list, tuple)) and len(el) > 0
and all(isinstance(v, (int, float, np.number))
and not isinstance(v, bool) for v in el)
for el in x):
x, nested_groups, nested_depths = _flatten_nested(x)
# MultiIndex DataFrames (GH #95): a DataFrame with a row MultiIndex
# (nlevels >= 2) is expanded HERE, before format_data/analyze/reduce, into
# one "leaf" dataset per unique full index combination -- so the leaves
# flow through the normal pipeline (normalize/reduce/align, streaming,
# interpolation, animation) exactly like any other list of datasets.
# After that pipeline transforms them (see the `_multiindex_meta is not
# None` branch below, alongside cluster/hue), per-level MEAN trajectories
# are computed in the TRANSFORMED space and appended, with per-dataset
# color/linewidth/alpha/linestyle/label overrides (see
# `hypertools.plot.multiindex` for the exact formulas). `cluster`/
# `n_clusters` fight the MultiIndex color assignment (both try to own
# the grouping-to-color mapping) and raise; `hue` is superseded with a
# warning (MultiIndex grouping takes precedence).
#
# This expansion ONLY happens for a BARE single MultiIndex DataFrame.
# A list containing MultiIndex DataFrame(s) (whether alone or mixed with
# arrays/other DataFrames) does NOT trigger expansion -- each such
# DataFrame is instead treated as a flat/plain dataset by the normal
# list-of-datasets pipeline, silently dropping the MultiIndex grouping
# unless we warn here.
if isinstance(x, list):
for _i, _el in enumerate(x):
if isinstance(_el, pd.DataFrame) and _el.index.nlevels >= 2:
warnings.warn(
"MultiIndex grouping is only applied when a single "
"DataFrame is passed; the MultiIndex on dataset "
f"{_i} is being treated as a flat index."
, stacklevel=external_stacklevel())
_multiindex_meta = None
if isinstance(x, pd.DataFrame) and x.index.nlevels >= 2:
if cluster is not None or n_clusters is not None:
raise ValueError(
"cluster=/n_clusters= is not compatible with a row-"
"MultiIndex DataFrame (GH #95): MultiIndex grouping already "
"assigns colors by the top-level index and would conflict "
"with cluster-based grouping. Reset the index "
"(df.reset_index(drop=True)) before clustering, or drop "
"cluster=/n_clusters= to use the MultiIndex grouping."
)
if predict is not None:
raise ValueError(
"predict= is not supported with MultiIndex expansion in "
"this release: forecasts are computed one-per-leaf before "
"the per-level mean traces are appended, so the leaf count "
"no longer matches the final trace count. Reset the index "
"(df.reset_index(drop=True)) before using predict=, or "
"drop predict= to use the MultiIndex grouping."
)
if hue is not None:
warnings.warn(
"x has a row MultiIndex (GH #95): MultiIndex grouping "
"(leaf traces + per-level averages) takes precedence over "
"hue=; ignoring hue."
, stacklevel=external_stacklevel())
hue = None
x, _multiindex_meta = expand_multiindex(x)
# default axis labels from DataFrame column names (release-1.0 audit,
# F08-plot-inputs-016): when a SINGLE DataFrame with named (non-default,
# non-duplicate) columns is passed, remember its df2mat-transformed
# column labels; they become the default xlabel/ylabel(/zlabel) below
# IF the drawn axes end up corresponding 1:1 to those columns (i.e. the
# transformed data is 2-D or 3-D, so no real dimensionality reduction
# mixes the columns). User-passed xlabel=/ylabel=/zlabel= always win,
# and nothing is inferred when transform=/pipeline= replace the
# standard analysis pipeline.
_df_axis_labels = None
if transform is None and pipeline is None:
_lbl_df = None
if isinstance(x, pd.DataFrame):
_lbl_df = x
elif (isinstance(x, (list, tuple)) and len(x) == 1
and isinstance(x[0], pd.DataFrame)):
_lbl_df = x[0]
if (_lbl_df is not None and _lbl_df.shape[1] <= 3
and _lbl_df.index.nlevels == 1
and not isinstance(_lbl_df.columns,
(pd.RangeIndex, pd.MultiIndex))
and not _lbl_df.columns.duplicated().any()):
try:
from ..tools.df2mat import df2mat as _df2mat
from ..tools.format_data import _prepare_df
_df_axis_labels = _df2mat(_prepare_df(_lbl_df, warn=False),
return_labels=True)[1]
except Exception:
_df_axis_labels = None # never let label sugar break a plot
# analyze the data
raw = None
if transform is None:
raw = format_data(x, impute=impute, **text_args)
# resample= (GH #94): PCHIP-resample each dataset to exactly
# `resample` rows via the existing `hyp.manip` `Resample`
# manipulator, applied HERE -- right after `format_data` has
# normalized `x` into a plain list of per-dataset numpy arrays,
# and BEFORE `analyze` (normalize/reduce/align) runs -- so the
# resampled row count is what normalize/reduce/align/cluster/hue
# all see, and (mirroring `predict=`'s forecast values) resample=
# values match `hyp.manip(data, model='Resample',
# n_samples=resample)` on the SAME per-dataset array exactly
# (`hyp.manip`'s dict model spec, e.g. ``{'model': 'Resample',
# 'args': [], 'kwargs': {'n_samples': resample}}``, is equivalent
# but the plain `model='Resample', n_samples=...` call form is
# simpler and used here). Runs before, and independently of, the
# later line-smoothing interpolation (GH #141) -- that step still
# only densifies for ANIMATION/line-drawing purposes and operates
# on whatever row count resample= (or the original data) leaves it.
if resample:
from ..manip.manip import manip as _manip
raw = [
np.asarray(_manip(ri, model='Resample', n_samples=resample))
for ri in raw
]
# per-dataset feature counts must agree (F01-011/F03-008/F08-002):
# the reduce stage stacks every dataset, so mismatched widths used
# to die deep inside numpy ("all the input array dimensions ...
# must match exactly") with no dataset info or fix hint. Fail fast
# with a clear message BEFORE the pipeline runs.
_widths = [ri.shape[1] for ri in raw]
if len(set(_widths)) > 1:
# when the ORIGINAL input mixed text and numeric datasets, the
# real problem is a text/numeric sample-count mismatch (equal
# counts would have been auto-hyperaligned by format_data), not
# the embedded column counts -- say so (release-1.0 audit,
# D08-tutorials-analysis-012 / D05-gallery-data-text-013).
def _has_text(v):
if isinstance(v, str):
return True
if isinstance(v, (list, tuple)):
return any(_has_text(vi) for vi in v)
return False
_text_hint = (
" (Note: text datasets are embedded into topic vectors -- "
"hence the differing column counts -- and can only be "
"combined with numeric datasets when every dataset has the "
"SAME number of samples, which lets hypertools align them "
"to a common space.)") if _has_text(x) else ""
raise ValueError(
"all datasets must have the same number of columns "
"(features) to be analyzed/plotted together, but the "
f"inputs have per-dataset column counts {_widths}. Either "
"pass datasets with matching columns, or bring them into "
"a shared space first and plot the result -- e.g. "
"hyp.plot(hyp.align(data, align='hyper'), ...)."
+ _text_hint)
# labels= carries one entry per observation (F01-010/F10-011):
# validate BEFORE the pipeline runs, mirroring hue='s check.
if labels is not None:
_validate_labels_length(labels, [ri.shape[0] for ri in raw])
# a per-dataset fmt LIST must match the dataset count
# (F01-006/F10-003): fail fast here when no later regrouping
# (hue=/cluster=/MultiIndex) can change the drawn-trace count; the
# regrouped case is re-checked against the FINAL count below.
if (isinstance(fmt, list) and hue is None and cluster is None
and n_clusters is None and _multiindex_meta is None
and len(fmt) != len(raw)):
raise ValueError(
f"fmt was given as a list of length {len(fmt)}, but there "
f"are {len(raw)} dataset(s) to plot; pass one format "
"string per dataset, or a single fmt string to broadcast "
"it to every dataset.")
if pipeline is not None:
# pipeline= (GH #227): apply the fitted Pipeline's stages via
# .transform (never refit) instead of fitting new manip=/
# normalize=/reduce=/align=/cluster= models. reduce=/ndims=/
# normalize=/align=/cluster=/manip= were already validated
# (above) to still be at their defaults, so they are safely
# omitted here -- pipeline= governs every stage.
xform, _ = analyze(raw, pipeline=pipeline, internal=True,
impute=impute, return_model=True)
else:
xform = analyze(
raw,
# plot()'s ndims defaults to 3 (unlike analyze's None), so
# forwarding it alongside reduce=None would trip analyze's
# "ndims= was passed but reduce= is None" warning on EVERY
# reduce=None plot -- including the internal streaming
# redraw -- even though plot enforces its own display
# dimensionality separately below (release-1.0 audit,
# D1-code-residue regression).
ndims=ndims if reduce is not None else None,
normalize=normalize,
reduce=reduce,
align=align,
manip=manip,
internal=True,
impute=impute,
random_state=random_state,
)
else:
xform = transform
# Return data that has been normalized and possibly reduced and/or aligned
xform_data = copy.copy(xform)
# catch all matplotlib kwargs here to pass on
mpl_kwargs = {}
# handle color (to be passed onto matplotlib). `colors` is treated as
# an alias of `color` (like linestyle(s)/marker(s) below) and takes
# priority when both are given -- but it must ALSO work on its own:
# previously this block was nested inside `if color is not None`, so
# `colors=` alone was silently ignored and fell back to the default
# palette (GH #142 follow-up).
if color is not None or colors is not None:
mpl_kwargs["color"] = color
if colors is not None:
mpl_kwargs["color"] = colors
if color is not None:
warnings.warn(
"Both color and colors defined: color will be "
"ignored in favor of colors."
, stacklevel=external_stacklevel())
# handle linestyle (to be passed onto matplotlib). `linestyles` is
# treated as an alias of `linestyle` and takes priority when both are
# given -- but it must ALSO work on its own: previously this block was
# nested inside `if linestyle is not None`, so `linestyles=` alone was
# silently ignored (GH #142 follow-up).
if linestyle is not None or linestyles is not None:
mpl_kwargs["linestyle"] = linestyle
if linestyles is not None:
mpl_kwargs["linestyle"] = linestyles
if linestyle is not None:
warnings.warn(
"Both linestyle and linestyles defined: linestyle "
"will be ignored in favor of linestyles."
, stacklevel=external_stacklevel())
# handle marker (to be passed onto matplotlib). `markers` is treated as
# an alias of `marker` and takes priority when both are given -- but it
# must ALSO work on its own: previously this block was nested inside
# `if marker is not None`, so `markers=` alone was silently ignored
# (GH #142 follow-up).
if marker is not None or markers is not None:
mpl_kwargs["marker"] = marker
if markers is not None:
mpl_kwargs["marker"] = markers
if marker is not None:
warnings.warn(
"Both marker and markers defined: marker will be "
"ignored in favor of markers."
, stacklevel=external_stacklevel())
# handle marker size (to be passed onto matplotlib/plotly)
if markersize is not None:
mpl_kwargs["markersize"] = markersize
# handle line width (to be passed onto matplotlib/plotly)
if linewidth is not None:
mpl_kwargs["linewidth"] = linewidth
# reduce data to <=3 dims for DISPLAY. `analyze` above already applied
# the requested reduce= spec; this pass only enforces the display
# dimensionality (3, or ndims if lower) and is SKIPPED when the data is
# already there -- re-applying a fitted/instance reducer here
# re-transformed its own (already-reduced) output and crashed with
# "X has 3 features, but ... is expecting N features" (F03-002); it
# also re-fired one-shot warnings (e.g. the deprecated {'model',
# 'params'} spec warning) twice per plot() call (F03-009). xform was
# already formatted by analyze(), so format_data is skipped here.
_display_ndims = ndims if (ndims and ndims < 3) else 3
if xform[0].shape[1] > _display_ndims:
if reduce is None:
raise ValueError(
f"the data to plot has {xform[0].shape[1]} dimensions, but "
f"static plots support at most {_display_ndims}; "
"reduce=None disables dimensionality reduction, so the "
"data cannot be drawn. Pass a reduce model (e.g. the "
"default reduce='IncrementalPCA'), or reduce the data "
"yourself before plotting.")
_display_reduce = reduce
if (not isinstance(reduce, (str, dict, type))
and hasattr(reduce, "fit_transform")):
# a model INSTANCE (possibly fitted, possibly configured with
# >3 components) must not be applied a second time -- project
# to display space with the default reducer instead.
_display_reduce = "IncrementalPCA"
xform = reducer(xform, ndims=_display_ndims, reduce=_display_reduce,
internal=True, format_data=False)
if xform[0].shape[1] > _display_ndims:
# e.g. a dict spec pinning n_components > 3: fall back to the
# default display projection rather than crash downstream.
xform = reducer(xform, ndims=_display_ndims,
reduce="IncrementalPCA", internal=True,
format_data=False)
# surface= (GH #109): no hull concept in 1D -- fail fast rather than
# silently ignoring the kwarg.
if _surface_norm is not None and xform[0].shape[1] == 1:
raise ValueError(
"surface= is not supported for 1D data (no hull concept in a "
"single dimension)."
)
# density= (GH #108/#191): no 2D/3D density-grid concept in 1D -- fail
# fast rather than silently ignoring the kwarg (mirrors surface= above).
if _density_norm is not None and xform[0].shape[1] == 1:
raise ValueError(
"density= is not supported for 1D data (no 2D/3D density grid "
"concept in a single dimension)."
)
# zlabel= (round17 #7): no z-axis to label on a 2D (or 1D) plot -- fail
# fast rather than silently ignoring the kwarg (mirrors surface=/
# density= above), now that xform's FINAL dimensionality is known.
# apply the DataFrame-column default axis labels (F08-016), now that
# xform's FINAL dimensionality is known -- only when it matches the
# DataFrame's transformed column count exactly (2-D or 3-D), so each
# drawn axis IS one named column; user-passed labels take precedence
# (pass e.g. xlabel='' to suppress a single inferred label).
if (_df_axis_labels is not None
and len(_df_axis_labels) == xform[0].shape[1]
and len(_df_axis_labels) in (2, 3)):
if xlabel is None:
xlabel = str(_df_axis_labels[0])
if ylabel is None:
ylabel = str(_df_axis_labels[1])
if len(_df_axis_labels) == 3 and zlabel is None:
zlabel = str(_df_axis_labels[2])
if zlabel is not None and xform[0].shape[1] < 3:
raise ValueError(
f"zlabel= is not supported for {xform[0].shape[1]}-D data (no "
"z-axis in a 2-D or 1-D plot); pass ndims=3 (the default) to "
"use zlabel=, or use xlabel=/ylabel= for 2-D data."
)
# predict=: forecast `t` new rows per input dataset, in the plotted
# (post normalize->reduce->align) space (GH #169). Computed here -- one
# forecast per ORIGINAL input dataset, before any cluster/hue reshaping
# -- so the forecasts correspond 1:1 with the datasets about to be
# drawn. For the DRAWN dashed trace only, the final observed row of
# each dataset is prepended so the trace connects to the plotted
# trajectory (drawn trace length is therefore t + 1).
# `bundle_forecasts` keeps the UNPREPENDED analyze-space forecasts --
# exactly `t` rows, matching what `hyp.predict(xform_data, ...)`
# returns (release-1.0 audit, X1-api-consistency-016: the bundle used
# to include the seam row, an off-by-one vs. hyp.predict);
# `raw_forecasts` is the seam-prepended working copy that gets the
# SAME center/scale transform as `xform` below, so the drawn dashed
# trace lines up with the drawn (centered/scaled) data.
raw_forecasts = None
bundle_forecasts = None
if predict is not None:
from ..predict.predict import predict as _predictor
_fc = _predictor(xform, model=predict, t=t)
if not isinstance(_fc, list):
_fc = [_fc]
bundle_forecasts = [np.asarray(fc, dtype=float) for fc in _fc]
raw_forecasts = [
np.vstack([np.asarray(xi[-1:]), np.asarray(fc)])
for xi, fc in zip(xform, _fc)
]
# per-point colors for multicolored lines (set by the hue branch below;
# computed after interpolation). Dataset lengths are captured now so hue
# values can be re-interpolated to match the interpolated trajectories.
multicolor_hue = None
# when a high-dim matrix hue is reduced to a 3-column RGB matrix (see the
# color_reduce= branch below), multicolor_hue holds literal per-point RGB
# values that must be used AS colors rather than blended over a palette.
multicolor_hue_is_rgb = False
pre_interp_lengths = [len(xi) for xi in xform]
# morph interpolates between point CLOUDS, so hue (which colors fixed
# observations) has no stable point to attach to across the morph -- every
# hue form crashed here (IndexError from the data/label reshape, QC 2026-07,
# pre-existing). Drop hue (with a warning) before the cluster/hue grouping
# chain below rather than crash.
if (((animate == 'morph') or isinstance(animate, list))
and hue is not None):
warnings.warn("hue is not supported with animate='morph'; "
"ignoring hue.", stacklevel=external_stacklevel())
hue = None
# original category NAMES for a categorical hue (set below, if
# applicable), used by `legend=True` so the legend/colorbar show the
# actual category strings rather than the integer group ids `hue` gets
# reassigned to just below (group_by_category returns ints).
hue_category_names = None
# one label per drawn GROUP, in group order -- like hue_category_names
# but with '_nolegend_' placeholders for unnamed groups (the None
# entries of a partially-labeled hue; F02-013), so legend=True and the
# discrete colorbar can label every trace without a length mismatch.
hue_group_labels = None
# (n_input_datasets, n_hue_groups) when a categorical hue regrouped the
# data by category -- names= (one name per INPUT dataset) cannot apply
# after that regrouping (F02-009).
_hue_regrouped_counts = None
# unfitted Clusterer built from the SAME resolved spec the figure's
# cluster stage used (set in the cluster branch below), so the
# return_model bundle's pipeline encodes the parameters the figure
# was actually drawn with (F13-004)
_bundle_cluster_stage = None
# MultiIndex DataFrames (GH #95): xform currently holds the TRANSFORMED
# leaf trajectories (post normalize/reduce/align), in the same order as
# `_multiindex_meta['leaf_keys']` -- exactly what `build_multiindex_styles`
# needs to compute per-level mean trajectories IN THE REDUCED SPACE and
# append them. cluster=/n_clusters= were already rejected and hue=
# already squelched (with a warning) above, so this always wins the
# cluster/hue/nested_groups chain below.
if _multiindex_meta is not None:
if color is not None or colors is not None:
warnings.warn(
"x has a row MultiIndex (GH #95): MultiIndex grouping "
"assigns color by the top-level index; ignoring "
"color/colors."
, stacklevel=external_stacklevel())
if linewidth is not None:
warnings.warn(
"x has a row MultiIndex (GH #95): MultiIndex grouping "
"assigns linewidth by level (leaves=1, thicker per level "
"averaged over); ignoring linewidth."
, stacklevel=external_stacklevel())
xform, _mi_style = build_multiindex_styles(
xform, _multiindex_meta, palette=palette,
linestyle=linestyle, linestyles=linestyles)
mpl_kwargs["color"] = _mi_style["colors"]
mpl_kwargs["linewidth"] = _mi_style["linewidths"]
mpl_kwargs["alpha"] = _mi_style["alphas"]
if _mi_style["linestyles"] is not None:
mpl_kwargs["linestyle"] = _mi_style["linestyles"]
mpl_kwargs["label"] = _mi_style["labels"]
legend = _mi_style["labels"]
# find cluster and reshape if cluster=/n_clusters= was given
# (n_clusters= alone defaults to KMeans, matching the docstring)
elif cluster is not None or n_clusters is not None:
if hue is not None:
warnings.warn(
("cluster" if cluster is not None else "n_clusters")
+ " overrides hue, ignoring hue.", stacklevel=external_stacklevel())
hue = None
if cluster is None:
cluster = "KMeans"
if isinstance(cluster, bytes):
cluster = cluster.decode("utf-8")
from ..cluster.cluster import _resolve_cluster_spec
_n_clusters_explicit = n_clusters is not None
_cluster_instance = None
_spec_kwargs = {}
_spec_top_n = None
if isinstance(cluster, str):
model = cluster
params = default_params(model) or {}
elif isinstance(cluster, dict):
if "model" not in cluster:
# the same instructive error hyp.cluster raises for a
# model-less dict spec, instead of a bare KeyError
# (F13-010)
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).")
model = cluster["model"]
model_key = model if isinstance(model, str) \
else getattr(model, "__name__", str(model))
# canonical {'model': ..., 'args': [...], 'kwargs': {...}} (or
# just 'kwargs', no 'args') vs LEGACY {'model': ...,
# 'params': {...}} (accepted for backward compatibility, with
# a DeprecationWarning) -- mirrors
# hypertools.cluster.cluster._resolve_cluster_spec's own
# dict-shape handling (round17 Task 6 fix: this used to only
# read cluster.get('params', {}), silently DROPPING a
# canonical {'model', 'kwargs'} dict's kwargs). The spec below
# is always rebuilt in the canonical {'model', 'kwargs'} form
# before being handed to `_resolve_cluster_spec` further down,
# so that call never re-triggers this same warning -- do NOT
# double-warn.
if "args" in cluster or "kwargs" in cluster:
_spec_kwargs = dict(cluster.get("kwargs", {}))
elif "params" in cluster:
warnings.warn(
"{'model': ..., 'params': {...}} is deprecated; use "
"{'model': ..., 'args': [...], 'kwargs': {...}} instead",
DeprecationWarning, stacklevel=external_stacklevel())
_spec_kwargs = dict(cluster["params"])
params = default_params(model_key, _spec_kwargs) or {}
if "n_clusters" in cluster:
# top-level convenience:
# cluster={'model': ..., 'n_clusters': k} -- handed to
# _resolve_cluster_spec below, which applies hyp.cluster's
# documented precedence (the spec's value wins over
# n_clusters=, with a UserWarning on explicit conflicts)
_spec_top_n = cluster["n_clusters"]
elif isinstance(cluster, type) or hasattr(cluster, "fit_predict") \
or hasattr(cluster, "fit_transform"):
# a class or (sklearn-API) model instance: hyp.cluster accepts
# these directly (F03-014) -- resolve the registry name for
# the mixture-model check below and pass the object through.
model = cluster if isinstance(cluster, type) else type(cluster)
_cluster_instance = None if isinstance(cluster, type) else cluster
params = {}
if n_clusters is not None and _cluster_instance is not None:
warnings.warn(
"n_clusters= is ignored when cluster= is a model "
"INSTANCE (the instance's own parameters are used); "
"configure the instance directly (e.g. "
"KMeans(n_clusters=...)) or pass the model by name "
"(e.g. cluster='KMeans').", stacklevel=external_stacklevel())
n_clusters = None
_n_clusters_explicit = False
else:
raise ValueError(
"invalid cluster model: expected a string, dict spec, "
"class, or (sklearn-API) model instance; got "
f"{cluster!r}.")
# FeatureAgglomeration clusters FEATURES (columns), not
# observations: it yields one label per COLUMN, so there is no
# per-observation grouping to color/reshape the plot with --
# regrouping by its labels silently drew n_features "points"
# where the data's rows should be, or crashed downstream
# (F13-001).
if _mixture_name(model) == "FeatureAgglomeration":
raise ValueError(
"cluster='FeatureAgglomeration' is not supported by "
"hyp.plot: FeatureAgglomeration clusters features "
"(columns), not observations, so its labels (one per "
"column) cannot color or group the plotted rows. Use "
"hyp.cluster(data, cluster='FeatureAgglomeration') to "
"get per-column labels directly.")
# n_clusters= exemption for models that discover their own
# cluster count (HDBSCAN, DBSCAN, MeanShift, OPTICS,
# AffinityPropagation): warn-and-ignore instead of crashing in
# the sklearn constructor, using the same signature-based check
# as hyp.cluster's _resolve_cluster_spec (F13-002; generalizes
# the old HDBSCAN-only special case).
_model_cls = None
if isinstance(model, str):
_model_cls = (hard_cluster_models.get(model)
or mixture_models.get(model))
elif isinstance(model, type):
_model_cls = model
if (_n_clusters_explicit and _model_cls is not None
and _mixture_name(model) not in mixture_models
and "n_clusters"
not in inspect.signature(_model_cls).parameters):
warnings.warn(
f"n_clusters is not a valid parameter for "
f"{_mixture_name(model)} clustering and will be ignored.", stacklevel=external_stacklevel())
n_clusters = None
_n_clusters_explicit = False
# default_params() pre-fills a DEFAULT cluster count (KMeans
# n_clusters=3, LDA/NMF n_components=20, ...). When the caller
# supplied a count (n_clusters= or the dict's top-level
# 'n_clusters'), drop that default -- it is not a user-typed
# spec kwarg, so it must not win the resolver's spec-kwargs-
# take-precedence rule (F13-009) or trigger a bogus conflict
# warning.
if _n_clusters_explicit or _spec_top_n is not None:
for _count_key in ("n_clusters", "n_components"):
if _count_key in params and _count_key not in _spec_kwargs:
del params[_count_key]
# resolve the spec ONCE with hyp.cluster's own resolver -- same
# grammar, same precedence (spec kwargs beat n_clusters=, with a
# UserWarning on explicit conflicts), same signature-based
# n_clusters exemption, and random_state= injection
# (F13-002/-003/-009/-020) -- and build the return_model
# bundle's cluster stage from the IDENTICAL resolved spec so the
# bundled pipeline encodes the same parameters the figure was
# drawn with (F13-004).
if _cluster_instance is not None:
_resolve_spec = _cluster_instance
else:
_resolve_spec = {"model": model, "kwargs": params}
if _spec_top_n is not None:
_resolve_spec["n_clusters"] = _spec_top_n
_cluster_stage = _resolve_cluster_spec(
_resolve_spec, n_clusters if n_clusters is not None else 3,
random_state=random_state,
n_clusters_explicit=_n_clusters_explicit)
# a second, unfitted resolution of the same spec for the bundle
# (n_clusters_explicit=False: any conflict was already warned
# about just above -- values resolve identically either way)
_bundle_cluster_stage = _resolve_cluster_spec(
_resolve_spec, n_clusters if n_clusters is not None else 3,
random_state=random_state)
cluster_labels = clusterer(xform, cluster=_cluster_stage)
if _mixture_name(model) in mixture_models:
# soft assignments: color each observation by the proportion-
# weighted blend of its components' colors
if legend is True:
warnings.warn(
"legend is not supported for mixture-model clustering "
"(observations have blended colors, not discrete "
"groups); ignoring legend."
, stacklevel=external_stacklevel())
legend = None
if not animate:
# exact per-point colors (rendered via collections/scatter)
multicolor_hue = np.asarray(cluster_labels,
dtype=np.float64)
hue = None
elif _fmt_draws_line(fmt):
# LINE animation: segment each dataset into contiguous
# same-group runs (never merging a group's non-adjacent
# points or crossing a dataset boundary; GH #291), each run
# coloured by its quantized blended group colour.
blended = mat2colors(cluster_labels, palette=palette)
group_ids, group_colors = colors2groups(blended)
_cat_color = {gid: group_colors[gid]
for gid in dict.fromkeys(group_ids)}
_cat_label = {gid: str(gid) for gid in dict.fromkeys(group_ids)}
_nd = len(xform)
xform, labels, _run_colors, hue_group_labels, _seg_ds = \
_regroup_categorical_lines(
xform, group_ids, labels, _cat_color, _cat_label)
fmt = _expand_styles_to_runs(fmt, mpl_kwargs, _seg_ds, _nd)
mpl_kwargs["color"] = _run_colors
hue = group_ids
else:
# marker animations render one trace per group: quantize the
# blended colors into (near-)identical-color groups
blended = mat2colors(cluster_labels, palette=palette)
group_ids, group_colors = colors2groups(blended)
xform, labels = reshape_data(xform, group_ids, labels)
mpl_kwargs["color"] = [
group_colors[gid]
for gid in sorted(set(group_ids), key=group_ids.index)
]
hue = group_ids
elif _fmt_draws_line(fmt):
# LINE clustering: contiguous-run segmentation so a cluster's
# non-adjacent points are NOT joined into one polyline and
# separate datasets are never bridged (GH #291); each run is
# coloured + labelled by its cluster id in sorted order, one
# legend/colorbar entry per cluster.
_cat_color, _cat_label = _categorical_color_label_maps(
cluster_labels, palette, None, None, sort_numeric=True)
_nd = len(xform)
xform, labels, _run_colors, hue_group_labels, _seg_ds = \
_regroup_categorical_lines(
xform, cluster_labels, labels, _cat_color, _cat_label)
fmt = _expand_styles_to_runs(fmt, mpl_kwargs, _seg_ds, _nd)
mpl_kwargs["color"] = _run_colors
hue = cluster_labels
try:
_cats_sorted = sorted(set(cluster_labels))
except TypeError:
_cats_sorted = list(dict.fromkeys(cluster_labels))
hue_category_names = [str(c) for c in _cats_sorted]
else:
xform, labels = reshape_data(xform, cluster_labels, labels)
# reshape_data returns groups in first-appearance order;
# reorder the drawn groups (and their legend/colorbar
# labels) into sorted label order so a legend reads
# '0, 1, 2' rather than e.g. '1, 0, 2' (F13-022)
_cats = list(sorted(set(cluster_labels),
key=list(cluster_labels).index))
try:
_order = sorted(range(len(_cats)), key=lambda i: _cats[i])
except TypeError:
_order = list(range(len(_cats)))
xform = [xform[i] for i in _order]
labels = [labels[i] for i in _order]
hue = cluster_labels
hue_group_labels = [str(_cats[i]) for i in _order]
hue_category_names = list(hue_group_labels)
# group data if there is a grouping var
elif hue is not None:
if color is not None:
warnings.warn("hue= and color= were both given; color= will "
"be ignored in favor of hue=.", stacklevel=external_stacklevel())
# pandas containers are used POSITIONALLY (their values, in order):
# a categorical Series whose index does not contain the label 0
# (e.g. a column sliced from a filtered DataFrame) crashed with a
# bare `KeyError: 0` at the `hue[0]` tuple check below, because
# `[]` on a Series is LABEL-based indexing (release-1.0 audit,
# F02-003). A single-column DataFrame keeps its existing
# matrix-hue handling via np.asarray below.
if isinstance(hue, (pd.Series, pd.Index, pd.Categorical)):
hue = hue.tolist()
# NESTED per-dataset hue: when the data is a list of datasets, hue may
# be given with the SAME nesting -- one hue sub-sequence per dataset,
# each matching that dataset's length (the classic list-of-lists form,
# e.g. examples/plot_hue.py). Flatten it to one value (or one matrix
# row) per observation before classifying, so np.asarray doesn't read a
# (n_datasets, len) block as a (3, ...) matrix hue. A genuinely flat or
# (n_obs, k) matrix hue has len(hue) != n_datasets (or scalar elements),
# so it is left untouched.
if (isinstance(hue, (list, tuple)) and len(xform) > 1
and len(hue) == len(xform)
and all(np.ndim(h) >= 1 and len(h) == len(xi)
for h, xi in zip(hue, xform))):
flat_hue = []
for h in hue:
flat_hue.extend(list(h))
hue = flat_hue
# classify the hue argument: per-observation numeric matrix
# (mixture proportions, model weights, ...), continuous 1D values,
# or discrete grouping labels
n_obs = sum(len(xi) for xi in xform)
try:
hue_array = np.asarray(hue)
except Exception:
hue_array = None
# a SCALAR hue -- a single string or number, e.g. hue='red' -- means
# "put every observation in one group". Broadcast it to one value per
# observation so it is not mis-measured as len('red') == 3 characters
# (QC 2026-07 red-team: hue='red' on 20 points raised the nonsensical
# "hue has 3 entries but the data has 20 observations"). Since a
# single-group hue colors nothing differently, this is usually a
# mistake -- e.g. a DataFrame COLUMN NAME passed seaborn-style --
# so say so rather than silently accepting it (release-1.0 audit,
# X2-error-quality-016).
if hue_array is not None and hue_array.ndim == 0:
warnings.warn(
f"hue= was given a single scalar value ({hue!r}); all "
"observations will be placed in ONE group (a single "
"color). hue= takes the per-observation values themselves "
"-- a list/array with one entry per observation (e.g. "
"hue=df['col'] rather than a column name).",
UserWarning, stacklevel=external_stacklevel())
hue = [hue_array.item()] * n_obs
hue_array = np.asarray(hue)
# validate hue length (QC 2026-07): a hue that was too SHORT silently
# truncated the plot (rendered only the first len(hue) points, no
# warning); too LONG raised a cryptic IndexError deep in reshape_data.
# hue must carry exactly one value/row per observation.
_hue_len = (hue_array.shape[0]
if hue_array is not None and hue_array.ndim >= 1
else len(hue))
if _hue_len != n_obs:
raise ValueError(
f"hue has {_hue_len} entr{'y' if _hue_len == 1 else 'ies'} but "
f"the data has {n_obs} observations; hue must have exactly one "
"value (or one row, for a matrix hue) per observation.")
hue_is_matrix = (hue_array is not None and hue_array.ndim == 2
and np.issubdtype(hue_array.dtype, np.number)
and hue_array.shape[0] == n_obs)
# small-cardinality integer (or boolean) hue -- e.g. the cluster
# labels hyp.cluster returns -- is CATEGORICAL, not continuous: on
# the continuous path, adjacent integer labels (0 and 1) map to
# visually indistinguishable neighboring palette samples
# (F13-005). Rule (documented in the hue docstring): integer/bool
# dtype, at most 12 unique values, and fewer unique values than
# observations; anything else numeric stays continuous.
_hue_int_categorical = (
hue_array is not None and hue_array.ndim == 1
and (np.issubdtype(hue_array.dtype, np.integer)
or np.issubdtype(hue_array.dtype, np.bool_))
and hue_array.shape[0] == n_obs
and len(np.unique(hue_array)) <= 12
and len(np.unique(hue_array)) < n_obs)
hue_is_continuous = (hue_array is not None and hue_array.ndim == 1
and np.issubdtype(hue_array.dtype, np.number)
and hue_array.shape[0] == n_obs
and not _hue_int_categorical)
# arbitrary matrix hue -> RGB: when the hue matrix has MORE than 3
# columns, or color_reduce= is explicitly given, reduce it to 3 columns
# (default 'IncrementalPCA'; color_reduce accepts any hyp.reduce spec)
# and min-max each column to [0, 1] so the three reduced dimensions map
# directly to (r, g, b). Those per-observation rows are then used AS
# colors. A <=3-column matrix with no color_reduce= keeps the
# palette-blend path (mixture proportions etc.).
if hue_is_matrix and (hue_array.shape[1] > 3 or color_reduce is not None):
_rgb = np.asarray(hue_array, dtype=np.float64)
if _rgb.shape[1] > 3:
# more than 3 columns: reduce to 3 (default IncrementalPCA;
# color_reduce accepts any hyp.reduce spec). A <=3-column matrix
# is NOT reduced -- hyp.reduce(ndims=3) can't synthesize more
# dimensions than the input has, and doing so crashed for k<=3
# (QC 2026-07 red-team); its columns are used directly instead.
from ..reduce.reduce import reduce as _color_reducer
try:
_rgb = np.asarray(
_color_reducer(
_rgb,
reduce=(color_reduce or 'IncrementalPCA'),
ndims=3),
dtype=np.float64)
except ValueError as exc:
# name the kwarg the user actually passed (the
# underlying error says 'reduce', which the user never
# typed; release-1.0 audit, F02-008) and collapse any
# whitespace runs from wrapped source lines
raise ValueError(
f"color_reduce={color_reduce!r} failed to reduce "
"the matrix hue to 3 color channels: "
f"{' '.join(str(exc).split())}") from exc
if _rgb.ndim == 3 and _rgb.shape[0] == 1:
_rgb = _rgb[0]
# min-max each column to [0, 1]
_lo = _rgb.min(axis=0, keepdims=True)
_hi = _rgb.max(axis=0, keepdims=True)
_span = np.where((_hi - _lo) > 0, _hi - _lo, 1.0)
_rgb = np.clip((_rgb - _lo) / _span, 0.0, 1.0)
# pad to exactly 3 channels (a 1- or 2-column matrix given with an
# explicit color_reduce=): fill the missing channel(s) with a
# neutral 0.5 so the present columns still drive the color.
if _rgb.shape[1] < 3:
_rgb = np.hstack(
[_rgb, np.full((_rgb.shape[0], 3 - _rgb.shape[1]), 0.5)])
hue_array = _rgb
multicolor_hue_is_rgb = True
# set when a categorical INTEGER/boolean hue needs its groups
# reordered from first-appearance to sorted numeric order after
# reshape_data below (F13-005/F13-022)
_hue_sort_numeric = False
# morph animations tag/reshape datasets specially, so continuous/matrix
# hue there keeps the grouped path below; every other animation (spin,
# window, parallel, serial, True) uses the SAME exact-per-point-color
# path as static plots (QC 2026-07). Excluding all animations here sent
# continuous hue into the categorical regroup below, which split it into
# single-point "groups" and crashed the frame interpolation
# (`interp_array`: "x must contain at least 2 elements").
_animate_is_morph = (animate == 'morph') or isinstance(animate, list)
if (hue_is_matrix or hue_is_continuous) and not _animate_is_morph:
# EXACT PER-POINT COLORS: color varies continuously across
# observations. Datasets stay intact (no group reshape, which
# would fragment lines and quantize marker colors); per-point
# colors are computed after interpolation, below, and rendered
# via collections (lines) or scatter (markers), and -- for
# animations -- passed through to each frame as point_colors.
multicolor_hue = np.asarray(hue_array, dtype=np.float64)
if legend is True:
warnings.warn("legend is not supported for continuous or "
"matrix-valued hue; ignoring legend.", stacklevel=external_stacklevel())
legend = None
hue = None
elif hue_is_matrix:
# markers (or animated) path: blend colors per observation,
# then group observations with (near-)identical colors into
# traces
blended = (hue_array if multicolor_hue_is_rgb
else mat2colors(hue_array, palette=palette))
group_ids, group_colors = colors2groups(blended)
mpl_kwargs["color"] = [
group_colors[gid]
for gid in sorted(set(group_ids), key=group_ids.index)
]
if legend is True:
warnings.warn("legend is not supported for matrix-valued "
"hue; ignoring legend.", stacklevel=external_stacklevel())
legend = None
hue = group_ids
else:
# if list of lists, unpack
if any(isinstance(el, list) for el in hue):
hue = list(itertools.chain(*hue))
# if all of the elements are numbers, map them to colors
if not isinstance(hue[0], tuple):
if _hue_int_categorical:
# categorical integer/boolean group ids (F13-005):
# grouped and palette-colored like string labels, with
# groups (and legend/colorbar labels) in sorted
# numeric order -- see the hue docstring's
# categorical-vs-continuous rule
_int_cats = sorted(set(hue_array.tolist()))
hue_category_names = [str(c) for c in _int_cats]
hue_group_labels = list(hue_category_names)
hue = hue_array.tolist()
_hue_sort_numeric = True
elif all(isinstance(el, (int, float, np.integer,
np.floating))
and not isinstance(el, bool) for el in hue):
hue = vals2bins(hue)
elif all(isinstance(el, str) for el in hue):
hue_category_names = list(
sorted(set(hue), key=list(hue).index))
hue_group_labels = list(hue_category_names)
hue = group_by_category(hue)
elif (any(el is None for el in hue)
and any(isinstance(el, str) for el in hue)
and all(el is None or isinstance(el, str)
for el in hue)):
# partially-labeled hue (the docstring's "label a subset
# of points" form, e.g. ['a', None, 'b', 'a']): the None
# entries mark UNLABELED points. They form their own
# group but are drawn in a de-emphasized neutral gray
# and get no legend entry, and the NAMED categories keep
# the first palette slots in first-appearance order --
# previously the None group consumed a fully-saturated
# palette slot the legend never explained, and shifted
# the named categories' colors (release-1.0 audit,
# F02-013).
_cats = list(sorted(set(hue), key=list(hue).index))
hue_category_names = [c for c in _cats if c is not None]
hue_group_labels = ['_nolegend_' if c is None else c
for c in _cats]
_base = get_palette_colors(palette,
len(hue_category_names))
_named_idx = {c: i for i, c
in enumerate(hue_category_names)}
mpl_kwargs["color"] = [
_UNLABELED_HUE_COLOR if c is None
else tuple(_base[_named_idx[c]]) for c in _cats]
hue = group_by_category(hue)
# reshape the data according to group
if hue is not None:
# fail fast, naming hue=, on unhashable entries (e.g. a dict
# passed as hue) -- previously a bare "TypeError: unhashable
# type" escaped from deep inside reshape_data (F02-010)
try:
set(hue)
except TypeError as exc:
_bad = next((el for el in hue
if getattr(el, '__hash__', None) is None), None)
raise TypeError(
"hue= entries must be hashable category labels, 1-D "
"numeric values, or the rows of a 2-D numeric matrix; "
f"got an entry of type {type(_bad).__name__}: {_bad!r}"
) from exc
_n_datasets_before_hue = len(xform)
if _fmt_draws_line(fmt):
# LINE: contiguous-run segmentation preserving order AND
# input-dataset identity (GH #291). Global category merging
# (reshape_data) would fuse separate datasets that share a
# category into one line, and collapse a category that
# recurs along a trajectory (A A B B A A) into a tangled
# polyline joining non-adjacent points. Segmenting keeps each
# run separate, colours it by its category, bridges only runs
# adjacent within one dataset, and gives each category ONE
# legend entry.
_cat_color, _cat_label = _categorical_color_label_maps(
hue, palette, mpl_kwargs.get("color"),
hue_group_labels, _hue_sort_numeric)
xform, labels, _run_colors, hue_group_labels, _seg_ds = \
_regroup_categorical_lines(
xform, hue, labels, _cat_color, _cat_label)
fmt = _expand_styles_to_runs(
fmt, mpl_kwargs, _seg_ds, _n_datasets_before_hue)
mpl_kwargs["color"] = _run_colors
else:
# MARKER-only: global grouping (one trace per category) is
# correct -- scatter has no connecting edges to fuse. Integer/
# boolean hue is grouped in first-appearance order then
# reordered into sorted numeric order (F13-005).
xform, labels = reshape_data(xform, hue, labels)
if _hue_sort_numeric:
_appear = list(sorted(set(hue), key=list(hue).index))
_order = sorted(range(len(_appear)),
key=lambda i: _appear[i])
xform = [xform[i] for i in _order]
labels = [labels[i] for i in _order]
_hue_regrouped_counts = (_n_datasets_before_hue, len(xform))
# a PURE line cannot render a single-observation category -- it
# draws NOTHING (and crashed animated interpolation, F02-002).
# A non-bridged single-point run (dataset-boundary/last run, or a
# singleton category) hits this; warn, naming the category. ('o-'
# and other marker+line combos still show the marker, so this is
# gated on the pure-line format only.)
if is_line(fmt):
_tiny = [i for i, xi in enumerate(xform) if xi.shape[0] < 2]
if _tiny:
_tiny_names = ", ".join(
repr(hue_group_labels[i])
if (hue_group_labels is not None
and i < len(hue_group_labels))
else f"group {i}" for i in _tiny)
warnings.warn(
f"hue categor{'y' if len(_tiny) == 1 else 'ies'} "
f"{_tiny_names} ha{'s' if len(_tiny) == 1 else 've'} "
"only one observation; a pure line format cannot "
"render a single point, so it will be invisible -- "
"pass fmt='.' or fmt='o-' to mark singleton "
"categories.", stacklevel=external_stacklevel())
# multilevel styling for nested-list input: every leaf under the same
# outermost group shares that group's color, and each additional nesting
# level renders thinner and fainter (summary -> detail)
elif nested_groups is not None and color is None and colors is None:
import seaborn as sns
n_outer = len(set(nested_groups))
base_colors = sns.color_palette(
_seaborn_palette_arg(palette, n_outer), n_outer)
mpl_kwargs["color"] = [base_colors[g] for g in nested_groups]
min_depth = min(nested_depths)
if any(d != min_depth for d in nested_depths):
mpl_kwargs["linewidth"] = [
max(0.5, 2.0 * (0.7 ** (d - min_depth))) for d in nested_depths
]
mpl_kwargs["alpha"] = [
max(0.3, 0.9 ** (d - min_depth)) for d in nested_depths
]
# surface= (GH #109): broadcast to the FINAL (post cluster/hue-reshape)
# dataset count -- reshaping above can change how many traces are
# actually drawn, so this must run after it, not against the original
# `x`.
surface_list = (broadcast_surface(_surface_norm, len(xform))
if _surface_norm is not None else None)
# density= (GH #108/#191): broadcast to the FINAL (post cluster/hue-
# reshape) dataset count, same as surface= above.
density_list = (broadcast_density(_density_norm, len(xform))
if _density_norm is not None else None)
# animate='morph' (Hungarian-matched point-cloud morphs between
# datasets, maintainer request): resolve a scalar or per-dataset LIST
# `animate` into the actual backend style (`animate` from here on is
# always one GLOBAL mode -- there is only ever one camera and one frame
# loop, exactly as before) plus `morph_tags` (which FINAL datasets join
# the morph sequence), now that `xform` reflects the final (post
# cluster/hue-reshape) dataset count -- same timing as
# surface_list/density_list above.
animate, morph_tags = _resolve_animate_mode(animate, len(xform))
# round17 #9 (GH #123): animate='morph' now supports 2-D as well as
# 3-D data, matching every other animate style -- only 1-D (and any
# higher-than-3-D result, which `plot.py` never actually produces for
# plotting) has no hull/point-cloud concept to morph between.
if morph_tags is not None and xform[0].shape[1] not in (2, 3):
raise NotImplementedError(
"animate='morph' is only supported for 2-D or 3-D plots; the "
f"data being plotted is {xform[0].shape[1]}-D. Pass ndims=2 or "
"ndims=3 (the default) to use animate='morph'."
)
# `rotations` as a per-SEGMENT list ([hold_1, morph_1->2, hold_2, ...],
# length 2 * n_morph_datasets - 1): the mode-mismatch check (list given
# under a non-morph mode) was already raised, fail-fast, near the top
# of this function -- it depends only on the raw `animate` argument,
# not on `n_datasets`. The length check below, in contrast, genuinely
# cannot happen any earlier: `n_morph_datasets` is the count of FINAL
# (post cluster/hue-reshape) datasets tagged for morph, which is only
# known now that `xform`/`morph_tags` exist.
if morph_tags is not None:
rotations = resolve_morph_rotations(rotations, sum(morph_tags))
# 2-D animations (round17 #9, GH #123): fixed (non-rotating) viewport --
# `rotations=`/`zoom=` are 3-D camera controls with no 2-D equivalent,
# so whenever `animate` is truthy on 2-D data and either was set to a
# non-default value, warn (once, here -- BEFORE dispatching to either
# backend, so both backends behave identically) that it is ignored.
# Applies uniformly to every animate style, including 'morph': its
# `rotations` doubles as a per-segment PACING control in 3-D (see
# `hypertools.plot.morph.segment_frame_counts`), not purely a camera
# control, but that coupling is itself 3-D-camera-derived, so 2-D
# morphs always use even segment timing for consistency with every
# other 2-D animate style (both backends -- see
# `matplotlib_backend.animate_plot2D`/`plotly_backend._add_animation`).
if animate and xform[0].shape[1] == 2:
_rotations_is_default = (
rotations == 1 if not isinstance(rotations, (list, tuple))
else False
)
if not _rotations_is_default:
warnings.warn(
"rotations= controls 3-D camera spin and has no effect on "
"2-D animations, which use a fixed (non-rotating) "
"viewport; ignoring.",
UserWarning,
stacklevel=external_stacklevel(),
)
if zoom != 1:
warnings.warn(
"zoom= controls the 3-D camera's distance/box-aspect zoom "
"and has no 2-D equivalent; ignoring.",
UserWarning,
stacklevel=external_stacklevel(),
)
# chemtrails/precog/bullettime (GH #127): broadcast bool-or-list to the
# FINAL (post cluster/hue-reshape) dataset count, same as surface=/
# density= above -- each accepts a single bool (applied to every
# dataset) or a list/tuple of bool (one entry per drawn dataset, mixed
# per-dataset combinations allowed). `animate` itself stays a single
# GLOBAL mode -- only these trail FLAGS become per-dataset.
chemtrails = broadcast_trail_flag(chemtrails, len(xform), "chemtrails")
precog = broadcast_trail_flag(precog, len(xform), "precog")
bullettime = broadcast_trail_flag(bullettime, len(xform), "bullettime")
# trail flags on a STATIC plot (release-1.0 audit, F05-007): the same
# user mistake the spin/serial/morph/window branch below already warns
# about -- a user who forgot animate=True got no feedback about why
# their trails were missing.
if not animate:
_static_trail_flags = [
name for name, flags in (("chemtrails", chemtrails),
("precog", precog),
("bullettime", bullettime))
if any(flags)
]
if _static_trail_flags:
warnings.warn(
f"{'/'.join(_static_trail_flags)} only affect ANIMATED "
"plots and will be ignored here; pass animate=True (or "
"'parallel') to draw trails.",
UserWarning,
stacklevel=external_stacklevel(),
)
# GH #127 (+ morph/window follow-up): 'spin' has no "current position"
# (only the camera moves, so a trail has nothing to trail BEHIND or AHEAD
# of), 'serial' already communicates elapsed time via its point-by-point
# reveal, 'morph' draws a single traveling point-cloud artist with no
# per-dataset "current position" either, and 'window' (round17 #8) is
# explicitly bullettime MINUS its chemtrails/precog trail components
# (Jeremy's own definition) -- trail styles are semantically meaningless
# in all four, so warn once (naming the mode, which flag(s) were set, and
# for which dataset indices) rather than silently building frozen/
# invisible trail artists. `_draw`/`plotly_draw` skip creating those
# artists entirely for these modes (see their own `style`/`animate`
# branches), so this is purely informational -- no flags are mutated
# here.
if animate in ("spin", "serial", "morph", "window"):
_ignored_trail_flags = [
(_name, [i for i, v in enumerate(_flags) if v])
for _name, _flags in (
("chemtrails", chemtrails),
("precog", precog),
("bullettime", bullettime),
)
]
_ignored_trail_flags = [
(name, idxs) for name, idxs in _ignored_trail_flags if idxs
]
if _ignored_trail_flags:
_detail = ", ".join(
f"{name} for datasets {idxs}" for name, idxs in _ignored_trail_flags
)
warnings.warn(
f"animate={animate!r} does not support trail styles; "
f"ignoring {_detail}",
UserWarning,
stacklevel=external_stacklevel(),
)
# names= (QC 2026-07): per-DATASET names, distinct from per-point `labels=`
# (text call-outs on individual observations) and the `legend=True`
# auto-numbering. Each name labels its dataset's trace and turns the legend
# on, so `hyp.plot([raw, a, b, c], names=['raw','a','b','c'], ...)` shows a
# legend naming the four datasets. Resolved BEFORE the legend block below so
# it wins over a bare legend=True; explicit conflicting values raise.
if names is not None:
names = list(names)
if _hue_regrouped_counts is not None:
# a categorical hue stacks and REGROUPS the data by category,
# so the drawn traces are hue groups, not the input datasets
# names= labels -- previously this surfaced as a misleading
# "names must have one entry per dataset (<group count>)"
# error, or silently labeled category groups with dataset
# names whenever the counts coincided (F02-009).
_nd, _ng = _hue_regrouped_counts
raise ValueError(
"names= assigns one name per input dataset, but hue= "
f"regrouped the data into hue groups ({_nd} dataset(s) -> "
f"{_ng} hue group(s)), so per-dataset names cannot apply. "
"Label the hue groups with legend=[...] (one entry per "
"group, in first-appearance order) instead, or drop hue=.")
if len(names) != len(xform):
raise ValueError(
f"names must have one entry per dataset ({len(xform)}); got "
f"{len(names)}")
if isinstance(legend, (list, tuple)):
raise ValueError(
"pass dataset names via names= OR a legend= list, not both")
legend = names
# handle legend
if legend is not None:
if legend is False:
legend = None
elif legend is True and hue is not None:
if hue_group_labels is not None:
# categorical string hue: show the ORIGINAL category names,
# not the integer group ids `hue` was reassigned to above
# ('_nolegend_' placeholders keep unnamed None-entry groups
# out of the legend while matching the trace count).
legend = list(hue_group_labels)
else:
legend = [item for item in
sorted(set(hue), key=list(hue).index)]
elif legend is True and hue is None:
legend = [i + 1 for i in range(len(xform))]
# a legend LIST must carry one entry per drawn trace -- checked
# here (naming legend=, the kwarg the user actually passed) rather
# than letting parse_kwargs report a mismatch on the internal
# 'label' kwarg (F10-010).
if isinstance(legend, (list, tuple)) and len(legend) != len(xform):
raise ValueError(
f"legend= was given as a list of length {len(legend)}, "
f"but there are {len(xform)} dataset(s)/group(s) to plot; "
"pass one entry per drawn dataset, or legend=True to "
"auto-number them.")
mpl_kwargs["label"] = legend
# colorbar (GH #100): resolve the color-mapping info (continuous hue
# value range + palette, or discrete group colors + labels) now, while
# `hue`/`multicolor_hue`/`xform`/`legend` reflect the FINAL grouping
# decision (post cluster/hue reshape, post legend-label resolution) but
# BEFORE interpolation (which doesn't change the mapping, only the
# point density) -- shared by both the matplotlib and plotly backends.
colorbar_info = _build_colorbar_info(
colorbar, hue, multicolor_hue, cluster, n_clusters, xform,
mpl_kwargs, legend, palette, hue_group_labels=hue_group_labels)
# interpolate if its a line plot. animate='morph' treats every dataset
# as a POINT CLOUD (Hungarian-matched to its neighbors in `morph.py`),
# never as a time-ordered trajectory -- interpolating it here would
# change its point count/order for no benefit and would desync the
# (separately, seed-controlled) morph sampling downstream, so this
# entire step is skipped for it.
# GH #141: marker+line combo styles (e.g. 'o-') must get the SAME
# connecting-line smoothing/interpolation pure line styles (e.g. '-')
# already get -- gated on `has_line_component` (true whenever a line is
# drawn at all) rather than the stricter `is_line` (true only when
# there is NO marker), which previously skipped interpolation entirely
# for any marker+line combo. `raw_xform` keeps a reference to the
# PRE-interpolation per-dataset arrays so markers can still be drawn at
# the true sample points (matplotlib_backend's static plot1D/2D/3D
# split combo styles into a smoothed line artist + a markers-only
# artist using this raw copy); it is carried through the SAME later
# transforms (nan_to_num/center/scale, below) as `xform` so the two
# stay in the same coordinate space. `interp_array`/`interp_array_list`
# return NEW arrays rather than mutating their input, so this reference
# stays valid even after `xform` itself is reassigned below.
raw_xform = list(xform)
pre_interp_point_counts = [xi.shape[0] for xi in xform]
# a per-dataset fmt LIST re-checked against the FINAL trace count (the
# early pre-pipeline check above cannot run when hue=/cluster=/
# MultiIndex regrouping changes the number of drawn traces) -- a
# mismatch used to surface as a bare IndexError from the loop below
# (F01-005/F01-006/F10-003).
if isinstance(fmt, list) and len(fmt) != len(xform):
raise ValueError(
f"fmt was given as a list of length {len(fmt)}, but there are "
f"{len(xform)} trace(s) to draw (the drawn-trace count can "
"differ from the input dataset count when hue=/cluster=/"
"n_clusters= or a MultiIndex regroups the data); pass one "
"format string per drawn trace, or a single fmt string to "
"broadcast it to every trace.")
# STATIC line smoothing is DATA-FAITHFUL (`_interp_static_line`): it
# only ever ADDS points between samples, keeps every original sample
# (including the final one) as a drawn vertex, and uses a fixed target
# density -- so duration=/frame_rate= (animation kwargs) no longer
# change static rendering (F01-001/F01-007). ANIMATED plots keep the
# historical frame_rate*duration grid: there the interpolated rows ARE
# the animation's frame-sampling.
if animate == "morph":
pass
elif fmt is None or isinstance(fmt, str):
if has_line_component(fmt):
if any(xi.shape[0] > 1 for xi in xform):
# rows with remaining NaN/inf would crash PCHIP with a bare
# scipy message -- fail fast with a hypertools-level one
# (release-1.0 audit, F05-011)
for _i, _xi in enumerate(xform):
if _xi.shape[0] > 1:
_require_finite_for_line(_xi, _i)
if animate:
# Every multi-row dataset is resampled onto the EXACT
# frame grid (release-1.0 audit): per-dataset
# interpolation (previously the step came from
# xform[0]'s length alone, silently truncating longer
# LATER datasets, F04-003) with exactly
# round(frame_rate * duration) rows (the docstring's
# promised frame count -- the old np.arange step
# produced 901/41 frames for some lengths, F04-004).
# Per-dataset singleton guard (F02-002/F05-012): a
# 1-point dataset (singleton hue category, or a
# reference point plotted beside a trajectory) cannot
# be PCHIP-interpolated (scipy needs >= 2 samples) --
# leave it as-is instead of crashing the whole plot;
# the backend paces it onto the frame grid.
_n_frames = max(2, int(round(frame_rate * duration)))
xform = [xi if xi.shape[0] < 2
else _interp_anim_line(xi, _n_frames)
for xi in xform]
else:
xform = [_interp_static_line(xi) for xi in xform]
elif isinstance(fmt, list):
for idx, xi in enumerate(xform):
if has_line_component(fmt[idx]):
if xi.shape[0] > 1:
# see the F05-011 note above
_require_finite_for_line(xi, idx)
# per-dataset exact frame grid -- see the F04-003/
# F04-004 note in the single-fmt branch above. (The
# historical interp_array_list call here treated the
# 2D array as a LIST of rows, silently replacing the
# dataset with a list of per-row interpolations --
# latent for years because a bug made is_line() always
# False.)
if animate:
xform[idx] = _interp_anim_line(
xi, max(2, int(round(frame_rate * duration))))
else:
xform[idx] = _interp_static_line(xi)
# interpolation adds points, so per-point labels must be re-mapped onto
# the interpolated trajectories (each label lands at its original
# point's new index; in-between points get None)
post_interp_point_counts = [xi.shape[0] for xi in xform]
if labels is not None and post_interp_point_counts != pre_interp_point_counts:
labels = _expand_labels(labels, pre_interp_point_counts,
post_interp_point_counts)
# compute per-point colors for multicolored lines now that trajectories
# have been interpolated (hue values are re-interpolated to match)
line_colors = None
if multicolor_hue is not None:
line_colors = _multicolor_line_colors(
multicolor_hue, pre_interp_lengths, xform, palette,
is_rgb=multicolor_hue_is_rgb)
# handle explore flag (a real ValueError, not an assert -- asserts are
# stripped under `python -O`, F01-016/F10-013)
if explore:
if xform[0].shape[1] != 3:
raise ValueError(
"explore mode is currently only supported for 3-D static "
f"plots; the data being plotted is {xform[0].shape[1]}-D. "
"Pass ndims=3 (the default) to use explore=True.")
# headless/non-interactive backends (Agg in scripts and CI, the
# doc-gallery build, ...) can render the figure but can never fire
# hover events, so explore=True silently degraded to a static plot
# with no hint why nothing pops up (release-1.0 audit,
# D05-gallery-data-text-012).
import matplotlib
_backend_name = matplotlib.get_backend().lower()
if any(_backend_name.endswith(nb) for nb in
("agg", "pdf", "svg", "ps", "template")) \
and not _backend_name.endswith(("qtagg", "tkagg", "gtk3agg",
"gtk4agg", "wxagg",
"macosx")):
warnings.warn(
"explore=True shows labels on hover, which needs an "
"interactive matplotlib backend; the current backend "
f"({matplotlib.get_backend()!r}) is non-interactive, so "
"the figure will be drawn as a static plot without hover "
"labels. Run in an interactive session (or switch "
"backends, e.g. matplotlib.use('QtAgg')) to use explore "
"mode.", UserWarning, stacklevel=external_stacklevel())
mpl_kwargs["picker"] = True
# predict= forecasts were computed per ORIGINAL input dataset; if
# cluster/hue reshaping regrouped `xform` into a different number of
# traces (by category rather than by dataset), the 1:1 correspondence
# no longer holds -- skip drawing forecasts rather than mismatch traces.
if raw_forecasts is not None and len(raw_forecasts) != len(xform):
raw_forecasts = None
# center + scale. When forecasts are drawn, the frame must contain
# EVERYTHING drawn: compute the center/scale statistics from the FULL
# stacked data (observed + forecasts, mirroring the animation principle
# that limits/frame come from the full stacked data) and pass both
# through the SAME transform. Otherwise forecasts that extend beyond
# the observed data's range map outside [-1, 1] and render past the
# square/cube frame (axes are off, so nothing clips them).
# GH #141: `raw_xform` (the pre-interpolation sample points, used to
# draw markers at their TRUE locations for marker+line combo styles --
# see the interpolation block above) must land in the EXACT same
# coordinate space as `xform`, so it is carried through the identical
# center/scale statistics computed from `xform` (+ forecasts, when
# present) below -- never its OWN, independently-computed stats.
if raw_forecasts is not None:
_joint = np.vstack([np.vstack(xform), np.vstack(raw_forecasts)])
_mean = np.mean(_joint, 0)
xform = [xi - _mean for xi in xform]
raw_forecasts = [fc - _mean for fc in raw_forecasts]
raw_xform = [xi - _mean for xi in raw_xform]
_joint = np.vstack([np.vstack(xform), np.vstack(raw_forecasts)])
_m1 = np.min(_joint)
_m2 = np.max(_joint - _m1) or 1.0 # degenerate (constant) data has
# zero range: dividing by it emitted an 'invalid value encountered
# in divide' RuntimeWarning and produced NaNs (release-1.0 audit,
# C2 residual warnings); constant data maps to a finite fixed
# position instead
_rescale = lambda a: 2 * (np.divide(a - _m1, _m2)) - 1
xform = [_rescale(xi) for xi in xform]
raw_forecasts = [_rescale(fc) for fc in raw_forecasts]
raw_xform = [_rescale(xi) for xi in raw_xform]
else:
# no forecasts: identical to the historical center()/scale() path,
# but with the SAME stats also applied to raw_xform (rather than
# calling center()/scale() a second time on raw_xform, which would
# compute DIFFERENT stats from raw_xform's own, possibly narrower,
# pre-interpolation range).
_stacked = np.vstack(xform)
_mean = np.mean(_stacked, 0)
xform = [xi - _mean for xi in xform]
raw_xform = [xi - _mean for xi in raw_xform]
_stacked = np.vstack(xform)
_m1 = np.min(_stacked)
_m2 = np.max(_stacked - _m1) or 1.0 # zero range (e.g. a single
# observation reduced to zeros, or constant data) -> a finite
# fixed position, instead of a divide-by-zero RuntimeWarning +
# NaNs (release-1.0 audit, C2 residual warnings)
_rescale = lambda a: 2 * (np.divide(a - _m1, _m2)) - 1
xform = [_rescale(xi) for xi in xform]
raw_xform = [_rescale(xi) for xi in raw_xform]
# handle palette with seaborn
import seaborn as sns
if isinstance(palette, np.bytes_):
palette = palette.decode("utf-8")
# a bare (r, g, b[, a]) tuple/list of floats is a SINGLE matplotlib
# color (F10-004), not a per-dataset list -- broadcast it to every
# dataset before parse_kwargs' per-dataset list handling sees it.
_color_val = mpl_kwargs.get("color")
if (isinstance(_color_val, (list, tuple))
and len(_color_val) in (3, 4)
and all(isinstance(v, (int, float, np.integer, np.floating))
and not isinstance(v, bool) and 0 <= v <= 1
for v in _color_val)):
mpl_kwargs["color"] = [tuple(_color_val)] * len(xform)
# turn kwargs into a list
kwargs_list = parse_kwargs(xform, mpl_kwargs)
# GH #206: arbitrary extra matplotlib-style kwargs (anything not one
# of plot()'s own named parameters, e.g. `zorder=`, `dashes=`,
# `alpha=`, `markeredgecolor=`) are merged in AFTER the named/internal
# style kwargs above (`_apply_extra_kwargs` never overwrites a key
# already set), verbatim -- no per-dataset list broadcasting is
# attempted for these (see `_apply_extra_kwargs`'s docstring for why).
_apply_extra_kwargs(kwargs_list, kwargs)
def _resolve_dataset_colors():
"""Resolve each dataset's OWN drawn color: an explicit color/colors
kwarg if given (already in `kwargs_list`), or -- if none was given --
the same per-dataset palette-cycle color both backends fall back to
(matplotlib via `sns.set_palette` below; plotly via the `sns_local`
fallback a few lines down). Shared by `surface_colors` (GH #109) and
`density_colors` (GH #108/#191): both need the exact color each
dataset will actually be drawn in on EITHER backend, resolved
identically."""
import matplotlib.colors as _mcolors
if "color" in mpl_kwargs:
_base_colors = [kwargs_list[i].get("color")
for i in range(len(xform))]
elif line_colors is not None:
# hue= is set: use each dataset's MEAN per-point hue color as its
# representative color, so surface=/density=/morph honor hue instead
# of falling back to the palette cycle (QC 2026-07: surface=True
# ignored hue -- the hull/mesh drew in a palette color while the
# points were hue-colored).
return [tuple(np.asarray(lc, dtype=float).mean(axis=0)[:3])
for lc in line_colors]
else:
_base_colors = list(sns.color_palette(
_seaborn_palette_arg(palette, len(xform)), len(xform)))
return [
_mcolors.to_rgb(c) if c is not None
else _mcolors.to_rgb(f"C{i % 10}")
for i, c in enumerate(_base_colors)
]
# surface= (GH #109): resolve each dataset's OWN drawn color now (used
# when a dataset's surface spec has color=None, i.e. "inherit").
surface_colors = (_resolve_dataset_colors()
if surface_list is not None else None)
# surface= per-vertex coloring (QC 2026-07): when hue is set, color each
# surface hull VERTEX by an inverse-distance-weighted blend of the enclosed
# points' hue colors (meshutil.vertex_colors_from_points) rather than one
# flat mean color (the old behavior painted the whole hull the average of
# the points' colors -- e.g. gray for a rainbow hue). Bundle each dataset's
# (points, per-point RGB); None where a dataset has no surface, no per-point
# hue colors, or an EXPLICIT surface color= was given (an explicit color
# wins over the inferred hue -- otherwise it would be silently ignored),
# in which case surface_colors' flat color is used.
def _surface_inherits_color(i):
spec = surface_list[i] if i < len(surface_list) else None
return spec is not None and spec.get('color') is None
if surface_list is not None and line_colors is not None:
surface_point_colors = [
(np.asarray(xform[i])[:, :3], np.asarray(line_colors[i])[:, :3])
if _surface_inherits_color(i) else None
for i in range(len(xform))
]
else:
surface_point_colors = None
# density= (GH #108/#191): resolve each dataset's OWN drawn color the
# SAME way as surface_colors above (density has no color-override key,
# so this is always what gets drawn, per_group=True case only -- the
# per_group=False pooled layer uses a fixed neutral gray instead).
density_colors = (_resolve_dataset_colors()
if density_list is not None else None)
# animate='morph': resolve each dataset's OWN drawn color the SAME way
# as surface_colors/density_colors above -- the traveling morph cloud's
# color is a linear RGB interpolation between two datasets' OWN colors
# (see `hypertools.plot.morph.morph_color`), so both backends need
# every dataset's resolved color regardless of whether surface=/
# density= were requested.
morph_colors = (_resolve_dataset_colors()
if morph_tags is not None else None)
# handle format strings
if fmt is not None:
if not isinstance(fmt, list):
draw_fmt = [fmt for i in xform]
else:
# COPY the caller's list: the matplotlib backend rewrites
# single-point line entries to '.' in place, which must not
# leak back into the user's own fmt list
# (X6-code-org-plot-008)
draw_fmt = list(fmt)
else:
# sized from the FINAL trace count -- `x` is the ORIGINAL input,
# whose length differs after hue=/cluster= regrouping (F01-005:
# fmt=None + hue on a list input crashed with a bare IndexError)
draw_fmt = ["-"] * len(xform)
# convert all nans to zeros
for i, xi in enumerate(xform):
xform[i] = np.nan_to_num(xi)
raw_xform = [np.nan_to_num(xi) for xi in raw_xform]
if raw_forecasts is not None:
raw_forecasts = [np.nan_to_num(fc) for fc in raw_forecasts]
# interactive (plotly) backend: render with plotly and skip the
# matplotlib pipeline entirely. backend='auto' resolves to plotly only
# on Colab/Kaggle (see hypertools.plot.plotly_backend for the policy).
if resolve_backend(backend) == "plotly":
from .plotly_backend import plotly_draw
# GH #206: warn (once, listing every offending kwarg) about extra
# kwargs that reached `mpl_kwargs` (via the `**kwargs` passthrough
# above) but that the plotly backend has no property to map them
# onto -- checked against the RAW `kwargs` the caller passed
# (rather than `mpl_kwargs`, which also holds plotly-supported
# named params like `color=`/`linewidth=`), so only genuinely
# unmappable extras are reported.
_unmapped_plotly_kwargs = sorted(set(kwargs) - _PLOTLY_MAPPED_KWARGS)
if _unmapped_plotly_kwargs:
warnings.warn(
f"backend='plotly' cannot map the following extra "
f"kwarg(s) to a trace property and will ignore them: "
f"{_unmapped_plotly_kwargs}. Supported passthrough "
f"kwargs for plotly are: {sorted(_PLOTLY_MAPPED_KWARGS)}."
, stacklevel=external_stacklevel())
if "color" not in mpl_kwargs:
import seaborn as sns_local
mpl_kwargs = dict(mpl_kwargs)
mpl_kwargs["color"] = sns_local.color_palette(
_seaborn_palette_arg(palette, len(xform)), len(xform))
kwargs_list = parse_kwargs(xform, mpl_kwargs)
_apply_extra_kwargs(kwargs_list, kwargs)
fig = plotly_draw(
xform,
fmt=draw_fmt,
kwargs_list=kwargs_list,
labels=labels,
legend=legend,
title=title,
animate=animate,
size=size,
show=show,
save_path=save_path,
frame_rate=frame_rate,
duration=duration,
rotations=rotations,
elev=elev,
azim=azim,
point_colors=line_colors,
tail_duration=tail_duration,
focused=resolved_focused,
chemtrails=chemtrails,
precog=precog,
bullettime=bullettime,
zoom=zoom,
forecasts=raw_forecasts,
colorbar_info=colorbar_info,
surface=surface_list,
surface_colors=surface_colors,
surface_point_colors=surface_point_colors,
density=density_list,
density_colors=density_colors,
morph_tags=morph_tags,
morph_colors=morph_colors,
morph_samples=morph_samples,
font=_artist_font,
font_extra=_plotly_font_extra,
label_alpha=resolved_label_alpha,
xlabel=xlabel,
ylabel=ylabel,
zlabel=zlabel,
)
ax = None
data = xform
line_ani = None
else:
# Apply the hypertools palette/style only for the duration of this
# plot call. Previously sns.set_palette/sns.set_style mutated global
# matplotlib rcParams as a side effect of plotting (GH issue #259);
# rc_context restores the user's settings on exit. The figure's axes
# and artists are created inside the context, so they keep the
# hypertools styling.
with plt.rc_context():
sns.set_palette(
palette=_seaborn_palette_arg(palette, len(xform)),
n_colors=len(xform))
sns.set_style(style="whitegrid")
# Font, applied AFTER sns.set_style (which sets its own font
# rcParams). A LIST gives matplotlib >= 3.6 PER-GLYPH fallback, so
# text mixing scripts renders fully instead of showing "tofu" for
# whatever the single active face lacks -- and an rcParam also
# covers text hypertools never touches directly (tick labels, and
# anything the user adds to the returned axes). Scoped by
# rc_context, so the user's global rcParams are intact.
#
# An EXPLICIT font= is made primary; an AUTO-detected font (which
# `resolve_font` only returns to fill a real coverage GAP -- e.g. a
# script no stack family has) is appended as a FALLBACK so the
# bundled Noto Sans stays the primary face and a stray accent/Greek
# letter never swaps the whole plot onto a platform font
# (maintainer font review).
if resolved_font is None:
_font_stack = sans_serif_stack()
elif font is not None:
_font_stack = sans_serif_stack(first=resolved_font.get_name())
else:
_font_stack = sans_serif_stack(extra=resolved_font.get_name())
# BOTH keys: artists created with an explicit generic
# `family='sans-serif'` (seaborn's style, and matplotlib's own
# default) resolve through `font.sans-serif`, while artists that
# inherit the rcParam resolve through `font.family` -- setting only
# one leaves the other resolving through matplotlib's stock list.
plt.rcParams['font.family'] = _font_stack
plt.rcParams['font.sans-serif'] = _font_stack
# draw the plot
fig, ax, data, line_ani = _draw(
xform,
fmt=draw_fmt,
kwargs_list=kwargs_list,
labels=labels,
legend=legend,
title=title,
animate=animate,
raw_data=raw_xform,
duration=duration,
tail_duration=tail_duration,
focused=resolved_focused,
rotations=rotations,
zoom=zoom,
chemtrails=chemtrails,
precog=precog,
bullettime=bullettime,
frame_rate=frame_rate,
elev=elev,
azim=azim,
explore=explore,
show=show,
size=size,
ax=ax,
frame_kwargs=frame_kwargs,
surface=surface_list,
surface_colors=surface_colors,
surface_point_colors=surface_point_colors,
density=density_list,
density_colors=density_colors,
morph_tags=morph_tags,
morph_colors=morph_colors,
morph_samples=morph_samples,
font=_artist_font,
label_alpha=resolved_label_alpha,
xlabel=xlabel,
ylabel=ylabel,
zlabel=zlabel,
)
# predict=: overlay one dashed, low-opacity (alpha 0.6) forecast
# trace per input dataset (GH #169), in the SAME color as its
# source line. Added AFTER `_draw` has already built the legend
# (from the original data lines only, via ax.legend() inside
# `_draw`), so these traces never gain a legend entry;
# label='_nolegend_' mirrors the trail-artist precedent
# (matplotlib_backend's animated trails) as a second guard.
if raw_forecasts is not None:
_src_lines = list(ax.lines)
for _i, _fc in enumerate(raw_forecasts):
_fc_color = (_src_lines[_i].get_color()
if _i < len(_src_lines) else None)
_d = _fc.shape[1] if _fc.ndim > 1 else 1
if _d >= 3:
ax.plot(_fc[:, 0], _fc[:, 1], _fc[:, 2],
linestyle='--', color=_fc_color, alpha=0.6,
label='_nolegend_')
elif _d == 2:
ax.plot(_fc[:, 0], _fc[:, 1], linestyle='--',
color=_fc_color, alpha=0.6, label='_nolegend_')
else:
ax.plot(_fc[:, 0], linestyle='--', color=_fc_color,
alpha=0.6, label='_nolegend_')
# exact per-point colors: swap the single-color artists for
# per-segment-colored line collections or per-point-colored
# scatter (the cube/square frame and axes from _draw are kept)
if line_colors is not None:
if (line_ani is not None and animate == 'morph'):
# morph draws its own single traveling artist; the
# static swap below would REMOVE it (and there is no
# per-point correspondence to color) -- warn instead
# of silently destroying the animation (F04-001
# follow-up)
warnings.warn(
"per-point (continuous/matrix) hue coloring is "
"not supported for animate='morph'; drawing the "
"morph with its default colors.",
UserWarning,
stacklevel=external_stacklevel())
elif (line_ani is not None and animate != 'spin'
and has_line_component(fmt)):
# animated reveal styles (parallel/window/serial):
# per-frame multicolor rendering (F04-001/F05-002 --
# the static swap froze the animation; 'spin' keeps
# the static swap below, which is exactly right for a
# camera-only animation). Marker+line combos animate
# as a single line artist (see _draw's raw_data note),
# so they take this path too.
_apply_multicolor_animation(
ax, xform, line_colors, kwargs_list, line_ani,
style=animate, chemtrails=chemtrails,
precog=precog, bullettime=bullettime,
total_frames=max(1, int(round(frame_rate
* duration))))
elif is_line(fmt):
_apply_multicolor_lines(ax, xform, line_colors,
kwargs_list)
elif has_line_component(fmt):
# marker+line combo fmt (e.g. 'o-') with continuous/
# matrix hue (GH #141 x F02-004): keep BOTH components
# -- a multicolored smoothed connecting line PLUS
# per-point-colored markers at the TRUE (pre-
# interpolation) sample points, mirroring the no-hue
# combo rendering. Previously the line was silently
# dropped and a marker was scattered at every
# interpolated point (~45x more "data points" than
# exist).
_apply_multicolor_lines(ax, xform, line_colors,
kwargs_list)
_marker_colors = _multicolor_line_colors(
multicolor_hue, pre_interp_lengths, raw_xform,
palette, is_rgb=multicolor_hue_is_rgb)
_apply_multicolor_markers(ax, raw_xform, _marker_colors,
kwargs_list, fmt=fmt)
else:
_apply_multicolor_markers(ax, xform, line_colors,
kwargs_list, fmt=fmt)
# tighten layout (static plots only: animated axes are given
# the full canvas so rotating zoomed cubes don't clip, and
# tight_layout would shrink them back into subplot margins)
if not animate:
plt.tight_layout()
# colorbar (GH #100): built once, here, from the (frame-
# independent) color mapping. For animated plots this is never
# touched by the per-frame update callbacks, so it stays static
# across every frame. Added BEFORE the legend fit below so that
# fit accounts for whatever room/reposition the colorbar just
# consumed -- a 'left'/'top'/'bottom' colorbar reshapes `ax`
# via matplotlib's own `make_axes` machinery, which can discard
# an EARLIER legend fit; fitting the legend last, against
# whatever the current layout actually is, sidesteps that.
if colorbar_info is not None and ax is not None:
_add_colorbar(fig, ax, colorbar_info, font=_artist_font)
# legend fitting (GH #100/#95 follow-up): a right-side (outside)
# legend can overflow the figure's right edge. `tight_layout`
# reserves room for it on 2D axes but NOT on 3D axes, and
# neither accounts for a colorbar sharing that edge (location=
# 'right') or reshaping `ax` (location='left'/'top'/'bottom').
# This previously only ran for STATIC plots, leaving the legend
# fully clipped whenever animate=True (the legend is added by
# `_draw` above regardless of `animate`, and is static across
# every animation frame, so fitting it once here -- exactly like
# the colorbar above -- is enough; no per-frame work needed).
if legend is not None and ax is not None:
_fit_right_legend(fig, ax)
# save. `fig.savefig`, NOT `plt.savefig` (release-1.0 audit,
# F09-001: `plt.savefig` writes pyplot's CURRENT figure, so
# with a user-supplied ax= whose figure was not current the
# exported file silently contained the WRONG figure). The
# except-branch keeps a failing save (bad extension,
# permissions, ...) from leaking the already-drawn figure into
# pyplot's manager when the caller asked for show=False
# (F09-006) -- the cleanup mirrors the normal-path close below.
if save_path is not None:
try:
if animate:
_save_animation(line_ani, save_path, frame_rate)
else:
fig.savefig(save_path)
except Exception:
if (not show and not _user_supplied_ax
and isinstance(fig, plt.Figure)):
plt.close(fig)
# the exception propagates before the HyperAnimation
# wrapper is ever constructed, so its __del__ silencing
# (X4-warnings-012) can never run for this abandoned,
# never-rendered FuncAnimation -- without this it warned
# "Animation was deleted without rendering anything" at
# the next cyclic-gc pass, misattributed to whatever code
# ran later (release-1.0 audit, zero-warnings sweep).
if animate and line_ani is not None:
from .hyper_animation import mark_draw_started
mark_draw_started(line_ani)
raise
# Return shape (Jeremy decision #2):
# - static (matplotlib or plotly): return the Figure alone
# - animated matplotlib: return (fig, line_ani) so the caller can keep
# a reference to the FuncAnimation (needed to keep it alive); ax is
# recoverable as fig.axes[0], so it needs no separate return slot
# - animated plotly: frames are embedded in the Figure, so return fig
# - return_model=True: return a dict bundle exposing the analyzed
# xform_data plus the reduce/align/cluster model specs
# GH #148: show=False must also remove the figure from pyplot's global
# manager. `plt.ioff()` alone leaves it registered, so Jupyter's post-cell
# flush_figures() still displays it (and a later plt.show() re-draws it).
# Closing deregisters it; the returned Figure stays valid and savable.
# Skip when the user supplied their own `ax` (their figure to manage) and
# skip plotly figures (not pyplot-managed). ANIMATED figures are closed
# too (release-1.0 audit, F09-003: every animated show=False call leaked
# one registered pyplot figure, growing without bound in batch-export
# loops); the returned HyperAnimation stays fully usable afterward --
# `.save()`/`.to_jshtml()` drive their frames explicitly on the existing
# canvas, GUI-backed or not (verified on FigureCanvasMac; covered by
# tests/test_plot_save_audit_fixes.py). Closing nulls the FuncAnimation's
# event source but leaves its pending FIRST-DRAW hook connected, and a
# later draw of the returned figure would fire it and dump a spurious
# "'NoneType' object has no attribute 'add_callback'" traceback (the
# historical GUI-backend crash this branch used to dodge by never
# closing animated figures at all) -- so disconnect that hook explicitly;
# Animation.save()/to_jshtml() never need it.
if (not show and not _user_supplied_ax and isinstance(fig, plt.Figure)):
# matplotlib >= 3.11: plt.close() DETACHES the figure's real canvas,
# swapping in a bare FigureCanvasBase (draw() is a no-op and there is
# no buffer_rgba), so the returned figure could no longer render or
# re-save. Re-attach the original canvas after closing -- restoring
# matplotlib <= 3.10's close semantics (canvas kept, figure
# deregistered from pyplot), which is exactly the contract documented
# above: the returned Figure stays valid, savable, and renderable.
_live_canvas = fig.canvas
plt.close(fig)
if fig.canvas is not _live_canvas:
fig.set_canvas(_live_canvas)
if line_ani is not None:
_first_draw_id = getattr(line_ani, '_first_draw_id', None)
if _first_draw_id is not None:
try:
fig.canvas.mpl_disconnect(_first_draw_id)
except Exception:
pass
line_ani._first_draw_id = None
if return_model:
# gather reduce params (spec, not a fitted instance)
if isinstance(reduce, dict):
reduce_dict = reduce
else:
reduce_dict = {"model": reduce, "params": {"n_components": ndims}}
# gather align params
if isinstance(align, dict):
align_dict = align
else:
align_dict = {"model": align, "params": {}}
# 'pipeline' (GH #227, round17 Task 6): a fitted hypertools.Pipeline
# covering whichever of manip/normalize/reduce/align/cluster ran,
# so `hyp.plot(B, pipeline=bundle['pipeline'])` reuses these exact
# fitted parameters on new data instead of refitting. When the
# caller passed pipeline= themselves (reuse case), that SAME
# (already-fitted) Pipeline is reused here too. When the caller
# supplied transform= directly (bypassing format_data/analyze
# entirely, so there is no `raw` this pipeline could have been fit
# on), no pipeline can be reconstructed. resample= sugar is NOT
# represented as a pipeline step (it is applied to `raw` before
# this pipeline is fit, mirroring how format_data itself is not a
# step either), so reusing this pipeline on new data does not
# re-apply resample=.
if pipeline is not None:
bundle_pipeline = pipeline
elif raw is not None:
from ..core.pipeline import build_pipeline
# the cluster stage reuses the EXACT resolved spec the
# figure's own cluster stage was built from (set in the
# cluster branch above; None when no clustering ran) --
# previously this path re-resolved the raw cluster= spec with
# cluster.cluster()'s n_clusters=3 default, so the bundled
# pipeline could encode a different cluster count/parameters
# than the published figure (F13-004), and the n_clusters=-
# only KMeans path was omitted from the pipeline entirely.
cluster_spec = _bundle_cluster_stage
# LOW (accepted tradeoff): this refits manip/normalize/reduce/
# align/cluster a second time on `raw`, duplicating the work
# already done above to produce `xform_data` for the figure --
# kept because it is the only way to hand back a genuinely
# fit-once-reusable `Pipeline` object (see the `pipeline=`
# discussion above) without threading a Pipeline out of every
# internal code path that can produce `xform_data`.
bundle_pipeline = build_pipeline(manip=manip, normalize=normalize,
reduce=reduce, ndims=ndims,
align=align, cluster=cluster_spec)
# this refit re-resolves the SAME reduce spec the figure was
# drawn with, so any spec-conflict warning it emits (e.g.
# "Unequal values passed to dims and n_components" when a
# pre-configured reduce instance's n_components differs from
# ndims) was ALREADY issued once by the analyze() call above --
# suppress the duplicate (release-1.0 audit, R1).
with warnings.catch_warnings():
warnings.filterwarnings(
'ignore',
message='Unequal values passed to dims and '
'n_components',
category=UserWarning)
bundle_pipeline.fit_transform(raw)
else:
bundle_pipeline = None
# the bundle hands back the RAW FuncAnimation (never a
# HyperAnimation), so the X4-warnings-012 __del__ silencing never
# applies to it; without this, discarding the bundle leaked
# matplotlib's "Animation was deleted without rendering anything"
# UserWarning at the next cyclic-gc pass (release-1.0 audit,
# zero-warnings sweep; see hyper_animation.mark_draw_started).
if line_ani is not None:
from .hyper_animation import mark_draw_started
mark_draw_started(line_ani)
return {
"fig": fig,
"xform_data": xform_data,
"animation": line_ani,
"pipeline": bundle_pipeline,
"models": {
"reduce": reduce_dict,
"align": align_dict,
"cluster": cluster,
"impute": impute,
},
"predict": None if predict is None else {
"model": predict,
"params": {"t": t},
"forecasts": bundle_forecasts,
},
}
# only animated matplotlib plots set line_ani; plotly and static plots
# leave it None. An animated plot returns a HyperAnimation (QC 2026-07): a
# single object exposing .to_html5_video()/.to_jshtml()/.save()/.figure that
# auto-plays inline in a notebook -- so `anim = hyp.plot(data, animate=...)`
# then `anim.to_html5_video()` works (it used to fail on the bare tuple).
# HyperAnimation still unpacks as the legacy (figure, animation) tuple, so
# `fig, anim = hyp.plot(...)` keeps working too.
if line_ani is not None:
from .hyper_animation import HyperAnimation
return HyperAnimation(fig, line_ani)
return fig
def _build_colorbar_info(colorbar, hue, multicolor_hue, cluster, n_clusters,
xform, mpl_kwargs, legend, palette,
hue_group_labels=None):
"""Resolve `colorbar=` into a backend-agnostic color-mapping dict, or
None if no colorbar was requested (GH #100).
Returns a dict with key ``'kind'`` of:
- ``'continuous'``: ``vmin``/``vmax`` (the ACTUAL hue value range) and
``palette`` -- the caller builds a `ScalarMappable` from
`continuous_colormap(palette)` + `Normalize(vmin, vmax)`, which is
guaranteed to match `_multicolor_line_colors`'s per-point colors
(same palette, same `mat2colors` default `n_bins`).
- ``'discrete'``: ``colors`` ((n, 3) array, ORDER matching the drawn
groups) and ``labels`` (tick labels, from `legend` if it is a list,
else from `hue_group_labels` -- the categorical hue's category names,
known whether or not the user ALSO asked for a legend (F02-007) --
else ``1..n``).
Both kinds also carry the user-facing ``label``/``ticks``/``location``
overrides (from the `colorbar` dict; see `plot`'s docstring).
Raises ``ValueError`` if `colorbar` was requested but there is no
color mapping to show (a single, ungrouped dataset), or the mapping is
a per-observation blend with no discrete grouping (an unbounded color
space -- nothing finite to put on a colorbar).
"""
if colorbar is None:
return None
label = colorbar.get('label')
ticks = colorbar.get('ticks')
location = colorbar.get('location', 'right')
if multicolor_hue is not None and multicolor_hue.ndim == 1 and hue is None:
vals = np.asarray(multicolor_hue, dtype=np.float64)
return {
'kind': 'continuous',
'vmin': float(np.min(vals)),
'vmax': float(np.max(vals)),
'palette': palette,
'label': label,
'ticks': ticks,
'location': location,
}
if multicolor_hue is not None:
raise ValueError(
"colorbar is not supported for per-observation matrix/mixture"
"-blended hue without discrete grouping (colors vary "
"continuously over an unbounded blend space, so there is no "
"finite set of colors to show). Use a 1D continuous hue, or "
"combine with cluster= (with animate=True, which quantizes "
"the blend into discrete groups) instead."
)
n_groups = len(xform)
if n_groups <= 1 and hue is None and cluster is None and n_clusters is None:
raise ValueError(
"colorbar=True requires a color mapping (hue=, cluster=, or "
"n_clusters=): a single, ungrouped dataset renders in one "
"color, so there is nothing to map on a colorbar."
)
explicit_colors = mpl_kwargs.get('color')
if (isinstance(explicit_colors, (list, tuple))
and len(explicit_colors) == n_groups):
# the mixture-blend paths (cluster=<mixture model> or matrix hue,
# animated) set an EXPLICIT per-group color list -- reuse it
# verbatim so the colorbar swatches exactly match the drawn lines.
colors = np.asarray(explicit_colors)[:, :3]
else:
# everything else (categorical hue, non-mixture cluster/n_clusters,
# or a plain list of datasets) is colored from the ambient palette
# in dataset/group order -- exactly what sns.set_palette (mpl) /
# the per-trace sns.color_palette (plotly) assign when drawing.
colors = get_palette_colors(palette, n_groups)
if isinstance(legend, list):
labels = list(legend)
elif (hue_group_labels is not None
and len(hue_group_labels) == n_groups):
# categorical hue: the category names are known even without
# legend=True -- previously the colorbar fell back to 1..n unless
# a (redundant) legend was also requested (F02-007)
labels = list(hue_group_labels)
else:
labels = [i + 1 for i in range(n_groups)]
# A trace labeled '_nolegend_' (e.g. every MultiIndex leaf and
# intermediate-level mean, GH #95 -- only the TOP-level mean of each
# group carries a real label) must NEVER appear on the colorbar: filter
# colors/labels down to the REAL (legend-worthy) entries together, so a
# 2-level MultiIndex DataFrame (8 leaves + 2 top-level means) renders 2
# colorbar segments (one per top-level group), not 10.
if '_nolegend_' in labels:
keep = [i for i, l in enumerate(labels) if l != '_nolegend_']
if not keep:
raise ValueError(
"colorbar=True requires at least one labeled group, but "
"every trace is unlabeled ('_nolegend_') -- there is no "
"finite set of named groups to show."
)
colors = np.asarray(colors)[keep]
labels = [labels[i] for i in keep]
return {
'kind': 'discrete',
'colors': colors,
'labels': labels,
'label': label,
'ticks': ticks,
'location': location,
}
def _apply_font_to_colorbar(cbar, font):
"""Apply the resolved `font=` (GH #205) to every text surface a
colorbar draws: its tick labels (on whichever axis is the "long" one
for its orientation -- x for a horizontal top/bottom colorbar, y for a
vertical left/right one) and its axis label. Applied unconditionally
to BOTH axes (harmless -- the unused one has no text) rather than
branching on orientation, which keeps this correct regardless of
location=. A no-op when `font` is None (no override requested/needed)."""
if font is None:
return
for lbl in list(cbar.ax.get_xticklabels()) + list(cbar.ax.get_yticklabels()):
lbl.set_fontproperties(font)
cbar.ax.xaxis.label.set_fontproperties(font)
cbar.ax.yaxis.label.set_fontproperties(font)
def _add_colorbar(fig, ax, colorbar_info, font=None):
"""Attach a matplotlib colorbar built from `_build_colorbar_info`'s
output to `fig`/`ax` (GH #100): a continuous `ScalarMappable` for
continuous hue, or a `BoundaryNorm`-segmented one (one block per
group, tick labels = group names) for discrete groups."""
from matplotlib.cm import ScalarMappable
from matplotlib.colors import BoundaryNorm, ListedColormap, Normalize
default_tick_labels = None
if colorbar_info['kind'] == 'continuous':
cmap = continuous_colormap(colorbar_info['palette'])
norm = Normalize(vmin=colorbar_info['vmin'],
vmax=colorbar_info['vmax'])
ticks = colorbar_info['ticks']
else:
n = len(colorbar_info['colors'])
cmap = ListedColormap(colorbar_info['colors'])
norm = BoundaryNorm(np.arange(n + 1) - 0.5, n)
ticks = (colorbar_info['ticks'] if colorbar_info['ticks'] is not None
else list(range(n)))
default_tick_labels = colorbar_info['labels']
mappable = ScalarMappable(norm=norm, cmap=cmap)
mappable.set_array([])
tick_kwargs = {} if ticks is None else {'ticks': ticks}
# the FINAL tick label strings/title -- these can be much wider than the
# numeric default tick labels matplotlib would otherwise draw (e.g. group
# names), so they must be applied BEFORE any pixel-measurement-based
# width fitting runs (see `_add_right_colorbar`), not after.
ticklabels = (None if default_tick_labels is None or colorbar_info['ticks']
is not None else [str(l) for l in default_tick_labels])
label = colorbar_info['label']
if colorbar_info['location'] == 'right':
cbar = _add_right_colorbar(fig, ax, mappable, ticklabels=ticklabels,
label=label, font=font, **tick_kwargs)
else:
cbar = fig.colorbar(mappable, ax=ax,
location=colorbar_info['location'],
**tick_kwargs)
if ticklabels is not None:
cbar.set_ticklabels(ticklabels)
if label:
cbar.set_label(label)
_apply_font_to_colorbar(cbar, font)
# A VERTICAL discrete colorbar ('right'/'left') must read top-to-bottom
# in the SAME order as the legend (first group at the TOP) -- matplotlib's
# default low-value-at-bottom convention otherwise reverses it relative
# to the legend, which reads first-to-last top-to-bottom (GH #100
# follow-up). `invert_yaxis` only flips the DISPLAY orientation of the
# existing data-to-color mapping -- the same data value still maps to
# the same color, so color<->label pairing survives the flip exactly;
# it just moves group 0 from the bottom to the top. A HORIZONTAL
# discrete colorbar ('top'/'bottom') already reads left-to-right in
# legend order (matplotlib's default), so it is left untouched.
# Continuous colorbars are numeric and must keep the conventional
# low-at-bottom orientation, so this only applies to 'discrete'.
if (colorbar_info['kind'] == 'discrete'
and colorbar_info['location'] in ('right', 'left')):
cbar.ax.invert_yaxis()
return cbar
def _tight_right_edge_in(fig):
"""The TRUE required figure width (inches) to avoid clipping ANY artist
off the right edge -- e.g. a legend and/or a colorbar's tick labels/axis
label.
Uses `Figure.get_tightbbox`, which computes each artist's actual extent
from its text/renderer metrics independent of the figure's CURRENT
canvas size -- unlike measuring rasterized "inked" pixels (the
previous approach here), which silently UNDER-reports whenever content
already overflows the current canvas: pixels that fall outside the
canvas are simply never in the rendered buffer, so a rasterize-based
fit that only closes the gap by a small fixed margin each iteration
converges far too slowly for long labels (needing many more than
`max_iter` steps -- GH #100 follow-up) and can still leave content
clipped. Measuring the true extent directly lets every caller compute
the exact required width in one shot.
hypertools draws inside a seaborn `rc_context`, but the figure is
actually rendered downstream (the sphinx-gallery scraper, or a bare
savefig/display after `plot()` returns) under the RESTORED default
rcParams, whose font is WIDER than seaborn's. Measuring under the
seaborn font would make content look like it fits when it clips in the
real output, so this always measures under `matplotlib.rcParamsDefault`
to match what actually gets saved/displayed.
"""
import matplotlib
from matplotlib.backends.backend_agg import FigureCanvasAgg
with plt.rc_context(matplotlib.rcParamsDefault):
canvas = FigureCanvasAgg(fig)
canvas.draw()
renderer = canvas.get_renderer()
return float(fig.get_tightbbox(renderer).x1)
def _legend_right_edge_in(fig, legend):
"""The legend's right edge (inches from the figure's left edge),
measured under the restored default rcParams exactly like
`_tight_right_edge_in` (see its docstring for why rcParamsDefault) --
but for the LEGEND artist alone, so unclipped animated data/trail
artists whose projected extent overshoots the canvas cannot inflate
the legend fit (release-1.0 audit, F04-002)."""
import matplotlib
from matplotlib.backends.backend_agg import FigureCanvasAgg
with plt.rc_context(matplotlib.rcParamsDefault):
canvas = FigureCanvasAgg(fig)
canvas.draw()
renderer = canvas.get_renderer()
return float(legend.get_window_extent(renderer).x1) / fig.dpi
def _add_right_colorbar(fig, ax, mappable, pad_in=0.2, width_in=0.35,
max_iter=3, ticklabels=None, label=None, font=None,
**colorbar_kwargs):
"""Add `mappable`'s colorbar in a NEW strip to the right of the figure
-- to the right of an existing right-side legend, if any -- widening
the figure (via `_tight_right_edge_in`'s true-extent measurement, then
repositioning `ax` by its unchanged absolute inches so neither the plot
nor an already-fitted legend need to shrink or move).
`ticklabels`/`label` (the FINAL tick label strings and axis label, if
any -- e.g. group names, which can be far wider than the default
numeric tick labels matplotlib would otherwise draw) are applied
IMMEDIATELY after the colorbar is created and BEFORE the width-fitting
pass below runs -- fitting against the (short) default labels and only
swapping in the real ones afterward would fit the wrong content and
leave the real labels clipped.
That fitting pass widens the figure to the exact true extent measured by
`_tight_right_edge_in`, repositioning BOTH `ax` and the new colorbar
axes by their unchanged absolute inches, so nothing is clipped off the
right edge regardless of label length."""
try:
fig.set_layout_engine('none')
except Exception:
pass
right_edge_in = _tight_right_edge_in(fig)
w, h = fig.get_size_inches()
cbar_x0_in = right_edge_in + pad_in
new_w = cbar_x0_in + width_in + pad_in
if new_w > w:
pos = ax.get_position()
left_in, bottom_in = pos.x0 * w, pos.y0 * h
w_in_ax, h_in_ax = pos.width * w, pos.height * h
fig.set_size_inches(new_w, h)
ax.set_position([left_in / new_w, bottom_in / h,
w_in_ax / new_w, h_in_ax / h])
w = new_w
pos = ax.get_position()
cbar_ax = fig.add_axes([cbar_x0_in / w, pos.y0 + pos.height * 0.2,
width_in / w, pos.height * 0.6])
cbar = fig.colorbar(mappable, cax=cbar_ax, **colorbar_kwargs)
if ticklabels is not None:
cbar.set_ticklabels(ticklabels)
if label:
cbar.set_label(label)
# font (GH #205) applied BEFORE the width-fitting pass below so that
# pass measures the ACTUAL (possibly wider, multibyte-covering) font
# -- fitting against the default font first and swapping fonts
# afterward could leave a multibyte label/ticklabel clipped.
_apply_font_to_colorbar(cbar, font)
# second pass: the colorbar's tick labels/title were just drawn and may
# extend past the reserved `width_in` strip -- widen to the exact true
# extent (in one shot, not a fixed-step guess) as needed.
for _ in range(max_iter):
w_cur, h_cur = fig.get_size_inches()
new_w = _tight_right_edge_in(fig) + pad_in
if new_w <= w_cur + 1e-3:
break
ax_pos, cbar_pos = ax.get_position(), cbar_ax.get_position()
ax_left_in, ax_w_in = ax_pos.x0 * w_cur, ax_pos.width * w_cur
cbar_left_in, cbar_w_in = cbar_pos.x0 * w_cur, cbar_pos.width * w_cur
fig.set_size_inches(new_w, h_cur)
ax.set_position([ax_left_in / new_w, ax_pos.y0,
ax_w_in / new_w, ax_pos.height])
cbar_ax.set_position([cbar_left_in / new_w, cbar_pos.y0,
cbar_w_in / new_w, cbar_pos.height])
return cbar
def _fit_right_legend(fig, ax, pad_in=0.15, max_iter=3):
"""Ensure a right-side (outside) legend stays fully within the figure.
hypertools anchors its legend to the RIGHT of the plot via
``ax.legend(loc='center left', bbox_to_anchor=(1.02, 0.5))``.
``tight_layout`` reserves horizontal room for such a legend on 2D axes but
NOT on 3D (Axes3D) axes, and a wide legend (long labels or many entries)
overflows the figure's right edge on either -- the earlier "shrink the
axes" approach hit a floor and gave up, clipping the legend. Instead widen
the figure, adding room only on the right while keeping the plot's absolute
size and position, until the legend's right edge sits inside the canvas.
Fixing the figure itself (rather than a save kwarg) means every downstream
save path AND interactive/notebook display shows the full legend.
Runs for BOTH static and animated plots (GH #100/#95 follow-up: the
legend is added by `_draw` regardless of `animate` and is static across
every animation frame, so a single fit here -- after the figure/axes
already reflect whatever a colorbar in ANY location did to `ax` -- is
enough; no per-frame work needed), and after ANY colorbar has already
been added (see the call site in `plot()`), so this always fits against
the figure's current, final layout rather than being undone by it.
"""
legend = ax.get_legend()
if legend is None:
return
# tight_layout may install a persistent layout engine that re-runs on every
# draw/save and would override the manual set_position below (undoing the
# widening). Freeze it so our figure resizing sticks through savefig.
try:
fig.set_layout_engine('none')
except Exception:
pass
for _ in range(max_iter):
w, h = fig.get_size_inches()
# measure the LEGEND's own right edge, not the full-figure
# tightbbox (release-1.0 audit, F04-002): animated data/trail
# artists are deliberately UNCLIPPED (`set_clip_on(False)`, see
# animate_plot3D's axes-box slicing fix), and a precog/bullettime
# trail holds (nearly) the whole trajectory from the first frame --
# its projected extent can reach far past the canvas, so a
# tightbbox-driven fit ran away to the 3x cap and exported every
# frame with the plot squashed into the left third. Only the legend
# needs room here; for content that genuinely fits the canvas the
# two measurements agree exactly.
new_w = min(_legend_right_edge_in(fig, legend) + pad_in, 3.0 * w)
if new_w <= w + 1e-3:
return # legend already fits
pos = ax.get_position()
left_in, plot_w_in = pos.x0 * w, pos.width * w
# widen (add room only on the right) while keeping the plot's
# absolute size and position, so the legend gains room without
# shrinking the plot.
fig.set_size_inches(new_w, h)
ax.set_position([left_in / new_w, pos.y0,
plot_w_in / new_w, pos.height])
def _flatten_nested(x, _depth=1):
"""Flatten arbitrarily nested lists of datasets (arrays/DataFrames) into
a flat list, recording each leaf's outermost-group index and nesting
depth. Lists containing strings (text data) are returned un-flattened,
since nested string lists denote text corpora, not grouped datasets."""
if _contains_string(x):
return x, None, None
leaves, groups, depths = [], [], []
for outer_idx, el in enumerate(x):
for leaf, depth in _iter_leaves(el, _depth):
leaves.append(leaf)
groups.append(outer_idx)
depths.append(depth)
return leaves, groups, depths
def _iter_leaves(el, depth):
if isinstance(el, list):
for sub in el:
yield from _iter_leaves(sub, depth + 1)
else:
yield el, depth
def _contains_string(el):
if isinstance(el, str):
return True
if isinstance(el, list):
return any(_contains_string(sub) for sub in el)
return False
def _multicolor_line_colors(hue_src, orig_lengths, xform, palette, is_rgb=False):
"""Per-point RGB colors for multicolored lines.
hue_src holds one value (or one row) per ORIGINAL observation; the
trajectories in xform have since been interpolated to a higher temporal
resolution, so each dataset's hue values are linearly re-interpolated to
its new length before color mapping. Colors are mapped over the
CONCATENATED hue values so the scale is shared across datasets.
When `is_rgb` is True, `hue_src` already holds literal per-point RGB values
(e.g. a matrix hue reduced to 3 columns via `plot`'s `color_reduce=`), so
the re-interpolated values are used AS colors instead of being mapped
through `mat2colors`.
Returns a list of (n_i, 3) arrays, one per dataset in xform.
"""
hue_src = np.asarray(hue_src, dtype=np.float64)
if hue_src.ndim == 1:
hue_src = hue_src[:, None]
splits = np.cumsum(orig_lengths)[:-1]
pieces = np.vsplit(hue_src, splits)
interped = []
for piece, xi in zip(pieces, xform):
n_new = xi.shape[0]
if n_new == piece.shape[0]:
interped.append(piece)
continue
old_t = np.linspace(0.0, 1.0, piece.shape[0])
new_t = np.linspace(0.0, 1.0, n_new)
interped.append(np.column_stack(
[np.interp(new_t, old_t, piece[:, c])
for c in range(piece.shape[1])]))
stacked = np.vstack(interped)
if is_rgb:
colors = np.clip(stacked, 0.0, 1.0)
else:
colors = mat2colors(
stacked.ravel() if stacked.shape[1] == 1 else stacked,
palette=palette)
out, start = [], 0
for xi in xform:
out.append(np.asarray(colors[start:start + xi.shape[0]]))
start += xi.shape[0]
return out
def _apply_multicolor_lines(ax, xform, line_colors, kwargs_list):
"""Replace single-color line artists with per-segment-colored
collections (matplotlib backend)."""
from matplotlib.collections import LineCollection
from mpl_toolkits.mplot3d.art3d import Line3DCollection
for line in list(ax.lines):
line.remove()
is_3d = xform[0].shape[1] >= 3
for i, (xi, ci) in enumerate(zip(xform, line_colors)):
tkwargs = kwargs_list[i] if i < len(kwargs_list) else {}
lw = tkwargs.get('linewidth') or plt.rcParams['lines.linewidth']
if xi.shape[1] == 1:
pts = np.column_stack([np.arange(xi.shape[0]), xi[:, 0]])
else:
pts = xi[:, :3] if is_3d else xi[:, :2]
segments = np.stack([pts[:-1], pts[1:]], axis=1)
seg_colors = (ci[:-1] + ci[1:]) / 2.0
if is_3d:
coll = Line3DCollection(segments, colors=seg_colors,
linewidths=lw)
ax.add_collection3d(coll)
else:
coll = LineCollection(segments, colors=seg_colors,
linewidths=lw)
ax.add_collection(coll)
def _apply_multicolor_animation(ax, xform, line_colors, kwargs_list,
line_ani, style, chemtrails, precog,
bullettime, total_frames):
"""Per-frame multicolored (continuous/matrix hue) line rendering for
ANIMATED matplotlib plots (release-1.0 audit, F04-001/F05-002).
The static multicolor path (`_apply_multicolor_lines`) swaps the
single-color line artists for full-trajectory collections -- on an
animated plot that removed the very artists the FuncAnimation updates
each frame and drew the ENTIRE multicolored trajectory statically in
every frame: no reveal, no chemtrails/precog/bullettime, and a 2-D
animation collapsed to a single-frame gif (the plotly backend animated
the identical call correctly). Instead: HIDE the single-color artists
(they keep driving the reveal/window bookkeeping each frame) and add
one initially-empty per-dataset collection for the head window plus one
per trail; then wrap the animation's frame callback so that, after each
original update runs, every collection is re-sliced to exactly the
index window its hidden artist just moved to -- the multicolor
rendering animates in lockstep with the no-hue animation.
Only used for the parallel/'window'/'serial' reveal styles: 'spin'
draws the full trajectory every frame (the static swap is already
correct there), and 'morph' draws its own single traveling artist (the
static swap would have REMOVED it -- callers skip morph entirely).
"""
from matplotlib.collections import LineCollection
from mpl_toolkits.mplot3d.art3d import Line3DCollection
is_3d = xform[0].shape[1] >= 3
n = len(xform)
# artist bookkeeping mirrors matplotlib_backend.animate_plot3D/2D: the
# n head lines are created first, then one trail artist per dataset
# that wants one (parallel/True only -- window/serial never create
# trails), in dataset order.
head_lines = list(ax.lines[:n])
wants_trail = [
style in (True, 'parallel')
and bool(chemtrails[i] or precog[i] or bullettime[i])
for i in range(n)
]
_trail_artists = list(ax.lines[n:n + sum(wants_trail)])
trail_lines = {}
for i in range(n):
if wants_trail[i] and _trail_artists:
trail_lines[i] = _trail_artists.pop(0)
def _linewidth(i):
tkwargs = kwargs_list[i] if i < len(kwargs_list) else {}
return (tkwargs.get('linewidth')
or plt.rcParams['lines.linewidth'])
def _points(i):
xi = xform[i]
if xi.shape[1] == 1:
return np.column_stack([np.arange(xi.shape[0]), xi[:, 0]])
return xi[:, :3] if is_3d else xi[:, :2]
def _make_collection(i, alpha=None):
if is_3d:
coll = Line3DCollection([], linewidths=_linewidth(i),
alpha=alpha)
# autolim=False: the animation already fixed the axes limits
# (cube_scale), and autoscaling an EMPTY collection crashes
# inside Axes3D.add_collection3d
ax.add_collection3d(coll, autolim=False)
else:
coll = LineCollection([], linewidths=_linewidth(i), alpha=alpha)
ax.add_collection(coll)
coll.set_label('_nolegend_')
# match the line artists' unclipping (see animate_plot3D's
# axes-box slicing fix)
coll.set_clip_on(False)
return coll
head_colls = [_make_collection(i) for i in range(n)]
trail_colls = {i: _make_collection(i, alpha=0.3) for i in trail_lines}
for artist in head_lines + list(trail_lines.values()):
artist.set_visible(False)
def _artist_len(artist):
return (len(artist.get_data_3d()[0]) if is_3d
else len(artist.get_xdata()))
def _set_segments(coll, pts, colors):
if pts.shape[0] < 2:
coll.set_segments([])
return
segments = np.stack([pts[:-1], pts[1:]], axis=1)
colors = np.asarray(colors)
coll.set_segments(segments)
coll.set_color((colors[:-1] + colors[1:]) / 2.0)
orig_func = line_ani._func
def _multicolor_frame(num, *fargs):
result = orig_func(num, *fargs)
for i in range(n):
pts = _points(i)
ci = np.asarray(line_colors[i])
n_pts = pts.shape[0]
# the hidden head artist was just set to the exact visible
# window; recover its [start, end) indices from its length
# plus the same frame->row mapping the backend used (see
# matplotlib_backend._anim_window_bounds)
head_len = _artist_len(head_lines[i])
if style == 'serial':
start, end = 0, head_len
else:
end = int(np.ceil((num + 1) * n_pts
/ max(1, int(total_frames))))
end = max(1, min(n_pts, end))
start = max(0, end - head_len)
_set_segments(head_colls[i], pts[start:end], ci[start:end])
trail = trail_lines.get(i)
if trail is not None:
trail_len = _artist_len(trail)
if precog[i] and not (chemtrails[i] or bullettime[i]):
ts, te = n_pts - trail_len, n_pts # anchored at the end
else:
ts, te = 0, trail_len # chemtrails/bullettime: from 0
_set_segments(trail_colls[i], pts[ts:te], ci[ts:te])
return result
line_ani._func = _multicolor_frame
def _expand_labels(labels, old_lengths, new_lengths):
"""Re-map per-point labels onto interpolated trajectories.
Each original point's label is placed at that point's index in the
interpolated (longer) trajectory; the interpolated in-between points get
None (no annotation). When the trajectory was DOWN-sampled instead
(animation frame grids can have fewer points than samples), each label
lands on the nearest remaining point. Accepts flat label lists or lists
nested per dataset; returns a flat list matching sum(new_lengths).
"""
if any(isinstance(el, list) for el in labels):
flat = list(itertools.chain(*labels))
else:
flat = list(labels)
out = []
start = 0
for old_n, new_n in zip(old_lengths, new_lengths):
piece = flat[start:start + old_n]
start += old_n
expanded = [None] * new_n
for i, lab in enumerate(piece):
# only REAL labels claim a slot: assigning the (mostly-None)
# in-between entries too let a later None overwrite an
# already-placed label whenever several original indices
# mapped to the same new index (down-sampling), silently
# dropping the user's labels (release-1.0 audit, F10-001).
if lab is None:
continue
if old_n == 1:
j = 0
else:
j = min(new_n - 1, int(round(i * (new_n - 1) / (old_n - 1))))
expanded[j] = lab
out.extend(expanded)
return out
def _apply_multicolor_markers(ax, xform, point_colors, kwargs_list,
fmt=None):
"""Replace single-color marker artists with per-point-colored scatter
(matplotlib backend). Gives exact per-observation colors -- e.g. mixture
proportions render as true blends instead of quantized groups. When
`fmt` is a single format string carrying a marker character (e.g. the
'o' of 'o-'), that marker glyph is used for the scatter points
(F02-004); otherwise the default circle is drawn."""
for line in list(ax.lines):
line.remove()
marker = None
if fmt is not None and not isinstance(fmt, (list, tuple, np.ndarray)):
_, marker = split_marker_line_fmt(fmt)
marker = marker or 'o'
is_3d = xform[0].shape[1] >= 3
for i, (xi, ci) in enumerate(zip(xform, point_colors)):
tkwargs = kwargs_list[i] if i < len(kwargs_list) else {}
ms = float(tkwargs.get('markersize')
or plt.rcParams['lines.markersize'])
s = ms ** 2 # scatter sizes are areas in points^2
if xi.shape[1] == 1:
ax.scatter(np.arange(xi.shape[0]), xi[:, 0], c=ci, s=s,
marker=marker)
elif is_3d:
ax.scatter(xi[:, 0], xi[:, 1], xi[:, 2], c=ci, s=s,
depthshade=False, marker=marker)
else:
ax.scatter(xi[:, 0], xi[:, 1], c=ci, s=s, marker=marker)
def _mixture_name(model):
"""Registry name for a cluster-model spec (string or class)."""
return model if isinstance(model, str) \
else getattr(model, "__name__", str(model))