HyperTools 1.0: architecture refactor + bug hunt#272
Open
jeremymanning wants to merge 243 commits into
Open
Conversation
Reorganize dev-2.0 into the jeremymanning fork's structure (base class per area, folder per module, one file per child class); remove DataGeometry; adopt datawrangler for the wrangling core (hybrid); keep dev-2.0's plotting/animation/streaming/coloring. Classic API names + module aliases; polars support via dw; strangler migration keeping tests green per commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bite-sized TDD plan: declare pydata-wrangler dep + text extra, probe dw 0.4.0 API surface and behavior (stack/unstack, funnel over numpy/pandas/ polars/list, sklearn text embed), reconcile py3.13/CI, establish the data-wrangler issue-coordination workflow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…w API) Controller recon resolved the environment blocker and confirmed dw 0.4.0's surface: standardize on .venv (py3.12), pin pandas<3 (dw#30), use verified dw.wrangle text call, and smoke-check against the 242-pass baseline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…r#30) HyperTools 2.0 step 0: adopt datawrangler for the wrangling core. Adds pydata-wrangler>=0.4.0, a hypertools[text] extra -> pydata-wrangler[hf] (opt-in transformer embeddings), and a temporary pandas<3 ceiling because dw 0.4.0 type detection breaks on pandas 3.0 (data-wrangler#30). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Canonical list of dw symbols the 2.0 refactor depends on. Missing symbols become filed data-wrangler issues + xfail-with-link, not silent green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Proves the real round-trips Plans 2-6 depend on: MultiIndex stack/unstack, funnel generalization over numpy/pandas/list/polars, and sklearn text embedding via dw.wrangle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the refactor branch to CI triggers, documents the py3.13/dw status, and starts the data-wrangler issue-coordination log. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…strangler
Move exceptions to core (shim _shared); add eval-free unpack_model + RobustDict;
central config.ini via dw configurator; relocate apply_model to core.model as
source of truth (tools shim) + accept fork {model,args,kwargs} dict form.
Behavior-preserving; existing suite stays green. Deep arrays->DataFrames dw
conversion deferred to per-module plans.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…form
tools/apply_model.py becomes a shim; core.model is now the source of truth.
Adds {model,args,kwargs} spec support alongside {model,params}. Behavior
otherwise identical; existing apply_model tests unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nalysis 276 tests green. Captures the manip design questions surfaced from reading the fork sources: arrays vs DataFrames, Smooth=savgol-not-gaussian, Resample needs core.get, Normalize semantics reconciliation, fork bugs to validate/fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Move vendored ppca/srm to external/ (shim _externals); build Manipulator base + Normalize/ZScore/Smooth/Resample (DataFrame/dw-based, fork ports validated+fixed) + hyp.manip dispatcher (funnel-wrapped, applies Manipulator directly, not via array-based core.apply_model). hyp.normalize compat left untouched. Adds core.get. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…al; shim _externals Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fork ports validated/fixed: np.clip bounds, core.shared.get import, Series dtypes. Gaussian-smooth mode still owed (Plan 6, weights pipeline). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…Normalize/ZScore/Smooth) The axis==1 branch of each transformer self-called the module-level transformer name, which is the @dw.decorate.apply_stacked-decorated function. Because apply_stacked unconditionally re-stacks its input (adding a synthetic 'ID' row-index level even for a single DataFrame), transposing that already-stacked frame leaked the ID level into the columns, and the fitted params (keyed by the original, pre-stacking row labels) could no longer be looked up -- raising "key of type tuple not found and not a MultiIndex" for ZScore(axis=1), Normalize(axis=1), and Smooth(axis=1). Fix: keep @dw.decorate.apply_stacked only on the inner, always-axis==0 per-column core (renamed _transform_stacked); make the public transformer an undecorated dispatcher that transposes the raw, not-yet-stacked data and recurses into itself for the axis==1 case, so the stacking machinery never sees a transposed frame. resample.py's transformer/fitter carry no such decorator and were unaffected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ndas 3.0) dw 0.5.0 fixes data-wrangler#30 (pandas-3 type detection). Ceiling lifted: pandas>=2.2.0 (no upper), pydata-wrangler>=0.5.0, text extra [hf]>=0.5.0. CI gains a pinned-pandas-3 acceptance gate (ubuntu/py3.12). Validated: full suite 293 passed on dw0.5.0/pandas3.0.3/numpy2.3.5 (== 2.3.3 baseline, no regressions). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…m tools Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tools/procrustes Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ot carried) RobustSharedResponseModel omitted (external.brainiak vendors SRM+DetSRM only). test_rsrm_not_exported uses `from hypertools.align import srm` because the classic hyp.align callable shadows the hypertools.align attribute (chained-attribute import form unsupported by design; see Plan 7 top-level API item). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move hypertools/tools/cluster.py to hypertools/cluster/cluster.py, fix the format_data import to go through tools.format_data, and add hypertools/cluster/__init__.py exposing cluster/models/mixture_models. Recreate hypertools/tools/cluster.py as a re-export shim so core.model._build_registry (which imports models/mixture_models from hypertools.tools.cluster) keeps resolving. Classic hyp.cluster is unchanged (still resolves via the tools/ shim per __init__.py). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…argin test
QC 2026-07 bug hunt (animation):
- HyperAnimation.save('x.svg')/('x.png') crashed (raw Animation.save piped h264
into an svg/png), even though save_path='x.svg' works. .save() now routes
through hypertools' extension-aware _save_animation dispatcher (gif/png/apng/
svg/mp4/mov/avi) at the animation's own fps; an explicit writer= still
delegates to matplotlib.
- animate with duration<=0 or frame_rate<=0 raised ZeroDivisionError / a cryptic
"zero-size array" error; now a clear ValueError.
- FIXED a FALSE-POSITIVE test (test_wide_chemtrails_cube_corners_...): it failed
on the fork point too. Root cause was in the TEST, not the render -- it measured
margins by mixing _inked_mask's PHYSICAL buffer-pixel indices with the LOGICAL
get_width_height() size (2x mismatch on HiDPI), yielding impossible margins like
-512px on a "640px" canvas; and it asserted phantom projected cube corners that
ignore set_box_aspect(zoom=1.125). The render is fully on-canvas (verified: >=118px
ink margin across all 30 frames). The test now measures in the mask's own pixel
space and asserts the rendered-ink extent.
New tests/test_animation_save_hardening.py. All 19 animation-margins tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…wargs, text warning
QC 2026-07 bug hunt (edge cases + polish):
- empty (0-row) input crashed cryptically inside sklearn ("The 'n_components'
parameter ..."). format_data now raises a clear "input has no observations".
- predict(t<=0) silently returned an empty (0, n_features) forecast, and a float
t gave a cryptic error. t is now validated (positive integer, OR a target
datetime/Timestamp for a forecast-until-a-time horizon -- still supported).
- a streaming reduce spec read only the legacy 'params' key, so
reduce={'model':'PCA','kwargs':{'whiten':True}} silently used defaults; it now
accepts the canonical 'kwargs' key like every other dispatcher.
- the default text path (pretrained wiki topic model) emitted sklearn
InconsistentVersionWarnings on every plot; the model's persisted state loads
and transforms correctly across versions (verified numerically), so the
known-safe load is now silenced narrowly.
- load() docstring: 'weights' is a list of 36 per-subject arrays, not 2
(weights_avg is the 2-array averaged version).
New tests/test_edge_cases_hardening.py.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… errors Red-team of ca13d05 (PPCA splice) found the splice regressed the primary path: columns PPCA DROPS for having < min_obs observations were left NaN, which then crashed hyp.reduce/hyp.plot with "Input X contains NaN" on sparse-column data (the pre-splice reconstruction was dense). Now fill each still-missing position with its column's observed mean (0.0 for an all-missing column) so the imputed matrix is dense, while observed values stay exact and fully-missing rows are still re-masked to NaN (documented limitation, pinned by test_ppca_warns_and_leaves_nan_on_fully_missing_rows). Also: single-column (< 2 valid columns) PPCA raised a cryptic LinAlgError ("0-dimensional array") from np.cov -> now a clear ValueError pointing at Kalman/SimpleImputer/KNNImputer; and the reuse-path column-count check used `assert cond, ValueError(...)` (raises AssertionError, stripped under -O) -> proper raise. +2 regression tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ze docs Red-team of 3072545 found the documented analyze(cluster=...) label-recovery path -- named_steps['cluster'].transform(returned_data) -- raised NotImplementedError for the 3 hard clusterers with no out-of-sample predict (DBSCAN, AgglomerativeClustering, SpectralClustering). analyze() itself was fine (it returns the transformed data and recovers labels internally), but the documented convenience recipe broke for those clusterers. Now the cluster step's transform() returns the stored fit-time labels_ when it is handed data with the same number of rows it was fit on (the recovery case) -- so recovery works for EVERY clusterer. Genuinely new data (a different row count) still raises NotImplementedError, since a hard clusterer has no defined labels for unseen points without refitting. Also corrected the analyze docstring to say "pass the RETURNED transformed data" (not the raw input, whose feature count differs after a reduce step) and to steer multi-dataset users to cluster() directly for per-dataset labels. +6 regression cases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…A hang cap Red-team of 7d71975 found the input-hardening fixes were incomplete: - align({'model': 'Nope'}) (the DICT spec form) slipped past the bare-string "unknown align model" guard and still hit the cryptic AttributeError ("'str' object has no attribute 'fit_transform'"). Extracted the guard into _reject_unknown_aligner() and applied it on both the dict and bare-string paths. - A 0-d ndarray (np.array(5)) has ndim 0, so the 1-D reshape left it untouched and it later raised an opaque "tuple index out of range" on i.shape[0]; a scalar is now one observation with one feature ((1, 1)), consistent with [5] -> (1, 1). - A scalar hue (hue='red' or hue=5) became a 0-d array and was mis-measured as len('red') == 3 characters ("hue has 3 entries but data has 20"); it now broadcasts to one group per observation. - The external PPCA EM loop was `while True` with no cap, so small/sparse or ill-conditioned NaN inputs (and a NaN convergence diff) could spin it forever (red-team saw >25s hangs). Added max_iter=500 + a non-finite-diff break with a clear "did not converge" warning; normal scattered-NaN data converges in far fewer iterations, so this only bounds genuinely degenerate fits. +6 regression cases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…se-insensitive backend Red-team follow-ups (be0dcb5, edf6049): - predict(t=...): np.True_ is np.bool_ (not Python bool) and 0-d numpy arrays (np.array(5)) both slipped past the horizon validation and hit a misleading "a datetime-like t requires a DatetimeIndex" message downstream. Now a 0-d array is normalized to its scalar and np.bool_ is rejected alongside bool. - predict.common.resolve_t used `assert isinstance(index, DatetimeIndex), ValueError(...)` -> raised AssertionError and is stripped under `python -O`. Converted to a real raise. - set_interactive_backend('Plotly') / plot(backend='Plotly') (capitalized) were treated as unknown mpl backends -- reproducing the exact HypertoolsBackendError the render routing exists to prevent. Render-backend detection and resolve_backend() now match case-insensitively and store the canonical lowercase preference. +6 regression cases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ults)
Evidence for the release-hardening pass and its independent red-team:
- evidence/hardening/{b2_1d_array,b2_flat_list,b5_align_mismatched_cols,
k1_matplotlib}.png -- "after-fix, now works" screenshots (visually verified);
- evidence/hardening/RESULTS.txt -- numeric verification (observed-preservation
1e-9, reduce dict+ndims matches sklearn, backend types, gif magic bytes);
- PR_EVIDENCE.md -- per-batch reproductions, red-team verdicts, fix rationale;
- pr_comment.md -- the PR #280 summary comment;
- gen_hardening_evidence.py -- reproducible evidence generator;
- BUGHUNT.md -- the confirmed-defect ledger.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…point hues A hue='d surface used to be painted ONE flat color -- the mean of all its points' hue colors (e.g. gray for a rainbow hue), so the surface ignored WHERE each color was (see before/after in notes/.../evidence/surface/). Now each mesh vertex is colored by an inverse-distance-weighted (Shepard/IDW) blend of the enclosed data points' colors, so the nearest coordinates dominate a vertex's color and the surface matches the hue of the points it wraps. - meshutil.vertex_colors_from_points(verts, points, point_colors): (V,3) IDW per-vertex RGB (weight 1/dist**2); face_colors_from_vertex_colors averages a triangle's three vertex colors for the matplotlib per-face path. - _blinn_phong_shade already broadcasts a per-element base_rgb, so per-vertex (plotly) / per-face (matplotlib) colors flow through the existing lighting unchanged; _blend_toward_white is now array-aware. - plot.py bundles each surfaced dataset's (points, per-point hue colors) as surface_point_colors and threads it to both backends' static 3D draw; None (no hue, or no surface) falls back to the prior flat surface color, so non-hue surfaces are unchanged. Verified both backends (matplotlib screenshot; plotly Mesh3d vertexcolor now has 8740 distinct colors vs 1). +15 tests (IDW unit tests + spatial-correctness integration for both backends). Full surface/mesh/plot suites: 163 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The story-trajectories gallery example ships an animated gif thumbnail (docs/_static/thumbnails/sphx_glr_plot_story_trajectories_thumb.gif, 80 frames) and sets sphinx_gallery_thumbnail_path to it, but post_build.py's GIF_REPLACEMENTS map -- which swaps each animated example's static .png thumbnail for its .gif in the built gallery HTML (run as a Read-the-Docs post_build job) -- never listed the story example. So on the live docs the story card showed a frozen still frame, not the animation. Added the story png->gif entry. Verified end-to-end against a local `sphinx-build` + `post_build.py` run: auto_examples/index.html now references sphx_glr_plot_story_trajectories_thumb.gif (0 remaining .png refs), the gif is copied into _images/, and tutorials.html embeds it. The example script, tutorial (docs/tutorials.rst, in the toctree), and source gif were already correct; auto_examples/ build artifacts are regenerated from source by RTD, so no committed-build change is needed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…oring Red-team of 8e6ed68 noted that when BOTH hue= and an explicit surface color= (e.g. surface={'color': 'crimson'}) were given, the per-vertex hue coloring was built unconditionally and silently overrode the explicit color. An explicit color should win (principle of least surprise); hue only colors surfaces that inherit their color (color=None). Now surface_point_colors is built only for datasets whose surface spec has no explicit color, so an explicit color falls back to the flat-color path. +1 regression test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… cards The story-gif red-team's completeness audit found a second frozen gallery thumbnail: animate_surface_morph ends with a static-figure tweak (an alpha-fade of the point layer), so sphinx-gallery thumbnailed that frozen frame as a png instead of the morph animation -- and, unlike story, it had no shipped gif at all. Ship a right-sized animated gif thumbnail (docs/_static/thumbnails/sphx_glr_animate_surface_morph_thumb.gif, 90 frames, 524 KB, subsampled from the full 360-frame render), point the example's sphinx_gallery_thumbnail_path at it, and register the png->gif swap in post_build.py. Verified end-to-end: built index.html now references the gif (0 png refs) and the gif is copied into _images/. Added tests/test_docs_thumbnails.py to keep post_build.GIF_REPLACEMENTS and the shipped _static/thumbnails/*.gif set in lockstep, so a shipped-but-unregistered (or registered-but-missing) gif -- the exact defect behind both story and surface_morph -- fails CI instead of silently freezing a gallery card. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…h gif fixes
- evidence/surface/{before,after}_mpl.png: surface goes from one flat mean color
(gray for a rainbow hue) to per-vertex distance-weighted point colors.
- evidence/surface/story_gif_frame70.png: a frame of the animated story-
trajectories thumbnail (confirms real animated content).
- pr_comment_2.md: the PR #280 evidence comment for both fixes + the
surface_morph follow-up the red-team's completeness audit surfaced.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tions animate='spin' and 'serial' passed frame_rate*duration straight to FuncAnimation as the frame count; with a fractional duration (e.g. duration=2.5) or fractional frame_rate that product is a float, and matplotlib does range(frames) -> "'float' object cannot be interpreted as an integer". The parallel/window styles already used an int count (x[0].shape[0]); the spin/serial (3-D and 2-D) paths and the morph total_frames now round to an int too. Surfaced while regenerating the story-trajectories animation with a non-integer duration. +regression tests (spin/serial/parallel, both backends). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…D, IncrementalPCA) Regenerated the story-trajectories demo per maintainer feedback. The old version was choppy, slow, and -- most importantly -- the subjects' trajectories did NOT move together, a real alignment failure. Root causes and fixes: - ALIGN IN THE LOW-DIMENSIONAL SPACE. Hyperaligning a bare 3-D UMAP embedding barely improved inter-subject clustering. Now reduce to a ndims=10 IncrementalPCA space and hyperalign THERE (n_iter=10), then show the first 3 aligned dims. Measured with a scale-free within-timepoint dispersion (subjects' spread around their shared centroid per timepoint, / cloud scale), hyperalignment tightens the cloud ~18%: 0.88 (no align) -> 0.73 (aligned). - INCREMENTALPCA, NOT UMAP. UMAP's nonlinear warping left trajectories jumpy: the largest per-step jump (normalized) is ~3.3 for UMAP vs ~0.37 for IncrementalPCA (~9x smoother) -- the difference between a choppy and a smooth animation (also strengthened smoothing to kernel_width=40). - SMOOTHER + FASTER. animate='spin' over full trajectories (a spin's frame count is independent of the number of timepoints, so the 600-sample resample costs nothing), 9 s instead of 30 s. (Plain inter-subject correlation is NOT used as the headline metric: a jumpy UMAP embedding can score HIGH correlation while looking scattered/choppy, so dispersion + smoothness are reported instead -- per the animation red-team.) Updated the gallery example (docstring, exact code, three camera-angle stills), tutorials.rst, the mp4 (6.9MB -> 2.0MB), the gallery gif thumbnail, and added scripts/generate_story_trajectories.py so the assets are reproducible from a committed, deterministic script. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- evidence/story/story_trajectories_new.gif: the regenerated spinning animation (align in low-D IncrementalPCA space, n_iter=10). - evidence/story/benchmark_reference.gif: the maintainer's quality reference. - pr_comment_story.md: PR #280 comment with the scale-free dispersion + smoothness metrics (correlation retracted per the red-team) and the red-team verdict (ACHIEVES THE CORRECT ANIMATION). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Maintainer feedback: the trajectories were NOT well aligned -- a spaghetti tangle instead of the reference's single coherent shared shape -- and the reference uses a 'window' animation, not 'spin'. ROOT CAUSE of the poor alignment: the previous version reduced each subject to a 10-D IncrementalPCA space and hyperaligned THERE, then showed 3 dims. Hyperalignment rotates each subject onto a shared response and needs room to do it; 10 dims (let alone 3) starves it. The fix is to hyperalign in the full 100-hub feature space FIRST, then reduce to 3-D for display. Measured within-timepoint dispersion (subjects' spread around a shared centroid per timepoint, / cloud scale; lower = tighter): 0.73 (reduce-then-align, old) -> 0.51 (align-in-hub-then-reduce). The 100-hub space IS the low-dimensional space to align in (it already summarizes ~hundreds of thousands of voxels); the mistake was over-reducing further before aligning. Also switched animate='spin' -> animate='window' (focused=2.5): a sliding trail traverses each aligned trajectory so all 36 subjects are seen moving together through the story, matching the reference style. Updated the example (docstring + exact code + three story-moment stills), tutorials.rst, the mp4, gallery gif thumbnail, and the deterministic scripts/generate_story_trajectories.py. Pipeline is deterministic (verified max|delta|=0 across runs). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ipelines reusable cluster(X, cluster='KMeans', reduce='PCA', manip='ZScore', return_model=True) -- with no ndims -- returned a Pipeline whose reduce STEP was never marked fitted, so p.transform(X) / reusing p (cluster(Y, cluster=p, ...)) crashed NotFittedError: "reduce stage must be fit before transform". Root cause: hyp.reduce(x, reduce='PCA', ndims=None) (or ndims >= n_features) is a legitimate no-op and returns model=None; _DispatchStep used `_fitted is None` to mean "never fit", conflating it with "fit to an identity". A no-op stage now records that fit_transform ran (_is_fit) and, on transform, passes data through unchanged instead of raising. Affects any build_pipeline stage that fits to no model (reduce/normalize no-ops). reduce(..., return_model=True) was already fine; this brings cluster/analyze into line. +2 regression tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… of every frame Per-point labels= in an animation were created once as static annotations and left visible on EVERY frame (a documented "known limitation" punted in prior sessions): the label sat at its frame-0 screen position the whole time, regardless of whether its datapoint was currently drawn or where the rotating camera had moved it. Now each label records its within-dataset point index (annotate_plot/add_labels), and a per-frame _sync_anim_labels(): - shows a label ONLY while its datapoint is inside the current drawn window ([num - window_frames, num]) for 'window'/'parallel'/'serial'; - keeps labels shown for 'spin' (every point is always drawn); - reprojects every visible label for the current (rotated) camera. Static plots are unchanged (labels always visible). The module-global labels_and_points is looked up safely and filtered to the current axes, so label-free animations (and back-to-back plots) don't crash or show a prior plot's labels. Verified numerically (label visibility scrolls with the window) and with rendered frames. +2 regression tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lyze/plot
Stochastic stages (UMAP/TSNE/MDS reductions, KMeans/GaussianMixture clustering)
were only reproducible through the verbose dict spec
reduce={'model':'UMAP','kwargs':{'random_state':1}}. Added a top-level
`random_state=` to reduce(), cluster(), analyze() and plot(): it is injected
into any stage model whose constructor accepts a `random_state` (checked via
inspect.signature), so `hyp.plot(x, reduce='UMAP', random_state=0)` and
`hyp.cluster(x, cluster='KMeans', random_state=0)` are repeatable. Deterministic
models (PCA/IncrementalPCA), density clusterers (DBSCAN/HDBSCAN/...) without a
random_state param, and already-constructed instances are left untouched; an
explicit random_state in a dict spec's kwargs still wins. Threaded through
build_pipeline so the cross-module (analyze/plot) pipeline reduce+cluster stages
are reproducible too. Documented on reduce() and plot(). +7 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Red-team of 52bcff8 found that fix only wired the per-frame label sync into 3-D 'parallel' and 'spin'; 3-D 'serial', ALL 2-D animations, and 'morph' still drew per-point labels on every frame (and the docstring/commit over-claimed 'serial'). Completed it: - 3-D and 2-D 'serial': labels reveal CUMULATIVELY -- a label appears once its GLOBAL index (across concatenated datasets) has been revealed and then stays; labels now store _hyp_global_idx as well as the within-dataset _hyp_point_idx. - 2-D 'window'/'parallel': same head-window logic as 3-D. - 'morph' (2-D and 3-D): the single traveling cloud does not correspond to the original labeled points, so per-point labels are hidden for the morph. _sync_anim_labels now takes explicit revealed=/hide_all= modes and its docstring describes every style accurately. +4 regression tests (serial multi-dataset global-index mapping, 2-D window, morph-hidden). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n lines) The animation red-team confirmed the alignment fix (subjects move together) but noted the render was still a dense muddy knot vs the reference's legible shape. Tuned the visuals: shorter window (focused 2.5 -> 1.5), translucent (alpha 0.55) and thinner (lw 1.3) lines so the individual aligned ribbons show through instead of blending into a blob. Regenerated the mp4, stills, and gif thumbnail from the (updated) deterministic generation script. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…spersion Directly compared against the reference hypertools.gif: it uses OPAQUE, bold, saturated lines, and because the subjects are tightly aligned the overlapping ribbons read as one coherent shape. The previous translucent thin lines (alpha 0.55) instead blurred into haze -- moving AWAY from the reference. Went opaque + bold (alpha 0.85, lw 1.6); the short window (focused=1.5 s -> 45 of 300 frames) is what keeps the overlap legible, not transparency. Regenerated mp4/stills/thumbnail. Also reconciled the dispersion figures to a single canonical, deterministic computation on the displayed 3-D coords (0.78 unaligned / 0.75 reduce-3D-then- align / 0.64 reduce-10D-then-align / 0.47 align-in-hub-then-reduce), replacing the earlier ~0.73->~0.51 estimates in the example and generation script. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Second animation red-team confirmed the window style matches but flagged the gist_rainbow palette as gaudier/more cartoonish than the reference and its individual strands as popping out of the bundle. Switched to seaborn's 'husl' palette -- the classic HyperTools palette of evenly-spaced but tempered hues (magenta/teal/gold/slate/coral), matching the reference hypertools.gif. Verified numerically that the alignment itself is already tight and correct (not the cause of the visual "outlier" loops): hyp.reduce on a list uses a SHARED projection (dispersion 0.466 vs 0.464 for a hand-fit shared PCA), and every subject's trajectory mean sits within 0.04*scale of the grand centroid. The occasional loop outside the bundle is one subject's momentary window segment, not a displaced/misaligned subject. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PR-comment draft with the story-render match (opaque bold husl vs reference), the 3 code fixes, verified dispersion table, both red-team verdicts (story: MATCHES REFERENCE after 3 passes; code: FIX1/3 SOLID, FIX2 completed), and the final CI result (1485 passed / 0 failed / 4 skipped / 7 deselected). Evidence gif regenerated from the husl mp4. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
examples/plot_hue.py (the original author's example) passes hue as one sub-list per dataset — the classic list-of-lists form matching a list-of-datasets input. The 1.0 hue validation flattened the DATA to n_obs but read np.asarray(hue) on the (n_datasets, len) sublists as a 2-D matrix hue, so it raised "hue has 3 entries but the data has 900 observations". Now a nested hue whose top level matches the number of datasets and whose sub-sequences match each dataset's length is flattened to one value (or matrix row) per observation before classification; genuinely flat / (n_obs, k) matrix hues are untouched, and a nested hue with mismatched sub-lengths still raises (no silent truncation). +4 regression tests (nested-scalar, nested-matrix, flat/matrix-unaffected, wrong-length-still-errors) and a docstring note. Found by directly executing all 53 gallery examples. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
All 53 example scripts + 10 core tutorial notebooks executed end-to-end; records the plot_hue nested-hue fix and the animate_plotly known-limitation finding. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drops notes/fix-qc-notes-2026-07/ (BUGHUNT/RESUME/PR_EVIDENCE, per-fix evidence screenshots + gifs, triage repro scripts, PR-comment drafts) -- review-only artifacts that shouldn't land in dev-1.0-refactor. All code fixes, tests, examples, docs, and asset-generation scripts are kept. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
QC-notes fixes for HyperTools 1.0 (do not merge — for review)
CI intermittently failed test_load_538_multi_csv_returns_dict on a transient "502 Bad Gateway" from api.github.com (a shared CI runner-IP pool hitting the API concurrently), on both the folder-listing call and the authenticated per-CSV fetch. A 502/503/504 is a server-side blip, not a client error, and clears within a second or two -- so the loader now retries transient gateway errors (and connection-level RequestExceptions) with exponential backoff via a new _github_get_with_retry helper, used at both call sites. Non-transient responses (2xx/404/403-rate-limit) still return immediately, so healthy calls and every error message are unchanged. Tested with a REAL local loopback HTTP server (a real socket, not a mock) that returns 502 a few times then 200: one test proves the retry recovers, another that a persistent 502 is returned after the retries are spent so the caller's existing HypertoolsIOError/raise_for_status still fires. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
HyperTools 1.0 — architecture refactor + bug hunt
This PR merges the completed HyperTools 1.0 class-based refactor into
dev-1.0, together with a broad bug hunt (open-issue triage + two animation-rendering fixes you reported + a legend-clipping fix) and the CI fixes needed to get all platforms green.1. Architecture refactor (Plans 1–8)
Reorganized the working
dev-1.0code into the class-based structure, on modern deps, withDataGeometryremoved from the public API:core(eval-freeapply_model),external(vendored PPCA + brainiak SRM/DetSRM/RSRM),manip(Normalize/ZScore/Smooth/Resample),reduce/align/cluster(generic sklearn dispatch by name),io,plot(matplotlib + plotly backends). Oldtools/names remain as shims.DataGeometryremoved from the public API (kept only as a hidden unpickle shim so legacy hosted.geodatasets still load).plot()returns a Figure /(fig, ani);plot(..., return_model=True)returns{'fig','xform_data','models','animation'};load()returns raw data.2. Reported animation fixes
Animated bounding box "crowded / cut off on the right." Animated 3D plots zoomed the cube in too far. Fix:
set_box_aspectzoom 1.25→1.125 + full-canvas axes. Min cube-to-edge margin over the fullsave_movierotation: right 80→96px, bottom 51→72px.Duplicate animation-legend entries (GH #207). Faint "trail" artists carried the dataset label, so each label appeared twice. Fix: trails tagged
_nolegend_; legend is now the static union of in-focus datasets. Verified['first','second'].Clipped static gallery legends. Wide legends (long labels / many entries) clipped off the right edge. Root cause: the fit routine measured the legend under seaborn's (narrower) font, but the figure is saved downstream under the default (wider) font. Fix:
_fit_right_legendnow measures the rasterized pixels under default rcParams and widens the figure (keeping the plot's size) until the legend has a margin. Regeneratedplot_legend/plot_PPCA/plot_missing_data; pixel-based regression test added.3. Open-issue triage → close-on-merge
The triage below has since been fully executed (2026-07-07): all 45 addressed/obsolete issues are CLOSED with per-issue run-code evidence comments; the 22 remaining feature requests carry research comments +
low/medium/high effortlabels; 6 issues were migrated from the jeremymanning fork (#273–#278, fork tracker now empty); and 6 residual gaps found during re-verification were fixed on this branch (#94, #141, #199, #206, #209, #244). See the audit summary comment. Originally: triaged all 67 open issues against this branch with real repros (seenotes/issues-to-close-on-merge.md): 31 addressed/obsolete + 16 fixed or implemented on this branch (incl. §5's #169/#132 and §6's seven features) = 47 to close on merge; 20 stay open (feature requests / design decisions), each documented.Bugs fixed from triage (all regression-tested):
import hypertoolsmutated globalrcParams['pdf.fonttype']manage_backend's snapshot/restoreget_projcrash on 2D labeled plotsget_proj; match annotate/update tuple shapes for 2D vs 3Dn_clustersn_clustersonly when the model's signature accepts it; register those clusterersshow=Falseleaked the figure into pyplotplt.close(fig)for static figures (skipped when the user passedax, and for animated figures, whose timer must stay alive)format_dataaligns named columns BY NAME to the first dataset's order (warns on reorder); mismatched column sets raise a clear ValueErrorwiki_modelkeyreduce=<class/instance>→UnboundLocalErrormodel_paramsin the custom-estimator branch4. CI fixes (get all platforms green)
The first CI run surfaced two platform issues (unrelated to the features above); both fixed:
os.getenv('HOME')at import time to build its data dir;HOMEis unset on Windows, soos.path.join(None, …)crashed dw's (and hypertools') import. Fixed hypertools-side by settingHOME=expanduser('~')before importing dw; filed upstream as Import crashes on Windows: config datadir evals os.getenv('HOME') which is None data-wrangler#32, fixed in dw 0.5.1 (released; verifiedimport datawranglerworks withHOMEunset). Thepydata-wranglerpin is bumped to>=0.5.1; the one-lineHOMEguard stays as belt-and-suspenders for environments still on 0.5.0.plt.close()(the disabling the figure doesn't work as intended #148 fix), so tests/callbacks that readfig.canvas.renderer/buffer_rgba()failed. Fixed by guarding the renderer inupdate_positionand rendering the affected tests through an explicit Agg canvas. (savefigafter close still works, so users are unaffected.)plt.close()destroyed theFuncAnimation's real Tk timer and any later draw of the returned figure crashed ('NoneType' object has no attribute 'start'). Animated figures are now exempt from theshow=Falseclose — the disabling the figure doesn't work as intended #148 complaint was about static figures, and animations need their timer alive for playback.plt.get_fignums(), which the disabling the figure doesn't work as intended #148 close empties; it now uses the figure(s) returned byplot()(13/13 cases pass).5. New:
hyp.predict+hyp.impute(resolves GH #169)Two new modules in the established class-based style (base class + one file per model + funnel dispatcher), integrated into
hyp.plot/hyp.analyzelike cluster/align:hyp.predict(data, model=..., t=...)— timeseries forecasting:Kalman,GaussianProcess,AutoRegressor(any sklearn regressor, recursive multi-step),ARIMA,Laplace(skaters ensemble),Chronos(HuggingFace foundation model, realchronos-t5-tinytest).tfollows Use Kalman filter to fill in missing data #169's spec (int steps, or a datetime on time-indexed data — including past-date truncation). One forecast per input dataset, same dimensions, continued index.hyp.impute(data, model=...)— missing data:PPCA(default; clean interface over the vendored implementation —format_data's fill now routes through it, behavior-preserving),SimpleImputer/KNNImputer/IterativeImputer, andKalman, which fills rows where every feature is NaN — the exact gap Use Kalman filter to fill in missing data #169 describes (PPCA cannot).return_model=Trueon both →(result, fitted)matchingapply_model's convention; the fitted model can be passed back asmodel=on new data and is applied without re-estimation (verified: fit on A, forecast/impute B).hyp.plot(data, predict='Kalman', t=30)overlays one dashed, low-opacity, same-color forecast tail per dataset (2D + 3D, both backends, no legend duplication, frame always contains the forecasts).impute=selects the missing-data model in the plot/analyze pipeline.[predict]extra (pykalman, statsmodels, skaters) and[predict-hf](chronos-forecasting); GaussianProcess/AutoRegressor/sklearn imputers work on the base install; friendlyImportErrors otherwise (fresh-venv verified). yfinance is not a dependency — the tutorial self-installs it.stock_forecasting.ipynb— scrapes 2y of real Yahoo Finance prices for 4 tickers, backtests all models against a 30-day holdout with an honest MAE/MAPE table (spoiler: ARIMA/Kalman ≈ the naive baseline, as efficient-market theory predicts — the tutorial says so).projectile_kalman.ipynb— a real NBA SportVU jump-shot arc (25 Hz optical tracking): Kalman imputation of 5 fully-occluded frames recovers them to RMSE 0.20 ft vs the recorded truth; forecasting the arc's final 20 frames from the first 30 lands within MAE 4.2 ft.DotProduct + RBF + WhiteKernel— the old stationary RBF reverted forecasts to the training mean beyond the data (drift −0.026/pt vs observed +0.0019/pt: reversed); the linear term extrapolates trends (+0.002..+0.010/pt: continues). Before/after renders + measurements in this comment.plot_predict(helical forecasts) +plot_impute(PPCA-vs-Kalman panels on the Use Kalman filter to fill in missing data #169 case); API reference sections added.6. Seven long-standing feature requests (GH #95, #100, #108, #109, #127, #142, #177, #191)
All seven implemented in the 1.0 design language, in both rendering backends, each with numeric + screenshot evidence in this comment:
colorbar=True/dict — continuous hue (same palette as the lines, real value range) and discrete groups (segmented, labeled); coexists with legends without clipping.surface=True/dict — smooth, lit convex-hull surfaces (3D, Blinn-Phong + plotly vertex shading) and smooth filled shapes (2D), dict-controlled properties, animatable (per-frame hull recompute); the axes cube auto-expands to contain the hull. Newanimate_surface_morphgallery demo.density=True/dict — subtle per-group KDE clouds (off by default; 2D alpha-ramp, 3D iso-surfaces/go.Volumewith small-cluster auto-boost); enhancement: replace matplotlib with ipyvolume #191's ipyvolume ask is superseded by the plotly backend.chemtrails/precog/bullettimeaccept per-dataset lists (mixed trail styles in one animation); spin/serial warn instead of silently ignoring.colors=/linestyles=/markers=were silent no-ops without their singular twins — fixed.hyp.load#177hyp.loadcompleted: Drive large-file interstitial (verified against a real 498MB public file),.xlsx/.xls, Google Sheets→CSV, remote-pickle trust policy.Every graphical feature was adversarially screenshot-reviewed by fresh agents across a {2D,3D}×{mpl,plotly}×{static,animated} grid; their findings drove 6 further fix commits (plotly WebGL surface artifacts, bounding-box containment, MultiIndex colorbars, legend fitting, 3D density visibility, trail-mode warnings). New gallery examples:
plot_surface,plot_density,plot_colorbar,plot_multiindex,animate_trails_mix,animate_surface_morph.Maintainer-feedback rounds (tight hulls + morph, constant rotation speed + plotly parity + lighting controls, axes-clipping + gif corruption + full-sample morphs, multibyte text support, GH #205): hulls now hug the observations (hull-hugging smoothing: post-Taubin pull-back to the hull; cube-cloud oversize 1.63×→1.13×, ≥99% containment; axes box sized from actual meshes incl. mid-morph union bound, both backends) and morphing is a first-class animation style —
animate='morph'(Hungarian point-cloud morphs between datasets, tagged-list form for static backdrops) with per-segmentrotationslists ([1, 0.25, 2, ...]= per hold/transition). Both shape-morph gallery demos collapsed to singlehyp.plotcalls. Morph rotation speed is constant (segment duration ∝ rotation count); plotly marker sizes are empirically calibrated to matplotlib (15.1px → 5.0px for markersize=6) and volumetric shading retuned to matplotlib's subtlety; every surface color/lighting/shading knob is verified effective on both backends (incl. newlightdir; silent no-op keys removed). The "cut off bounding box" report was traced to two real rendering bugs, both fixed: 3-D scene artists were clipped at matplotlib's aspect-shrunk square viewport (now unclipped in every animation path), and animation GIFs saved at reduced dpi were corrupted by a matplotlib writer resize through the interactive-backend window (saves now dpi-safe). Morph animations use every dataset's full sample set (duplicate-to-largest, duplicates hidden at holds; hold frames are a 100% pixel match to static plots). GH #205 fixed: full multibyte (CJK) text support in both backends — automatic covering-font detection (excluding placeholder fonts like LastResort) + afont=kwarg (family/path/FontProperties) applied to labels/legends/colorbars/titles; plotly also gained reallabels=annotations (was a silent no-op); CI provisions CJK fonts on all platforms with anti-tofu pixel tests.7. Round 17 — every non-deferred open issue addressed (GH #103 #116 #123 #130 #138 #153 #154 #159 #161 #162 #174 #187 #198 #227 #273 #274 #275 #276 #277 #278)
Following the per-issue "current status (1.0 update)" triage, this round implements all 20 non-deferred open issues (the 5 marked defer/don't-implement were left untouched). Highlights: a unified cross-module API (every dispatcher takes
manip=/normalize=/reduce=/align=/cluster=/return_model=, canonical order documented as a flowchart indocs/pipeline_order.rst) + publichyp.Pipelinewithpipeline=reuse (#138 #153 #227 #161 #174); manip list-chaining and the story-trajectories animation —animate='window'/focused=/duration=(#274 #275, post-align inter-subject correlation −0.004→0.33, jumps 18.96→0.73);label_alpha=/axis labels/animate=dict/2-D animations (#103 #154 #123); six torch autoencoder reducers, sklearn/seaborn/538/kaggle loaders, LSL streaming, gensim text wrappers (#162 #273 #116 #130 #198, all opt-in extras); and a full docs pass — docstring coverage 131→0 with an enforcement test, completeapi.rst, 7 new tutorials, regenerated README media (#276 #278 #159 #187 #277).Executed as 19 review-gated tasks; the reviews caught and fixed five real reuse-contract bugs (silent pipeline refit,
Aligner/SRM/Resampletransform replaying fit-time data). Per-issue evidence comments (code + exact new API + numeric/screenshot proof) are posted on all 20 issues; see the round-17 summary comment for details.Testing
1271 passed, 0 failed(+492 tests across all rounds: surface/density/colorbar/multiindex/trails/meshutil/load/color-alias/morph-animation/hull-tightness suites); 6 plotly→kaleido image-export tests deselected locally only — they deadlock Chromium in this sandbox but run fine in CI.8c40499a-- run 28903254424make htmlsucceeds); gallery regenerated with the 6 new example pages (including two captured animations).🤖 Generated with Claude Code