hypertools.plot

hypertools.plot(x, fmt='-', marker=None, markers=None, markersize=None, linewidth=None, alpha=None, linestyle=None, linestyles=None, color=None, colors=None, palette='hls', hue=None, color_reduce=None, hue_mode=None, labels=None, names=None, legend=None, colorbar=None, title=None, size=None, elev=10, azim=-60, ndims=3, axis_scale=None, xlim=None, ylim=None, 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, truth=None, save_path=None, animate=False, order=None, duration=30, tail_duration=2, rotations=1, zoom=1, chemtrails=False, precog=False, bullettime=False, forecast_trail=False, forecast_hue=None, forecast_cluster=None, forecast_n_clusters=None, forecast_palette=None, forecast_fmt=None, palette_sort=None, palette_reduce=None, palette_manip=None, palette_normalize=None, palette_align=None, slow_warning_seconds=<default>, frame_rate=30, focused=None, morph_samples=None, loop=False, dataset_fade=None, companion=None, on_frame=None, simplify=True, 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, antialias=True, font=None, label_alpha=None, xlabel=None, ylabel=None, zlabel=None, panels=None, subplots=None, panel_fit='shared', title_kwargs=None, title_color=None, title_wrap=None, label_anchor=None, legend_kwargs=None, legend_colors=None, **kwargs)[source]

Plots dimensionality reduced data and parses plot arguments

Parameters:
xNumpy 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/alpha kwarg is ignored (with a UserWarning) since MultiIndex grouping owns those. legend= is the one overridden kwarg that is HONORED rather than warned away: a list renames the top-level groups (one entry per unique top-level value, in first-appearance order – see legend below) and legend=False suppresses the automatic legend. names= (per-INPUT-DATASET entries) does not apply – one frame is drawn as leaves plus derived means – and raises ValueError pointing at legend=. hue= is superseded with a UserWarning (MultiIndex grouping takes precedence) – on the COLUMN axis a CONTINUOUS hue is instead carried through the hierarchy, described below; 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= is supported since 1.1 (it used to raise): one forecast is computed per FINAL trace – every leaf AND every derived mean, a mean forecast from its own averaged trajectory – so the returned predict['forecasts'] lines up 1:1 with trace_data. It needs at least 2 rows per trace and raises ValueError otherwise – typically for this axis because the innermost index level is unique per row: expansion draws one trace per unique FULL index tuple, so such a frame yields one-row traces (and one-row means). The check runs on the PLOTTED trajectories, so a row-count-changing analysis stage (manip='Resample', a smoother that trims edges) can trigger it too; the ValueError distinguishes the two causes and names the one that applies. 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 single-level (or default RangeIndex) DataFrame, or a plain array/list input, is completely unaffected by any of the above.

A MultiIndex on the COLUMNS (x.columns.nlevels >= 2) is expanded too, since 1.1, under a DIFFERENT rule: the INNERMOST column level is the FEATURE axis and every level above it is the grouping hierarchy. A (Market, Sector, Measure) frame therefore groups by (Market, Sector) – one leaf per sector, each holding that sector’s measurements as its features – and gains one market mean, for 4 traces. Styling, legend, linestyle and the cluster=/ color=/linewidth= rules above all apply unchanged, reading n_levels as the number of GROUPING levels (2 here, not 3). Unlike the row rule, every group keeps all len(x) rows: column grouping never shortens a trace.

predict= works on this axis too, per FINAL trace exactly as described above. Grouping never shortens a trace, so the >= 2-rows-per-trace requirement fails here for one of exactly two reasons, and the ValueError names whichever applies: the INPUT frame has fewer than 2 rows (flattening the columns is deliberately not suggested for that case, since it cannot add a row), or a row-count-changing analysis stage – manip='Resample', a smoother that trims edges – shortened the trajectory between the input and the plot.

One thing follows from that rule and is worth stating plainly. Feature correspondence across groups is by NAME, not by position. The innermost labels are feature identities: every group must carry the same feature labels, and groups are permuted into the first group’s order before analysis, so reordering the columns within a group changes nothing. Duplicate labels inside one group are permitted and matched by (label, occurrence). A hierarchy whose groups hold DIFFERENT labels – one ticker per sector, say – raises ValueError naming the missing and unexpected features, because arbitrary column positions are not corresponding variables merely by being written in the same slot. Make the innermost level shared measurements (return, volatility), reduce each group yourself, or, if position i really does mean the same feature in every group, group them yourself and discard the labels deliberately:

from hypertools.core.hierarchy import group_columns
leaves, _ = group_columns(df, feature_correspondence='position')
hyp.plot([leaf.to_numpy() for leaf in leaves])

That last recipe is a LOWER-LEVEL escape hatch, not positional column-hierarchy plotting, and it is not equivalent to hyp.plot(df): it draws a plain list of datasets, so there are no per-level mean traces, no hierarchy linewidth/alpha/legend styling and no trace_metadata in the return bundle. There is no hierarchy-preserving positional mode in 1.1.

That rule is about correspondence WITHIN a group. The order of the GROUPS themselves is still the order you wrote them, and it reaches the analysis pipeline: groups become datasets, and reduce= row-stacks every dataset and fits ONE model on the stack (a single shared space), so group order IS row order in that stack. A reducer whose fit depends on row order therefore embeds a block-reordered frame differently – including the DEFAULT reduce='IncrementalPCA', which fits by partial_fit over successive minibatches. On a 40-row frame of 4 sector blocks x 5 measures, permuting the BLOCKS gave a DIFFERENT embedding under IncrementalPCA and TSNE, while PCA, TruncatedSVD, FactorAnalysis, Isomap and SpectralEmbedding preserved it up to numerical and sign equivalence (a within-group column permutation is exactly invariant under all of them, per the rule above). No displacement percentage is quoted because it depends on the data, the scikit-learn version and the platform, and a flipped component sign is the same embedding.

This is a property of the shared reduction space rather than of hierarchies – hyp.plot([A, B, C]) and hyp.plot([C, B, A]) differ the same way, and did before 1.1 – so it is DOCUMENTED, not silently worked around. A canonical group order would mean inventing a total ordering over arbitrary, mixed-type, NA-bearing labels, and would make a labeled hierarchy behave differently from the equivalent positional list of datasets. Pass reduce='PCA' when block order must not matter.

A continuous hue= is carried THROUGH a column hierarchy rather than superseded by it (since 1.1; a row hierarchy still warns and ignores). Two forms are accepted, both stated relative to the input frame rather than to the drawn figure:

  • a flat sequence of len(x) values – shared row-wise values, broadcast to every trace;

  • one sequence per leaf, each of len(x) values – per-leaf values.

A mean trace takes the element-wise mean of its members’ hue, computed by the same operation that averages their data, so an auxiliary value can never drift out of step with the trace it describes. Colors are mapped over the concatenation of every trace’s values, leaves and means together, so one scale spans the figure and a colorbar= reads against all of it. The hierarchy still sets linewidth, alpha and labels; only its colors step aside. The per-trace values are returned as trace_metadata['aux'].

A flat array sized to the TOTAL DRAWN observations is rejected, not reinterpreted: it is indistinguishable from the first form whenever a frame has as many rows as the figure has points, and it would require the caller to predict how many mean traces the expansion creates. A CATEGORICAL hue still defers to the grouping with a UserWarning, because it regroups traces and the named leaves would stop existing.

Note also that align= does NOT recover discarded feature identity: it aligns the resulting spaces, but by then the reduction has already interpreted arbitrary positions as corresponding inputs.

A frame carrying a hierarchy on both axes raises ValueError: which one takes precedence is genuinely ambiguous, and before 1.1 the row path silently won. Flatten one axis and try again.

Expansion is ONLY applied when a single bare DataFrame is passed as x, because the hierarchy determines the entire trace list and that cannot be reconciled with a caller-supplied list of datasets. If x is a LIST containing a row-MultiIndex DataFrame, the MultiIndex is treated as a flat index on that element by the normal list-of-datasets pipeline, with a UserWarning naming its position. A column-MultiIndex DataFrame in a list instead raises ValueError – before 1.1 it silently flattened to a single line with no warning at all. The asymmetry is deliberate: the row behavior is documented and depended upon, the column one was not.

See docs/hierarchy.rst for the user-facing guide to all of the above: the per-axis comparison table, worked examples of each rule, and the return shapes.

fmtstr 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.

Under backend='plotly' a 3-D plot can only draw plotly’s Scatter3d marker set (circle, square, diamond, cross, x and the open circle/square/diamond), so other markers take the nearest of those: triangles ('^', 'v', '<', '>') and 'd' draw as diamonds, '*' as an open diamond, '+', '|', '_' and '1'-'4' as crosses, 'x' as an x, and 'p', 'h', 'H' and '8' as circles. 1-D/2-D plotly plots and every matplotlib plot draw the marker asked for.

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

markersizeint or float

Size of the markers in points (default: matplotlib’s 6.0). Applies to both backends.

linewidthint or float

Width of plotted lines in points (default: matplotlib’s 1.5 for static plots, 1 for animations). Applies to both backends.

alphafloat or list of float

Opacity in [0, 1], either one value for every dataset or one value per dataset (e.g. alpha=[0.1, 0.1, 1.0] to fade two backdrops behind a highlighted third). Inputs that assign alpha internally – a row MultiIndex (per-level fading) or a nested list with varying nesting depth (per-depth fading) – keep their own values and warn that alpha= was ignored, rather than silently dropping it. This is unconditional: an otherwise-invalid alpha= (wrong number of entries, non-numeric, out of range) is also just ignored-with-a- warning in this case, not validated against and raised on – whether alpha= will be used is decided before it is checked. A continuous or matrix hue= keeps it: the per-point colored line segments AND markers carry their dataset’s alpha.

A predict= forecast overlay is drawn at HALF its dataset’s alpha (alpha=[1.0, 0.4] gives forecasts at [0.5, 0.2]); an unset alpha counts as opaque, so the default forecast alpha is 0.5. See predict below.

color(s)str or list of str

A list of colors

**kwargsany 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, 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/alpha) for that. Merged in AFTER the named style kwargs are resolved, so an explicit named kwarg (or internal styling logic, e.g. MultiIndex grouping’s color/linewidth/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, alpha – NOT the generic **kwargs passthrough above, which is applied verbatim and never broadcast, so zorder=, dashes=, 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.

palettestr, 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. A palette string of the form 'image:<path>' extracts colors from a LOCAL image file instead (palette='image:starry_night.jpg'): six anchor colors, sorted by value (dark to bright) so the palette reads as a gradient (palette_sort= or ?sort= in the spec picks another order; the most visually salient color is what a per-dataset image entry stands for). For a continuous hue those anchors are blended into a gradient exactly as any short color list is. See hypertools.plot.colors.image_palette for the extraction itself (and to choose a different number of colors). hypertools never downloads the image: fetch and cache it yourself, then pass the path. The spec takes optional query parameters – 'image:art.jpg?max_luminance=0.6&n_colors=8' – which are forwarded to image_palette (see it for the full set), so a too-bright or too-dark image can be bounded without a second call.

A {category: color} DICT names the color for each category of a categorical hue= explicitly:

hyp.plot(x, hue=speakers,
         palette={'Alice': '#E4572E', 'Bob': '#17BEBB'})

With a dict the category ORDER stops mattering – no more working out first-appearance order by hand so that a color list lines up with the right speaker. Categories the dict does NOT name keep the color the default palette (‘hls’) would give them at their own position, so naming a subset shifts nothing else, and a key that matches no category raises ValueError listing the categories that were seen (a stale or misspelled key is exactly what an explicit mapping exists to catch). Resolved by name on both backends, and on the legend and the colorbar. A dict needs categories to name, so passing one with no categorical hue= – where the drawn groups are datasets – raises ValueError rather than quietly falling back to the default palette.

A list of PALETTES is read as one palette PER DATASET:

hyp.plot([a, b, c], palette=['image:wave.jpg',
                             'image:starry_night.jpg',
                             'viridis'])

each dataset drawing in its own palette’s lead color (for an image palette, its most salient color). The disambiguation rule, in full: a list whose entries are ALL colors is one palette of explicit colors, exactly as it has always been (['red', '#00ff00', (0, 0, 1)] is unchanged); a list with at least one entry that is NOT a color but IS a palette – a palette name, an 'image:<path>' spec, a Colormap, a {category: color} dict, or a nested list of colors – is per-dataset. A ONE-entry list is broadcast to every dataset. A per-dataset list must have 1 or len(x) entries, and cannot be combined with a continuous hue= (there is no single ramp to map values through – ValueError). With a CATEGORICAL hue= (or cluster=/n_clusters=) the drawn groups are categories rather than datasets, so only a list of {category: color} dicts is meaningful there – each dataset’s dict names its own categories’ colors, and they are merged into one mapping resolved by name (naming one category with two different colors raises); any other per-dataset list with a categorical grouping raises ValueError stating both counts. A plain color list shorter than the number of datasets is CYCLED when there is no hue (['red', 'blue'] over three datasets draws red, blue, red), as it always was.

A t x k DATA MATRIX is a palette too. It is reduced to three dimensions with hypertools.reduce (palette_reduce=, default ‘PCA’, under palette_manip=/palette_normalize=/ palette_align=), each reduced column is scaled to [0, 1] as an RGB channel, the rows are ordered by palette_sort= (default 'columns': along the first component) and the result is a colormap that is resampled by interpolation to however many colors the plot needs – one per dataset, one per category, or a gradient along a continuous hue=:

hyp.plot(x, hue=np.arange(len(x)), palette=weights)   # (t, k)

A 2-D array with 3 or 4 columns and every value in [0, 1] stays a list of colors; pass a DataFrame (or values outside [0, 1]) for a three-column matrix that is data. One with 3 or 4 columns of whole numbers in 0..255 reads as 0-255 colors and raises ValueError (divide by 255 for the colors; a DataFrame for data). The palette is as smooth as the matrix: the rows are ordered along the first component, so a matrix whose other components follow the first (a trend with oscillations) gives a clean gradient and an unstructured one a striped palette. In a per-dataset list a matrix stands for its most saturated color. Palettes extracted from an image are sorted by value (dark to bright) so they read as a gradient; palette_sort= (or ?sort= in the spec) changes that, and the most salient color still leads when an image stands for one dataset in a per-dataset list.

palette_sort{‘value’, ‘hue’, ‘lightness’, ‘columns’, ‘original’} or None

How the colors of an image or matrix palette are ordered (see hypertools.plot.colors.sort_colors). None (default) means 'value' for an image and 'columns' for a matrix; a key given here applies to palette= and forecast_palette= alike, including every entry of a per-dataset list. A spec’s own ?sort= wins.

palette_reducereducer spec or None

The reducer hypertools.reduce applies to a matrix palette (default ‘PCA’); any form reduce= accepts.

palette_manip, palette_normalize, palette_alignsame forms as manip=, normalize=, align=, or None

Handed to hypertools.reduce when a matrix palette is reduced, exactly as the same-named arguments would be for the data.

huelist, 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.

It may also carry one SCALAR per dataset, broadcast over that dataset’s rows – hyp.plot(windows, hue=speakers) instead of hue=[[speaker] * len(w) for speaker, w in zip(speakers, windows)] – so datasets sharing a value share a color. The exact rule: this reading applies only when hue is a list/tuple of exactly len(x) entries, NONE of them a sequence (a sequence per entry is the nested form above), there is more than one dataset, and len(hue) is not also the total observation count. When it IS the observation count – every dataset holding exactly one row – both readings produce the identical per-observation hue, so the per-observation one is kept and nothing changes; and a SINGLE dataset is never broadcast, so hyp.plot(x, hue=['a']) keeps its existing per-observation meaning.

Over a column MultiIndex the same nesting means one sequence per LEAF (each of len(x) values), and a flat sequence of len(x) values is broadcast to every trace; a mean trace’s hue is the element-wise mean of its members’. A flat array sized to the total DRAWN observations is rejected, and a categorical hue still defers to the grouping. See x for the full rule.

A 2D matrix hue is MIXTURE WEIGHTS by default when it has at most 3 columns, and literal RGB when it has more (or when color_reduce= is given) – see color_reduce. Pass hue_mode= to say which you meant instead of relying on the width.

hue_mode{‘mixture’, ‘rgb’} or None

How to interpret a 2D matrix hue (default: None – choose by width, the historical rule described under color_reduce).

'mixture' blends each row through palette as weights, one palette color per COLUMN, whatever the width. This is what a hierarchy needs to make its own color scheme: give each leaf one primary and every derived mean comes out the blend of its children, with nothing computing it. Six sectors need six columns, and the automatic rule would send exactly that to the reducer instead.

'rgb' reduces the matrix to 3 min-max scaled channels used directly as (r, g, b), which is what a wide matrix does by default.

Only meaningful for a matrix hue; anything else raises, as does combining 'mixture' with color_reduce=. The default is left width-based deliberately: changing it would silently repaint every existing figure that passes a wide matrix hue.

color_reducestr, 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.

nameslist 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; an explicit legend=False still suppresses it. 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. Incompatible with a MultiIndex HIERARCHY for the same reason (one input frame, drawn as leaves plus derived per-level means); that combination also raises ValueError – name the top-level groups with legend=[...] or flatten the hierarchy.

labelslist

A list of point labels: exactly one entry per OBSERVATION (row) across all datasets, or a nested list with one sub-sequence per dataset (a list, tuple, 1-D array or Series each); a length mismatch raises ValueError naming labels and both counts. If no label is wanted for a particular point, input None for that entry. Every form keeps each label on its own observation when hue= or cluster= regroups the drawn traces.

labels may instead carry one entry PER DATASET – one string (or None) for each of the len(x) datasets – which annotates each dataset ONCE, at the row label_anchor= names (the first observation by default). hyp.plot(paintings, labels=titles, label_anchor='center') replaces building an all-None per-observation list with one string dropped into the middle of each sub-list. The disambiguation is by shape, not by a flag: this reading applies only when every entry is a bare string (or None) – a sub-LIST per dataset is the per-observation nested form above – and only when the dataset count is not also the total observation count, in which case the two readings annotate the same points anyway and the per-observation one is kept.

In an ANIMATION every observation stays a vertex of the animated line (the frame grid only ever adds points between observations), so each label is attached to its own observation and none is 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_anchor‘first’, ‘center’, ‘last’, int, or None

Where a PER-DATASET labels= list attaches its annotation inside each dataset: the first observation (default), the middle one, the last one, or an explicit row index (negative counts from the end, like a Python list index). Only meaningful alongside the per-dataset form of labels= described above; passing it with a per-observation labels= raises ValueError (those labels are already positioned). Default None (behaves as 'first').

label_alphafloat 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).

legendlist, 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).

Under a MultiIndex HIERARCHY the drawn traces are leaves plus derived per-level means, and only the TOP-level groups carry a legend entry – so there a list RENAMES those groups: one entry per unique top-level index value, in first-appearance order (the same convention linestyles= uses), with any other length raising ValueError. legend=False suppresses the hierarchy’s automatic legend; True/omitted labels the groups with the index values themselves. On a column hierarchy combined with a continuous or matrix-valued hue any legend is dropped with a UserWarning (that path colors by value, so there are no discrete groups to name).

A NESTED-list x ([[a, b], [c, d]]) colors every leaf by its outer group, and its legend follows the same rule: one entry per outer group (1..n for True, or a list with one name per group), drawn on the group’s shallowest leaf; a list with one entry per LEAF still labels every leaf.

Under a matrix-valued (mixture) hue= the blended per-observation colors have no discrete traces to label, so hypertools used to drop the legend with a warning. It now builds one proxy swatch per hue COLUMN instead – the palette colors the blend is made of – labeled from an explicit legend=[...] list (which must then have one entry per hue column), else the hue DataFrame’s own column names, else 1..k. A literal-RGB matrix hue (hue_mode=’rgb’ or color_reduce=) and a 1-D continuous hue still have no finite key and still drop the legend with a warning (use colorbar= there).

legend_kwargsdict

Extra keyword arguments forwarded to matplotlib’s Axes.legend ({‘loc’: ‘upper left’, ‘fontsize’: 8, ‘frameon’: False, ‘ncol’: 2}), or to plotly’s layout.legend ({‘x’: 0.02, ‘y’: 0.98, ‘orientation’: ‘h’}) under backend=’plotly’. Applied LAST, so anything given here wins over hypertools’ own defaults (a frameless legend centered outside the right edge). Default None.

legend_colorslist of colors, or list of (label, color) pairs

An explicit override of the legend’s swatches. A plain list of colors – one per legend entry, in order – RECOLORS the entries hypertools would draw anyway; when predict=/truth= add their own entries after the data’s, one color per DATA entry is accepted too and leaves those overlay glyphs as drawn (matplotlib only; the plotly legend takes its swatches from the traces themselves, so this form raises NotImplementedError there). A list of (label, color) pairs REPLACES the legend outright with exactly those entries, on both backends – which is how a figure adds a key entry no trace corresponds to (e.g. a gray “Market” line alongside per-sector swatches). Mixing the two forms raises ValueError. Under panels=, a plain list naming the datasets (one color per dataset, then one per shared entry such as a forecast model or truth) is split so each panel’s legend gets its own dataset’s color followed by the shared ones; a pair list is drawn whole in every panel. Default None.

colorbarbool 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).

titlestr or list of str

A title for the plot. Normally a single string. For serial-style animations (order='serial', animate='serial' or animate='morph') you may pass one string per dataset: each is shown while its dataset is the one being revealed, and morph TRANSITIONS show a blank title so only fully-formed clouds are named: every transition frame draws a cloud strictly between the two datasets, and only a hold draws a dataset’s own cloud (a hold and a transition both progress 0 -> 1, so the distinction is the segment itself, not how far through it you are). Anywhere else a non-string raises TypeError: use names= for per-dataset legend entries, or labels= for per-observation annotations. Rendered identically on the matplotlib and plotly backends.

Two DYNAMIC forms re-title the plot every frame (GH #285), both styled by title_kwargs=/title_color=/font= exactly like a fixed title, and both supported on either backend:

  • a callable ctx -> str, given that frame’s FrameContext (the same object on_frame= receives), e.g. title=lambda ctx: f'{ctx.progress:.0%} through';

  • a format pattern containing {index, e.g. title='{index:%B %Y}', formatted with index= set to the input’s own row-index value under the reveal head. This needs a pandas DataFrame/Series input carrying a non-default index (a DatetimeIndex is the intended case); with none, it raises ValueError before the pipeline runs. The FIRST input that carries an index is the one read. A title with braces that does NOT contain {index is drawn verbatim, as always.

Where the reveal head is. One rule, so the same pattern means the same thing on the same data: for a serial reveal it is the rows revealed so far across ALL datasets (finished ones plus the revealed part of the current one) as a fraction of every row, mapped onto the input’s rows – cumulative, so it never runs backwards when the next dataset starts; for every other animated style (True/'parallel'/'window'/ 'spin'/'morph') it is round(ctx.progress * (n_rows - 1)), since those advance every dataset together – note that 'spin' and 'morph' reveal no rows at all, so there the pattern simply sweeps the index over the clip’s running time. On a STATIC plot the dynamic form is resolved ONCE, into an ordinary title, from a context describing the finished figure: frame and n_frames are None, progress is 1.0, revealed_counts is every dataset’s full row count, and figure/axes are None (the figure is built FROM the title, so it does not exist yet) – which makes a pattern title on a static plot show the LAST index value.

title_kwargsdict

Styling for whatever title is drawn, applied by hypertools’ own title setter – including on EVERY FRAME of an animation with a per-segment title= list, which previously reset the title’s styling each frame and had to be re-applied from an on_frame= callback. Keys are matplotlib Text properties as Axes.set_title takes them, plus short aliases: size (fontsize), weight (fontweight), family (fontfamily), style, variant, stretch, and the already-real names color, alpha, y, pad, loc, rotation, linespacing, backgroundcolor. An unknown key raises ValueError naming the supported set. A size given here also sizes the room reserved above an animated 3-D title (matplotlib’s top-margin probe; plotly’s layout.margin.t), so a larger title still fits on the canvas.

On backend=’plotly’ the size/family/weight/style/color/y keys map onto layout.title; anything plotly’s title cannot express (pad, loc, backgroundcolor, linespacing, …) is reported in one UserWarning naming the backend and the keys. Default None (titles look exactly as they always have).

title_colorcolor, list of colors, or callable

A single color styles the whole figure’s title (shorthand for title_kwargs={'color': ...}). A LIST of colors tints each segment of a per-segment title= list – one per segment, so each dataset’s title can carry that dataset’s own color – and requires title= to be such a list. A CALLABLE receives the per-frame FrameContext (see on_frame=) and returns the color for that frame; being frame-driven it is matplotlib-only, and raises NotImplementedError under backend=’plotly’, whose frames are all built up front. Default None.

title_wrapint or None

Hard-wrap every title at this many characters (textwrap.fill), so a long title stays inside the canvas instead of running off it. Applied after per-segment resolution, so a scalar title and every entry of a per-segment list wrap identically; the line break is the backend’s own (a newline for matplotlib, '<br>' for plotly). A dynamic (callable / {index) title is wrapped afresh every frame. An animated 3-D title reserves top margin per line on both backends: for a per-segment list, the tallest entry; for a dynamic title, plotly measures every frame (all frames are built up front) while matplotlib measures FRAME 0’s text, so a callable whose title grows lines on later frames can clip there – give frame 0 the same number of lines. Default None.

fontNone, 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.

Weights: the package bundles a Bold face beside Noto Sans Regular (NotoSans-Bold.ttf, same license and provenance), so a bold request under the default font – title_kwargs={'fontweight': 'bold'}, legend_kwargs={'prop': {'weight': 'bold'}} and the like – resolves to that real bold face on the matplotlib backend rather than to a synthetic or Regular substitute; any other weight (light, medium, semibold, black, …) falls back to the nearest bundled face, i.e. Regular or Bold.

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, zlabelstr 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.

sizelist

A [width, height] pair of numbers, in inches, to resize the figure (anything else raises ValueError naming size)

elevint 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.

azimint 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.

normalizestr, 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).

manipmodel 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).

pipelinehypertools.Pipeline or None

A Pipeline to run in place of the manip/normalize/reduce/ align/cluster stages (GH #227). 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) is applied to x via .transform – never refit – 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). A pipeline that is NOT yet fitted (p.is_fitted is False) is fit on x first, exactly as an unfitted instance passed to reduce= would be, and the fitted object is what return_model=True hands back. Hand-built pipelines whose steps are raw scikit-learn estimators, e.g. hyp.Pipeline([('reduce', PCA(n_components=3))]) (fitted or not), are applied to each of x’s datasets separately (fit, when needed, on all of their rows stacked, as reduce= does); those steps take one array at a time, whereas the dispatcher pipelines analyze/plot build take the whole list. A bare fitted stage object (a Reducer/Aligner/… from a dispatcher’s return_model=True) is accepted as a one-step pipeline. A pipeline ending in a fitted ‘cluster’ step (a clustered figure’s bundle, or hyp.analyze(..., cluster=..., return_model=True)) colors the figure by that step’s labels for x, with the fit figure’s cluster colors, unless hue= is given; a clusterer with no out-of-sample predict that cannot label x warns and draws without clusters. 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. When x is a COLUMN- hierarchical frame, that grouping is recorded on the passed-in Pipeline itself (unless it already carries one), so the object return_model=True hands back re-applies to such a frame – see return_model (default: None).

reducestr, 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; the [torch] extra, installed on demand). 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.

ndimsint

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).

ndims=1 (1.1, GH #285) is a real TIME-SERIES mode, not a one-column scatter: every column of every dataset is drawn as its own line against the dataset’s ROW INDEX (a DataFrame’s index – including a DatetimeIndex, which ticks as real dates – or 0..n-1 for a plain array), in the data’s own units (axis_scale defaults to 'data' here). Two or more columns are allowed and become two or more lines, colored from palette and named in the legend by their DataFrame column names (names= overrides them; legend=True shows them). reduce= still applies first, to ONE component – ndims=1, reduce='PCA' draws the first principal component over the index. An animation reveals each line left to right along x. An index that is not in ascending order is drawn in index order, as predict= forecasts it, with a UserWarning – unless hue=/labels=/cluster= are given per observation in the input order, which keeps that order (and warns that the line doubles back). Before 1.1 this path drew a single column against 0..n-1 with the values rescaled to [-1, 1], no visible axes, and refused 2+ columns – so this is a deliberate behavior change: what a 1-D figure draws is now the data’s own coordinates. axis_scale='unit' puts the (index, value) traces back inside the [-1, 1] frame square like any other 2-D plot, which rescales BOTH axes and so does not restore the pre-1.1 geometry either.

axis_scale{‘unit’, ‘data’} or None

Which coordinates the figure is drawn in (1.1, GH #285).

'unit' is what hypertools has always done, and remains the default for 2-D and 3-D plots: the (possibly reduced/aligned) coordinates are mean-centered and rescaled into [-1, 1] by a single shared affine transform across all datasets (and, with predict=, across every forecast vertex), the axes are pinned around hypertools’ own frame (2-D: (-1.2375, 1.2375) around a frame square of half-width 1.125; 3-D: the [-1, 1] cube, grown to fit a surface=), and that frame square/cube is drawn in place of matplotlib’s ticks and spines. Coordinates read off the returned Figure are therefore an affine IMAGE of the analyzed data, not the raw values, and are not comparable across figures.

'data' skips that rescale entirely: the lines carry the pipeline’s own output coordinates, so with reduce=None they are the input columns themselves – hyp.plot(np.column_stack([t, y]), reduce=None, ndims=2, axis_scale='data') draws real t on x and real y on y. No frame square is drawn, the axes keep their ticks and spines (top and right removed) and draw no gridlines on either backend (a caller’s own ax= keeps any grid it already has), xlabel=/ylabel= still apply (DataFrame column names supply them by default), and the limits come from xlim/ylim when given, otherwise from the full data (forecast and truth= overlays included) with a 5% margin – fixed up front, so an animated viewport never jumps between frames. Supported for 1-D and 2-D plots on BOTH backends, static and animated; 3-D is always 'unit' (the cube frame has no other meaning) and axis_scale='data' with 3-D data raises ValueError.

None (the default) means 'data' in ndims=1 series mode and 'unit' everywhere else.

xlim, ylim(low, high) or None

Explicit axis limits, in the DRAWN coordinates (1.1, GH #285). Only meaningful under axis_scale='data' (under 'unit' the axes are pinned to the frame box); passing either with axis_scale='unit' raises ValueError rather than silently doing nothing. None (the default) computes the limits from the data. Both backends, static and animated. On a DATE x axis (ndims=1 over a DatetimeIndex) xlim accepts datetime-like values and date strings (xlim=('2020-01-05', '2020-01-10')) on both backends; a number there is a matplotlib day number (the unit matplotlib.dates.date2num produces) on both backends, and is converted for plotly.

alignstr, 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).

clusterstr, 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}} (as in hyp.cluster, a top-level key other than ‘model’, ‘args’, ‘kwargs’ and the ‘n_clusters’ shortcut raises ValueError). 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_clustersint

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_stateint, 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).

imputestr 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.

resampleint 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.

predictstr 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 forecast trace per dataset, listed ONCE in the legend (when there is one) under the model’s name – the same name hyp.predict(x, model=[spec]) would give it – with a glyph in the forecasts’ own style: their color when every forecast shares one, else a neutral gray, since the entry then names the model rather than any one dataset. (Before the 1.1.0 release review only a collection of models was listed.) A forecast is the SAME series projected forward, so it INHERITS the style of the observed trace it continues – same color, same linestyle, same linewidth – and differs only in transparency: forecast_alpha = observed_alpha * 0.5 (an unset alpha is matplotlib’s opaque 1.0, so the default is 0.5). A forecast given its OWN color (forecast_hue=, forecast_cluster=, forecast_palette=, or a color letter in forecast_fmt=) keeps its trace’s alpha instead: the color is then what tells it apart, and fading it as well hid it among translucent traces. Per-dataset styling carries through dataset by dataset, e.g. alpha=[1.0, 0.4] gives forecasts at [0.5, 0.2], and a dotted dataset gets a dotted forecast. Both backends apply the identical rule. (Before 1.1.0 every forecast was drawn dashed at a hard-coded alpha of 0.6 regardless of how its data was drawn.) The drawn overlay prepends the last observed row so the 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. Supported for STATIC plots, for animate='spin' (which only rotates the camera around the static forecast overlay, so the trace simply rotates with the rest of the scene), and for the TIME-PROGRESSING animate modes (True/'parallel'/'serial'/'window'), where the forecast is recomputed from the history revealed so far and drawn from the endpoint of the current frame, so it grows with the animation. Every one of those forecasts is computed BEFORE the first frame is drawn and folded into the plot’s center/scale statistics, so the whole fan lands inside the cube and nothing is clipped or clamped, and every frame is a lookup – ani.save() and to_jshtml() replays render identically. NOT supported with animate='morph' (including the per-dataset morph list form), which interpolates between point CLOUDS and so has no time axis to forecast along; that combination raises NotImplementedError.

For a hierarchical x (a row or column MultiIndex; see x) the unit is the FINAL TRACE rather than the input dataset: one forecast per leaf AND one per derived per-level mean, a mean forecast from its own averaged trajectory rather than from an average of its members’ forecasts. predict['forecasts'][i] in the return_model=True bundle therefore equals hyp.predict(trace_data[i], model, t) for every i. Forecasting needs history, so EVERY final trace – both axes, leaves and means alike – must have at least 2 rows; the first that does not raises ValueError naming the trace, its hierarchy key and its row count, before any model is fitted (and, for an animated plot, before the per-frame forecast schedule is built, since a one-row trace can never reach 2 rows at any frame). The remedy differs by axis: a ROW hierarchy draws one trace per unique FULL index tuple, so an innermost level that is unique per row yields one-row traces – drop the hierarchy (df.reset_index(drop=True)) or move the grouping to the columns; a COLUMN hierarchy keeps all of the frame’s rows in every group, so a short trace means the input itself has fewer than 2 observations and only more data helps. See docs/hierarchy.rst (default: None).

A collection of models (1.1, GH #285) draws one overlay per model on every trace: predict=['Kalman', 'ARIMA', 'GP'], or the mapping form predict={'my kalman': {'model': 'Kalman', 'kwargs': {...}}, 'arima': 'ARIMA'} when you want to name them yourself. The specs are handed to hyp.predict(x, model=[...]), whose {name: forecast} contract this reuses verbatim, so the two agree by construction. Two things need telling apart – which series a forecast continues and which model made it – so each overlay keeps its dataset’s COLOR (as the single-model form does) and takes a linestyle per MODEL, cycling solid, dashed, dotted, dash-dot in model order (the first model is solid, so predict=['Kalman'] draws what predict='Kalman' draws). The legend lists each model by name (the auto-name from the spec, or the mapping key) with a glyph in its linestyle – neutral gray when the model’s forecasts span several colors. forecast_fmt= may be a list, one entry per model, replacing the cycle; forecast_palette (a seaborn palette name or an explicit color list) colors by MODEL instead, one color per model shared across datasets. (Until the 1.1.0 release review a per-model 'husl' palette was the default, and its first color was the first dataset’s own.) Works with truth=, static and animated, on both backends. The return_model=True bundle’s predict['forecasts'] becomes a {name: [forecast per dataset]} dict for this form, mirroring hyp.predict – for a hierarchical x too, where each list holds one forecast per FINAL trace. forecast_hue= keeps its one-value-per-DATASET meaning: a value is shared by every model’s forecast of that dataset (a list sized to the overlays themselves, model-major, is also accepted).

trutharray, DataFrame, or list of these, or None

The ACTUAL continuation of each dataset, drawn beside the forecast in the same space (1.1, GH #285) – so a train/held-out/forecast figure is one call rather than three hand-built datasets: hyp.plot(train, predict='Chronos', t=30, truth=held_out). Requires predict=. One array per input dataset (a bare array for a single dataset), each with exactly t rows and the same number of columns as the plotted data; anything else raises ValueError naming the mismatch. With ndims=1 each plotted column is its own trace whose x is the index, so its truth is ONE column of values (or one frame with one column per trace); its x continues the index exactly as the forecast’s does. It is read in the PLOTTED space – with reduce=None (the case these figures use) that is the input space, and with a reduce= spec it is whatever that spec produced, so pass values already in it (hypertools does not re-project truth=; the column-count check is what catches the mistake). It then goes through the SAME center/scale transform as the data, so it lands where the forecast does, and – like a forecast – is drawn with its dataset’s last observed row prepended so it connects to the trajectory. It is styled DISTINCTLY from the forecast – its dataset’s color, solid, fully opaque, with round markers at the observations – and its artists are role-tagged 'truth' (matplotlib line._hyp_forecast_role, plotly meta['hyp_forecast_role']) exactly as forecast artists are tagged 'static'/'live'/'trail'. With legend=True it gets one 'truth' legend entry. Static plots (and animate='spin') draw it in FULL. A time-progressing animation (True/'parallel'/'serial'/'window', including a hue=/cluster= regrouped reveal) reveals it as the forecast reaches it: each held-out row appears on the first frame whose drawn forecast of that dataset (any model’s, for a predict= list) extends to or past that row’s position, and stays on every later frame – so the answer is not on screen before the animation has forecast anywhere near it. While no drawn forecast has reached it (e.g. too little history revealed yet) the truth is hidden; its 'truth' legend entry is shown on every frame. Identical on both backends (default: None).

tint or datetime-like

Forecast horizon passed to predict (see hypertools.predict.common.resolve_t); ignored unless predict is set. Measured in RAW observations of the analyzed data – NOT in animation frames and NOT in drawn vertices. t=1 forecasts only the next observation. A datetime-like t (a Timestamp, a datetime or a date string) is measured against each dataset’s own DatetimeIndex, exactly as hyp.predict measures it: every dataset is forecast up to that time, so a flat list whose indexes end at different times or have different spacings gets a different number of steps per dataset (the single integer horizon the renderer works in is the LARGEST of those counts). A hierarchical (MultiIndex) input is stricter: t must resolve to ONE step count shared by every group (ValueError otherwise), and a row MultiIndex is not a DatetimeIndex, so it needs an integer t. Datasets without a DatetimeIndex, a t at or before any dataset’s last observation, or a pipeline stage that changed the row count raise ValueError. Because an animation is paced on a resampled frame grid (see duration/frame_rate), a frame’s drawn head usually falls between two observations; an animated forecast starts exactly there, while the points it predicts stay t observations on from the last one revealed (default: 10).

save_pathstr 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.

animatebool, ‘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). ‘serial’ COMPOSES with those per-dataset trail flags (chemtrails- serial / precog-serial / bullettime-serial) on BOTH backends: the ONE dataset currently being revealed also traces out its own low-opacity trail (past / future / whole, per its flag) led by a short opaque comet-head, while already-revealed datasets stay fully drawn and future ones stay invisible – see bullettime below.

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) – the padding step itself never drops a real data point. Whether one was already dropped EARLIER, by sampling, is the caller’s documented choice: with an explicit morph_samples=, or with simplify=True (the default) over clouds larger than MORPH_SAMPLES_REQUIRED_ABOVE = 2000 points, each cloud is first downsampled to that cap; with simplify=False and no morph_samples=, every dataset keeps its FULL point count and no real data point is ever dropped (see simplify below). 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 the shape-morph gallery example’s original hand-rolled algorithm (now examples/animate_morph_zoo.py), 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.

predict= (forecast overlays; see below) is compatible with animate=’spin’ (which renders a STATIC scene and merely rotates the camera, so the fixed forecast overlay is drawn once and rotates along with everything else) AND with the time-progressing modes (True/’parallel’/’serial’/’window’), where the forecast is recomputed from the history revealed so far and drawn from the endpoint of the current frame, so it grows with the animation. Only ‘morph’ (including per-dataset morph lists) raises NotImplementedError: a morph interpolates between point CLOUDS, so there is no time axis to forecast along. See forecast_trail= to keep earlier forecasts on screen as a fading fan.

File size (plotly). An animated plotly figure embeds every frame, and its animated lines keep every observation, so the figure’s HTML/JSON grows with rows x frames: a 3000-row 3-D random walk animated over 300 frames (duration=10, frame_rate=30) is about 6.7 MB of JSON (about 4.5 MB in 2-D). Passing resample=N (below) to thin the input first shrinks it in proportion – resample=1000 brings that example to about 2.3 MB (1.5 MB in 2-D). Fewer frames (a lower frame_rate or shorter duration) shrink it too.

order{‘parallel’, ‘serial’}

Whether animated datasets are revealed all at once (‘parallel’) or one after another (‘serial’). This is ORTHOGONAL to animate=, which names the style, so it composes with the trail flags: animate=True, order='serial', chemtrails=True is the serial version of chemtrails, and renders identically on the matplotlib and plotly backends. The default (None) means “whatever the style implies”: parallel for the reveal styles, serial for animate='serial' (a permanent alias for animate=True, order='serial') and animate='morph' (inherently serial). Passing order='parallel' alongside either of those raises ValueError. 'spin' and 'window' have no dataset-by-dataset reveal, so order='serial' warns and is ignored there. order='serial' without an animation raises ValueError.

backendstr

Rendering backend: ‘matplotlib’ (the classic renderer), ‘plotly’ (interactive; the [interactive] extra installs itself on first use), 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 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) apply to animate=True/ ‘parallel’ and to animate=’serial’, on BOTH backends (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 to animate=True/’parallel’ and to animate=’serial’, on BOTH backends – where they COMPOSE with the serial reveal: only the ONE dataset currently being revealed carries a trail (chemtrails = its revealed-so-far past, precog = its not-yet-revealed future, bullettime / chemtrails+precog = its whole trajectory), led by a short opaque comet-head near the reveal tip, while already-revealed datasets stay fully drawn and future ones stay invisible. ‘spin’ has no “current position” for a trail to lead/ follow (only the camera moves), ‘morph’ draws a single traveling cloud, and ‘window’ is bullettime MINUS its trail by definition, so animate=’spin’/’morph’/’window’ 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.

forecast_trail (animation only)bool or int

Keep earlier forecasts on screen as a fading fan – the forecast analog of chemtrails (default: False). Requires predict=; without it, ValueError. True retains 16 past forecasts; an int sets the cap (must be >= 1). Each retained forecast is drawn in its dataset’s style, exactly like the live one, at an alpha that decays with age from THAT dataset’s live forecast alpha down to a floor proportional to it – so a retained forecast is never more opaque than the live forecast it fades from, however faint the dataset. A viewer can then see how the prediction CHANGED as history accumulated.

The fan is recomputed from the frame index rather than accumulated in a buffer, so it depends only on which frame is being drawn: a saved GIF and an interactively-played animation are identical, and driving frames out of order (which save()/to_jshtml() do) gives the same picture. Retained forecasts need no extra room in the plot box: a retained forecast is just an earlier frame’s, and the box is already built to contain every forecast the animation will draw.

forecast_huesequence or None

Group the FORECASTS by one value per dataset, coloring them independently of the observed data (default: None – inherit). Requires predict=; without it, ValueError.

One value per FORECAST, not per observation: a forecast is a single trace. Datasets sharing a value share a color, drawn from forecast_palette= – or, without one, from the figure’s palette= CONTINUED past the colors the observed data takes, so no forecast group wears an observed line’s color (see forecast_palette=). Mutually exclusive with forecast_cluster= (both decide the same thing), exactly as hue= and cluster= are for the observed data.

There is one forecast per DRAWN TRACE, which is one per input dataset until a hierarchical (MultiIndex) x= regroups the data: plot() forecasts every FINAL trace, so a hierarchy needs one value per leaf group PLUS one per derived mean. The same unit applies to forecast_fmt= and to forecast_palette=’s no-grouping case.

forecast_clusterstr, class, instance, dict, or None

Color each forecast by WHERE IT IS PREDICTED TO END UP: the forecast ENDPOINTS are clustered, and each forecast takes its cluster’s color (default: None – inherit). Requires predict=; without it, ValueError. Takes the same model specs as cluster=.

So a forecast’s color answers “which of these series are heading to the same place?” – a question the observed data cannot answer, which is the point of the separate kwarg. It deliberately does NOT recluster the observed data: inheriting the observed assignment is what the default already does, so that reading would make this a no-op. Nor does it cluster every predicted POINT (a single forecast would then change color along its own short path) or whole flattened trajectories (sensitive to t, to sampling and to dimensionality, where an endpoint has one stable meaning).

The endpoints are taken in the space the figure DRAWS – after reduce=/align= – so the grouping matches the geometry on screen rather than a pre-reduction one the viewer cannot see. With fewer than two forecasts it warns and inherits: every partition of a single point is the same partition. The endpoints clustered are those of the DRAWN traces’ forecasts, so a hierarchical (MultiIndex) x= clusters its derived means’ endpoints alongside its leaves’ (see forecast_hue=).

In an ANIMATION the groups are resolved ONCE, from the full-history forecasts (the ones return_model=True returns), and stay fixed for every frame – they are not reclustered as the reveal progresses, so the colors hold still while the forecast geometry moves. Cluster labels are arbitrary names for groups, so per-frame reclustering would let a forecast change color whenever a fit nudged its endpoint across a boundary, and would repaint a retained forecast_trail= fan whose earlier members were drawn under the old grouping.

forecast_n_clustersint or None

Number of groups for forecast_cluster= (default: None – the clusterer’s own default). Separate from n_clusters= on purpose: the observed observations and the forecast endpoints are different point sets, and a good number of groups for one need not be a good number for the other. Without forecast_cluster= it warns and is ignored.

forecast_palettestr, list of colors, matplotlib Colormap, or None

Colors for the forecast grouping (default: None – inherit the observed colors). Requires predict=; without it, ValueError. Takes the same forms as palette=, so the forecasts can use a different colormap from the data.

With forecast_hue= or forecast_cluster=, one color per group. Left at None there, the groups take the figure’s palette= CONTINUED past the observed data’s color slots (its datasets, or its hue=/cluster= groups, plus any an earlier call took on a reused ax=) – the next colors of a fixed-sequence palette such as ‘Set2’, and the free slots of a finer sampling of an evenly spaced one such as ‘hls’ (four observed ‘hls’ colors -> the groups get the 45- and 135-degree hues of ‘hls’ at 8) – so no forecast group is drawn in an observed line’s color. A per-dataset or {category: color} palette= has no sequence to continue, so ‘hls’ is continued past the observed count instead; a continuous hue= (colormap colors, no palette slots) keeps ‘hls’ from its first color. An explicit forecast_palette= replaces all of this. With panels= the same default is resolved against the whole grid, so each label keeps the color the single-axes figure gives it. With NEITHER, there is no forecast grouping to color by, so it is spent one color per forecast (see forecast_hue= on what counts as one for a hierarchical x=) – except for a COLLECTION of models (predict=[...]), where it is spent one color per MODEL, shared by that model’s forecast of every dataset (see predict=).

forecast_fmtstr, sequence of str, or None

Line/marker style for the forecast overlays, in the same format-string grammar as fmt (default: None – inherit the observed style). Requires predict=; without it, ValueError. One string, or one per FORECAST – which for a hierarchical (MultiIndex) x= means one per leaf group PLUS one per derived mean, not one per input dataset (see forecast_hue=).

Overrides ONLY the style: a dotted forecast of a red trace is still red, unless a color is also given (via forecast_palette=, forecast_hue=, forecast_cluster=, or a color letter in the format string itself – an explicit color beats the format string’s, matching matplotlib’s own rule). A marker in the format string ('ro:') is drawn at the forecast’s own steps – the seam observation and each of the t forecast rows – never at the antialiased vertices between them (see antialias).

Note that these four kwargs are independent, so observed and forecast data may differ in style, in grouping, in palette, or in any combination. Everything not named stays inherited: a forecast is its observed trace projected forward, drawn at half its alpha (hypertools.plot.forecast.FORECAST_ALPHA_SCALE), and each of these replaces exactly one aspect of that.

slow_warning_seconds (animation only)float or None

Warn when an animated predict= schedule looks like it will take a long time to build (default: 10 seconds). Pass None to silence it.

An animated forecast needs one fit per DISTINCT revealed history length, so cost grows with the data rather than with the frame count: 3 datasets x 60 rows x 900 frames is 177 fits (~5 s), but 3 x 500 x 900 is 1497 fits (~330 s) because a longer series has both more distinct histories AND a costlier fit each. Nothing is skipped to make that faster – sampling the reveal would change what is plotted – so the notice exists to make a long wait expected rather than mysterious. It is emitted once fits at two or more history lengths (one of at least 10 rows, or the longest available) have been timed, not after the wait.

frame_rate (animation only)int or float

Frame rate for animation in frames per second (default: 30). Both backends generate exactly round(frame_rate * duration) frames – or a single still frame when that rounds below one – and both play them at 1000 / frame_rate ms per frame, 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 (no real data point is ever dropped; see hypertools.plot.morph). The Hungarian assignment’s cost is roughly O(n^3) in the (post-cap) target point count, so above 2000 points per cloud an uncapped morph is intractable: with the default simplify=True hypertools caps it at 2000 for you, and with simplify=False it raises ValueError naming this parameter rather than appearing to hang. Pass morph_samples=1000 (or whatever cap you want) to choose for yourself; an explicit value always wins over simplify. Measured matching cost: 0.10 s at 1000 points, 0.64 s at 2000, 4.99 s at 4000; the built-in zoo shapes (~30k points) would need a 7.2 GB cost matrix and were still running after 10 minutes. Must be a positive integer (or None); anything else raises ValueError. Ignored for every other animate mode.

loop (animate=’morph’ only)bool

Close the morph sequence: after the last cloud, transition back to the FIRST one and hold it again, so a looping player never hard-cuts from the last shape to the first. n clouds then give 2(n + 1) - 1 hold/transition segments instead of 2n - 1, and the closing hold is titled (and colored) like the opening one – title= still takes one entry per DATASET, not one per segment.

The closing cloud reuses the FIRST cloud’s already-sampled points, which is the whole reason this exists: appending clouds[0] to the input list yourself makes hypertools treat it as one more independent dataset, so whenever morph_samples= actually bites it draws a FRESH random subset for the repeat and the loop point visibly jumps (and, separately, the duplicate shifts the shared mean-centering every dataset is drawn through). Default False. Raises ValueError for every other animate mode – the reveal styles already end where a repeat would start, so there is nothing for it to close. Supported on both backends.

dataset_fade (serial reveals only)dict, tuple, or None

Fade already-revealed datasets by how long ago they were revealed, so a serial animation shows recent history brightest. {'floor': 0.15, 'decay': 0.7} (or the positional (0.15, 0.7)) gives dataset i the alpha

0.0                                  i not revealed yet, or
                                     only one point drawn
1.0                                  i is being revealed now
floor + (1 - floor) * decay ** (current - i)   otherwise

where current is ctx.current_index. floor (default 0.0) is what an infinitely old dataset fades to, and decay is the per-dataset falloff; both are checked (0 <= floor <= 1, 0 < decay <= 1). A dataset’s head and its trail take the SAME alpha – deliberately not hypertools’ usual 0.3x trail convention, because on a serial reveal the trail IS the already-revealed part of the dataset being revealed, and at 0.3x the newest dataset would read as the faintest thing on screen.

Applied by an internal per-frame updater that runs BEFORE any on_frame= callback, so a callback can still override one dataset’s alpha. chemtrails/precog/bullettime fade WITHIN one trajectory; this fades ACROSS datasets, and the two compose. Serial-only (animate='serial', or order='serial'): a parallel animation reveals every dataset at once, so there is no “how long ago” to decay over, and it raises ValueError. Default None. Supported on both backends (matplotlib alpha, plotly per-frame trace opacity).

companion (matplotlib only)dict, list of dict, or None

Extra 2-D panels drawn beside an animated plot and revealed in LOCKSTEP with it – the linked time-series panel that used to require building a second axes by hand and driving it from an on_frame= hook (ax= plus animate= raises; several animated panels in one figure are not otherwise supported).

Each panel is a dict:

kind

'series' – the only kind in 1.1.

data

The series to draw: (n_rows,) or (n_rows, 1) for y against row number, or (n_rows, 2) read as (x, y). Its rows are the INPUT’s rows: the panel’s reveal head is the same row a title='{index...}' pattern reads (see title), so a panel and a date title can never disagree.

reveal

True (default) reveals the series up to the head; False draws it whole on every frame (including its smoothed trend). The head marker still follows the current input row in both modes.

smooth

An int rolling-mean window (>= 2) drawn as a black trend line over the revealed part, NaN until the window fills – the same convention as pandas.Series.rolling(w).mean(). Default None (no trend line).

marker

True (default) puts a dot on the head.

hue

One value per row: the revealed line is drawn as a color-mapped LineCollection through the PLOT’s own color scale when it has one (a continuous hue=), else a linear scale over these values. Default None (a solid color).

position

'bottom' (default) or 'right'.

size

The panel’s share of the finished figure, 0.05-0.8 (default 0.35). The figure GROWS to make room – the animated axes keeps the exact absolute size it had, so the plot itself is unchanged.

pad

How much of size is left for the panel’s own ticks and label (default 0.10, in figure fractions).

color

Line and head-marker color. Default: the color the first dataset’s trajectory is drawn in (its fmt= color letter, color=, or its palette color).

xlabel, ylabel

Axis labels.

What this deliberately does NOT do: no 3-D companion panels, no panel with its own animation schedule (it always follows the main reveal), no legend or colorbar of its own, no interaction with ax= or panels=, and no plotly support – on plotly an animation is a list of go.Frame payloads over one figure’s traces, so a companion xy panel would need make_subplots layout and its own per-frame trace payloads; backend='plotly' with companion= raises NotImplementedError naming the backend. For a static multi-panel figure use panels=. Default None.

on_framecallable

Called after each animation frame is drawn, with a single FrameContext argument exposing the frame index, progress (0..1 through the whole clip, on every style), the axes and drawn artists, the arrays being animated, per-dataset revealed_counts and window_bounds (populated on the parallel, 'window' and 'spin' paths too since 1.1.x, so a hook no longer has to recompute the head from frame / (n_frames - 1)), and – for serial-style animations – which dataset is being revealed and how far through it. For animate='morph' it also reports segment_index and segment_kind (‘hold’ or ‘transition’). Use this instead of reaching into matplotlib’s private FuncAnimation._func. On MATPLOTLIB, callbacks may also be attached afterwards via HyperAnimation.on_frame(); this is not available on plotly, whose animated return is a plain go.Figure with its frames already built, so pass on_frame= here for backend-portable code.

Supported on BOTH backends, with the same per-frame context metadata but different call schedules: matplotlib calls back at render time, so a frame index may recur across a looping animation or a save; plotly calls back exactly once per frame index, while the frames are built. Callbacks must be deterministic and idempotent for a given frame context. They must not depend on call count, call order, wall-clock time, or accumulated external state.

Mutating what the context hands you is the point of the hook and is fully supported – the example below sets a title every frame. (A 3-D matplotlib animation passed on_frame= reserves the same top margin a title= gets, so a title set by the callback is drawn on the canvas; one attached later with HyperAnimation.on_frame() should pass title=' ' to reserve it.) What is unsupported is accumulation (count += 1, alpha *= 0.9), because a repeated frame would change the result. Precompute running quantities and index them by ctx.frame.

ctx.figure, ctx.axes and ctx.artists are backend-native (ctx.axes is None on plotly, whose ctx.artists are that frame’s traces), so a callback that touches them is not portable across backends; every other field is identical across backends.

>>> import numpy as np
>>> import hypertools as hyp
>>> data = [np.cumsum(np.random.default_rng(0).standard_normal(
...     (20, 3)), axis=0)]
>>> def annotate(ctx):
...     ctx.axes.set_title(f'frame {ctx.frame} of {ctx.n_frames}')
>>> anim = hyp.plot(data, animate=True, on_frame=annotate, show=False)
simplifybool

Whether hypertools may silently downsample to keep a render tractable. Today this governs animate='morph' only: a morph over clouds larger than 2000 points is downsampled to 2000 with no warning (see morph_samples), because the alternative is a plot that never appears. Pass simplify=False to get an explanatory ValueError instead, so that no real data point is ever dropped without you asking. Below the threshold, and whenever you pass morph_samples= yourself, simplify does nothing at all.

interactivebool

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).

explorebool

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_backendstr

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.

showbool

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 (the plotly backend displays it once, at the end of the cell, whether or not you keep the returned figure; in a script it calls its own renderer); 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.

transformarray, DataFrame, list of these, or None

The transformed data, bypasses transformations if this is set (default : None). One entry per dataset of x, row for row; a bare array or DataFrame is one dataset. A DataFrame’s rows are matched to x’s by position; with predict=, whose observation times come from x’s index, a DataFrame index that is neither a plain 0..n-1 one nor x’s own raises ValueError rather than guessing which observation each row is.

vectorizerstr, dict, class or class instance

The vectorizer to use. Built-in options are ‘CountVectorizer’ or ‘TfidfVectorizer’; a name not found there is looked up as a gensim model (‘Word2Vec’, ‘Doc2Vec’, ‘FastText’), then as a Hugging Face sentence-transformers model id (e.g. ‘all-MiniLM-L6-v2’). 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.

semanticstr, dict, class or class instance

Text model to use to transform text data. Built-in options are ‘LatentDirichletAllocation’ or ‘NMF’ (default: LDA for count vectorizers; for embedding vectorizers – gensim or Hugging Face – the semantic stage defaults to none and corpus is unused, since a topic model cannot consume continuous embeddings; an explicit ‘NMF’ with a Hugging Face vectorizer raises ValueError). Pass None to skip the semantic stage explicitly. 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.

corpuslist (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. Unused (never loaded or embedded) when the vectorizer is a pretrained Hugging Face embedding model and there is no semantic stage.

axmatplotlib.Axes, plotly.graph_objects.Figure, or plotly grid cell

The surface to draw into: a matplotlib Axes for the matplotlib backend, or, with the plotly backend, either the plotly Figure an earlier hyp.plot returned – this call’s traces are appended to it and it is returned (its layout is left alone) – or one cell of a hyp.subplots(nrows, ncols, backend='plotly') grid, into which the whole drawn panel moves (traces, axes/frame, title=, labels= annotations, and its own legend and colorbar beside the cell), the same composition loop as the matplotlib fig, axes = hyp.subplots(...); hyp.plot(d, ax=axes[i]) form. A plotly Figure or cell with the default backend='auto' draws with plotly; with an explicit backend='matplotlib' it raises TypeError. A matplotlib Axes under plotly raises ValueError.

The datasets are drawn in the palette exactly as on a figure of their own (a caller’s Axes used to keep the color cycle of the figure it came from), and a second call into the SAME Axes or plotly Figure continues the palette past the datasets the earlier call drew, on both backends – so composing two calls does not draw both in the first color. A fixed-sequence palette (a color list, ‘deep’, ‘Set2’) gives its next colors; an evenly re-sampled one (‘hls’, ‘husl’, a colormap) fills the gaps between the colors already drawn rather than repeating one (‘hls’ drawn as 2 datasets and then 2 more gives the four 4-color ‘hls’ hues). The return_model bundle’s 'colors' (and a colorbar) report the colors drawn. Pass color= to choose instead.

STATIC PLOTS ONLY. An animated plot (any truthy animate=) owns its own figure: it creates one, draws there, and returns it, so an ax passed alongside animate= would be left empty. That combination raises ValueError rather than drawing the right data somewhere the caller is not looking. Style an animation through the figure it hands back – anim.figure, anim.figure.axes[0] – which persists: styling applied to that figure after the call survives both draw_frame and save (measured). Use on_frame= for decoration that must CHANGE with the frame, such as a marker tracking the head of a trace. Several animated panels in one figure are not supported; lay the panels out in the DATA instead (translate each column group into its own region of one shared frame) and make a single plot call.

panelsbool, int, (nrows, ncols), or None

Draw one STATIC panel per dataset in a single figure, instead of every dataset on one axes – the built-in form of the fig, axes = plt.subplots(nrows, ncols, subplot_kw={'projection': '3d'}); for ax, d in zip(axes.ravel(), data): hyp.plot(d, ax=ax, show=False); plt.tight_layout() grid. True (or 'auto') picks the grid from the figure’s aspect ratio (size=), preferring a grid with no spare cell: three panels form a row in a default or wide figure and a column in a tall one, four form 2x2, six form 2x3; an int is the number of COLUMNS; an (nrows, ncols) pair is used verbatim and must have room for every panel. Spare cells are hidden, the layout is tightened, and the one Figure is returned. Every cell has ONE projection, decided from the ANALYZED data (the rows the pipeline hands each panel to draw, not the raw column count: two raw columns through a feature-expanding manip='Delay' come out three wide and get 3-D cells): 3-D by default; 2-D axes for ndims=2 or ndims=1, and when every panel’s analyzed rows are 1 or 2 columns wide (the single-axes call draws such data on 2-D axes whatever ndims= says). Panels of UNEQUAL analyzed width (independent fits of a 2-column and a 3-column dataset, say) share the wider grid: 2-column rows are drawn flat on the 3-D cell’s floor, and a 1-column series as row index vs value on that floor (its own numeric index on x when the frame has one, positions otherwise). That placement is a matter of DRAWING only: such a panel forecasts (predict=), reads truth= and reports its bundle in its own analyzed space – the numbers the individual call for that dataset produces – and its forecast and truth overlays are placed the way its rows are (a series continues its index one row per step). subplots= is an accepted alias (passing both raises).

The analysis pipeline (manip/normalize/reduce/align) is fit ONCE across ALL the datasets by default – exactly as the equivalent single-axes call fits it – and each panel then draws its own dataset’s rows from that shared fit, so every panel shares one set of components and the panels are comparable. (Each panel’s box is scaled to its own contents, as a per-panel ax= call is today.) Pass panel_fit='independent' to fit per panel instead.

Axis labels are derived per panel from that panel’s own DataFrame column names, under the same rule a single-axes call uses (2-D or 3-D, one drawn axis per named column); an explicit xlabel=/ylabel=/zlabel= still wins.

Like the single-axes path, show=True registers the figure with pyplot and does NOT call plt.show() itself (see show=).

If reduce= is a LIST, the panels are one per REDUCER instead – reduce=['PCA', 'UMAP', 'TSNE'] draws every dataset three times, once per reducer, each panel running its own full pipeline.

title= takes one string per panel (or one string for all of them). Per-dataset arguments given as lists (hue=, labels=, names=, color=, fmt=, alpha=, marker(s)=, linestyle(s)=, surface=, density=, truth=, a per-dataset palette= list such as ['viridis', 'magma'], …) are narrowed to each panel’s own dataset, in either panel_fit= mode; legend=/colorbar= are drawn per panel, and a legend= LIST naming the datasets (no hue=/cluster= grouping) gives each panel its own entry. Forecast arguments partition the same way: forecast_fmt= and forecast_hue= given per forecast (per dataset for one predict= model, model-major for a collection – see predict=) reach each panel as its own forecasts’ entries (a collection’s one-per-MODEL forecast_fmt= is forwarded whole, since every panel draws every model), forecast_palette= is resolved against the whole grid so a dataset or a forecast_hue= label keeps the color the single-axes figure gives it, and a forecaster already FITTED on every dataset (hyp.predict(x, return_model=True)) is bound to each panel’s dataset (Forecaster.for_dataset) so its learned parameters are reused rather than refit – a forecaster fitted on a different number of datasets than there are panels raises ValueError. forecast_cluster= groups each panel’s own forecast endpoints (with a single model, one per panel – it then warns and inherits).

STATIC ONLY: combining panels= with any truthy animate= raises ValueError (an animation owns its whole figure – see ax=), as does combining it with ax= or explore=True. Under backend=’plotly’ the same grid is built with plotly.subplots.make_subplots (3-D panels as type='scene' cells), and return_model=True returns the SAME bundle keys there as on matplotlib (fig, axes, panels, panel_models, xform_data, colors, pipeline) – axes holding the layout objects the panels were transplanted into (one layout.scene* per 3-D panel, an (xaxis, yaxis) pair per 2-D one). pipeline is the ONE fitted hypertools.Pipeline the shared fit produced (panel_fit='shared'; None under 'independent' and a list-valued reduce=), and every panel_models[i]['pipeline'] is that same object – so bundle['panel_models'][i]['pipeline'].transform(new_data) projects held-out data into the panels’ common space without refitting, exactly as the single-axes bundle’s pipeline does; under panel_fit='independent' (and per-reducer grids) each panel bundle’s pipeline is that panel’s own fit instead. Default None (one axes, as before).

See also hypertools.plot.plot.subplots, a thin (fig, flat_axes) helper for grids you want to fill yourself.

subplotsbool, int, (nrows, ncols), or None

Alias for panels= (matching the name of the matplotlib call it replaces). Passing both raises ValueError.

panel_fit{‘shared’, ‘independent’}

How panels= fits the analysis pipeline (GH #285). Ignored without panels=/subplots=.

'shared' (default, and the only behavior before 1.1.x) fits manip/normalize/reduce/align ONCE across every dataset and gives each panel its slice of the result, so the panels share one set of components and are directly comparable.

'independent' fits the pipeline per panel: each panel is analyzed exactly as the hyp.plot(x[i], ax=axes[i], ...) you would have written by hand, down to the coordinates and the seeded clustering (cluster=/n_clusters= with random_state= groups a panel exactly as that call does; the grid’s cells are the exception described under panels=, one projection shared by every panel). Use it when the datasets differ enough in scale or shape that one shared fit is dominated by the largest of them – examples/plot_shapes_zoo.py’s seven shapes reduced separately is the case that motivated it. panel_fit='independent' with a list-valued reduce= raises: a per-reducer grid already runs a full pipeline per panel.

frame_kwargsdict

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_initint

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_chunkint

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_maxint 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_windowint 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.

surfacebool, 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.

densitybool, 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); scikit-image (the [density3d] extra) installs itself on first use, and only if that is not possible does it fall 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.

antialiasbool

Automatically smooth every drawn LINE so there are no sharp angles between successive observations. Default True (on for every plot, static and animated, in both backends).

Trajectories are upsampled along a monotone PCHIP interpolant, which is C1 – its tangent is continuous, so the drawn curve bends smoothly through each sample instead of turning a corner at it – and every original sample remains an exact vertex of the drawn line, so this changes only how the data is DRAWN, never the data itself (returned arrays, return_model=True bundles, forecasts, hulls, densities and per-point labels/markers are all unaffected).

Applied at the LAST stage before drawing, so it composes with everything upstream. In an ANIMATION each frame draws the smooth curve for exactly the portion of the trajectory that frame would have shown – so a short animation of a finely-structured trajectory (many tight loops) renders as smooth curves rather than as one coarse straight segment per frame, at any frame_rate.

Only applies to styles that draw a LINE (solid or dashed/dotted – e.g. '-', '--', ':', and marker+line combos like 'o-'). MARKER-ONLY styles (e.g. 'o', '.') are never touched: markers always render at the true sample points. Forecast overlays drawn by predict= are smoothed the same way. A marker+line style ('o-', or a forecast_fmt='o:') marks only the true samples, never the vertices smoothing adds; in an animation, whose lines are resampled onto the frame grid, each marker sits on the grid vertex nearest its sample.

Pass antialias=False to draw raw straight segments between consecutive samples (the pre-1.1.0 behavior).

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_modelbool

If True, return a dict bundle {'fig': ..., 'colors': ..., 'xform_data': ..., 'trace_data': ..., 'trace_metadata': ..., 'animation': ..., 'pipeline': ..., 'models': ..., 'predict': ...} instead of the bare figure, where colors is the RESOLVED color scale this figure draws with (see below), xform_data is the normalized/reduced/aligned data, animation is the matplotlib.animation.Animation handle (None unless animate=True with the matplotlib backend) – this is the RAW handle, never wrapped in a HyperAnimation, so it has no .on_frame(); pass on_frame= to this call instead, which still fires regardless of return_model=.

colors is the color scale hypertools actually resolved, so a caller can reproduce this figure’s mapping in a companion panel instead of guessing at it: {'kind': 'continuous'|'discrete'| 'blend', 'palette': ..., 'cmap': <matplotlib Colormap>, 'norm': <Normalize or BoundaryNorm>, 'vmin': ..., 'vmax': ..., 'colors': <(n, 3) RGB array, discrete only>, 'labels': [...], 'categories': {label: rgb}}. It is built from the same inputs colorbar= is, at the same point in the pipeline, so the two can never disagree – but unlike the colorbar it is always present and never raises. HyperAnimation.colors (the object an animated call returns) exposes the identical dict.

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). For a COLUMN-hierarchical frame the pipeline is fit on the frame’s GROUPS, each as wide as ONE group, so it also remembers that grouping: calling bundle['pipeline'].transform(df) on a column-hierarchical frame groups it first and returns one array per group (before 1.1.0 this raised scikit-learn’s “X has 20 features, but IncrementalPCA is expecting 5 features” – or, when the reduce stage was a no-op because each group already had <= ndims columns, silently returned the ungrouped frame). A pipeline the caller passed in via pipeline= has that grouping recorded ON IT (in place – it is the same object the bundle hands back) when it does not already carry one of its own, so it re-applies on the same terms. Feature correspondence is BY NAME there too: the frame’s innermost column labels are matched to the ones the pipeline was fit on, so reordering them is harmless and naming different measurements raises. models holds the reduce/align/cluster/impute specs plus 'cluster_labels': the per-observation labels the figure was clustered by (every input dataset’s rows, in order; a mixture model’s component proportions; None without cluster=/n_clusters=). predict is None unless predict was set, in which case it is {'model': ..., 'params': {'t': t}, 'forecasts': [...]} (one forecast array per input dataset – or, for a HIERARCHICAL input, one per FINAL TRACE, i.e. per leaf and per derived per-level mean, each mean forecast from its own averaged trajectory – in the analyzed/plotted, pre-center/scale, space). For a COLLECTION of models (predict=['Kalman', 'ARIMA'], GH #285) 'forecasts' is a {name: [one forecast per dataset]} dict instead, exactly as hyp.predict(x, model=[...]) returns. Each bundled forecast has exactly t rows, matching what hyp.predict(xform_data, model=..., t=t) returns – in ndims=1 series mode too, where it is one (t, n_columns) array of VALUES per input dataset (the x positions the overlay is drawn at are the row index’s own continuation, one step per row, and are not part of the bundle); a hierarchy under ndims=1 keeps one (t, 2) [x, value] array per final trace, its documented unit. A hierarchy additionally requires at least 2 rows in EVERY final trace, on either axis, and raises otherwise; see predict.

xform_data vs trace_data. xform_data is the analyzed pipeline output, one entry per analyzed INPUT dataset. trace_data is the final PRE-CENTER/PRE-SCALE plotted trajectories – for a hierarchical input, the leaves followed by the per-level means, which are presentation artifacts built in display space and so are deliberately absent from xform_data. The two are the same object only when no display-only projection occurred; if a reduce= spec pins more than three components, xform_data keeps that many while trace_data is projected to the plotted dimensionality. Neither is what the artists hold: those are centered, scaled and (unless antialias=False) PCHIP-upsampled afterwards. Bundled forecasts always correspond to trace_data, so forecasts[i] matches hyp.predict(trace_data[i], model=..., t=t) for every i; they match hyp.predict(xform_data, ...) element-wise only when the two spaces coincide – the usual case.

trace_metadata is None for non-hierarchical input. For a hierarchy it describes every entry of trace_data positionally: {'keys', 'level_idx', 'is_mean', 'axis', 'level_names', 'aux'}. See docs/hierarchy.rst.

predict also carries 'drawn' (bool) and 'draw_reason' (None, or a sentence naming the limitation). The forecasts are reported whenever the FIT succeeded, whether or not the figure could render them: return_model= hands back model output, and throwing a valid result away because a rendering combination is unsupported would discard the very thing it exists to return. drawn is what keeps the two cases distinguishable.

There are three cases:

  1. A plain forecast (no hue=/cluster=), static or animated. Drawn, and drawn is True. For an ANIMATION the bundle is the END state: at the final frame the revealed history is the full history, so the full-history forecast the bundle carries is exactly the one the last frame draws; at every EARLIER frame the drawn forecast is computed from the history revealed so far and so is deliberately different. The bundle is not a per-frame record.

  2. A STATIC plot with hue= or cluster=. These regroup the drawn traces by category rather than by dataset, so there is no longer one trace per dataset – but there is still one forecast per dataset, each anchored at that dataset’s last observation and taking the style of the trace holding it, which is the trace it visually continues. That holds for a categorical hue=, a continuous hue= (where the color is taken at the anchor, since the trace has many) and cluster=. Drawn, and drawn is True.

  3. An ANIMATED plot with hue= or cluster=. Not drawn, and warns: the per-frame schedule maps frame-grid rows onto each DATASET’s raw observations, and regrouping leaves only per-run traces to reveal, with no per-dataset reveal to schedule against. drawn is False and draw_reason says why; forecasts still holds the full-history fit. Plot statically to SEE forecasts alongside hue=/cluster=.

The DRAWN overlay is not vertex-for-vertex the bundled array: it prepends the last observed row as a connector (t + 1 vertices), and with antialias=True (the default) it is then PCHIP-densified well beyond that, so a short forecast still renders as a smooth curve rather than a few straight segments. Pass antialias=False to draw exactly the t + 1 raw vertices. Default False.

Returns:
figmatplotlib.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(), .draw_frame(i), .on_frame(callback), .colors and .drawn_extent(frames=None, threshold=5) (the union bounding box of everything the animation draws, measured from rendered pixels in figure fractions – see HyperAnimation.drawn_extent) 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': ..., 'trace_data': ..., 'trace_metadata': ..., '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'