Skip to content

perf: disaggregate unsegmented clusterings with a numpy gather - #116

Merged
FBumann merged 2 commits into
mainfrom
perf/disaggregate-gather
Jul 27, 2026
Merged

perf: disaggregate unsegmented clusterings with a numpy gather#116
FBumann merged 2 commits into
mainfrom
perf/disaggregate-gather

Conversation

@FBumann

@FBumann FBumann commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Closes #115.

Expanding an unsegmented clustering back to the full time axis is a gather along the cluster axis. The wrapper expressed it as a pandas round-trip — build a MultiIndexed DataFrame, hand it to tsam's disaggregate() (which does unstack.loc[assignments]stack), convert back — once per slice-dim combination. Callers expand every variable of a solution, so a 12-period model paid n_variables × 12 round-trips for what is one gather.

The gather now runs in numpy across all slices at once.

Results

365 days, 36 clusters, 100 variables:

before after
no slice dims 481 ms 20 ms 24x
12 slices 6.15 s 146 ms 42x

Why not just wait for the upstream tsam fix

FBumann/tsam#61 makes tsam's own disaggregate() ~22x faster. It is worth having, but it does not replace this. Keeping the per-slice wrapper loop and swapping in a shim that does exactly what that PR does:

100 vars today upstream only this PR
no slice dims 387 ms 25 ms (15x) 17 ms (23x)
12 slices 5.05 s 511 ms (10x) 87 ms (58x)

Without slice dims they land in the same ballpark. With slices, upstream alone leaves 511 ms on the table, because once tsam is fast the remaining cost is the wrapper: 12 DataFrame constructions, 12 cr.disaggregate() calls, 12 DataArray builds, one xr.concat. No upstream change removes that loop.

They also do not overlap. This PR bypasses tsam for unsegmented clusterings, so when #61 lands its win goes to the segmented path — which this PR deliberately leaves alone. Nothing to unwind later, and no tsam version gate.

Fallbacks

xarray guarantees the regular grid the fast path needs — a dense cluster × timestep block, equal block sizes, one shared timestep order — so the structural guards a pandas-level fix needs are unnecessary here. The path declines only what it cannot express as one rectangular result: segmented clusterings, and slices whose stored time indices disagree. Both fall back to the per-slice tsam path unchanged.

Two things a patch here has to preserve

  • Dim order. The per-slice loop plus _concat_along_dims leaves slice dims leading: (*slice_dims, time, *other_dims). The naive vectorized form yields (time, *slice_dims) — identical under xarray comparisons, but .values silently transposes from (12, 8761) to (8761, 12), breaking positional access downstream.
  • Cluster labels are labels, not positions. _expand_periods indexes with .loc[list(cluster_assignments)], so assignments are cluster labels and must be mapped to positions rather than used as indices.

Verification

  • Differential harness against the previous implementation on 19 shapes: slice-dim counts, segmentation, custom DimNames, JSON round-trip, shuffled cluster and timestep coords, int/float32/bool/NaN payloads, extra trailing dims, missing time_index. Values, dtype, shape, dim order and coords identical in every case.
  • Error type and text for mismatched cluster IDs unchanged. The timestep-count message differs slightly — data has 12 timesteps per cluster rather than tsam's cluster 0 has 12 timesteps, since in xarray all clusters share the axis.
  • 546 tests pass; 4 new ones cover the dim-order trap, label-vs-position, dtype preservation, and both error paths. ruff and mypy clean.
  • Benchmarks added for the sliced and segmented cases, so the sliced win and the still-tsam-bound segmented path are both tracked.

AggregationResult.disaggregate() now delegates to the identical loop on ClusteringResult instead of building a per-slice result view.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Improved disaggregation for sliced and segmented clustering results.
    • Preserves slice dimension ordering, cluster labels, and integer data types.
    • Supports faster processing for compatible unsegmented sliced results.
  • Bug Fixes

    • Added validation for missing cluster IDs and incorrect time coverage, with clear errors.
    • Ensured disaggregation handles reordered cluster outputs correctly.
  • Tests

    • Expanded coverage for sliced, segmented, reordered, and invalid disaggregation scenarios.

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@FBumann, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 49 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 171b27cf-a3a8-44ed-a639-390da9d617d2

📥 Commits

Reviewing files that changed from the base of the PR and between e2f20c3 and e8e95f2.

📒 Files selected for processing (4)
  • benchmarks/test_bench_aggregate.py
  • src/tsam_xarray/_clustering.py
  • src/tsam_xarray/_result.py
  • test/test_aggregate.py
📝 Walkthrough

Walkthrough

ClusteringResult.disaggregate() adds vectorized gathering for compatible unsegmented clusterings, preserves a per-slice fallback for other cases, and centralizes AggregationResult delegation. Tests and benchmarks cover sliced, segmented, reordered, integer, and invalid representative inputs.

Changes

Disaggregation fast path

Layer / File(s) Summary
Vectorized gather implementation
src/tsam_xarray/_clustering.py
Unsegmented cluster assignments are validated and expanded across the full time axis using label-based vectorized indexing, while segmented inputs retain their existing path.
Sliced and aggregation routing
src/tsam_xarray/_clustering.py, src/tsam_xarray/_result.py
Sliced clusterings reuse precomputed per-slice results and select the shared gather path when compatible; aggregation results delegate directly to clustering disaggregation.
Disaggregation validation and benchmarks
test/test_aggregate.py, benchmarks/test_bench_aggregate.py
Coverage verifies dimension order, coordinate-label gathering, integer dtype preservation, and validation errors; benchmarks cover sliced and segmented cases.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AggregationResult
  participant ClusteringResult
  participant _disaggregate_gather
  AggregationResult->>ClusteringResult: delegate disaggregate(data)
  ClusteringResult->>ClusteringResult: build and validate per-slice clusterings
  ClusteringResult->>_disaggregate_gather: gather compatible unsegmented assignments
  _disaggregate_gather-->>ClusteringResult: return expanded time-series data
  ClusteringResult-->>AggregationResult: return disaggregated result
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately describes the main performance change to unsegmented clustering disaggregation.
Linked Issues check ✅ Passed The PR implements #115 by replacing the pandas round-trip with a NumPy gather for non-segmented clusterings while preserving fallback behavior.
Out of Scope Changes check ✅ Passed The added benchmarks and tests support the disaggregation optimization and stay within the linked issue's scope.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/disaggregate-gather

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
src/tsam_xarray/_clustering.py (1)

713-741: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add Args:/Returns: sections to the new helper docstrings.

_is_gatherable documents behavior in prose only, and _same_time_index has no docstring. As per coding guidelines, use Google-style docstrings with Args:, Returns:, Raises:, Attributes: sections.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tsam_xarray/_clustering.py` around lines 713 - 741, Add Google-style
docstrings to `_is_gatherable` and `_same_time_index`, documenting each
parameter under `Args:` and the boolean result under `Returns:`; preserve the
existing behavior and prose where useful, and do not add unsupported `Raises:`
or `Attributes:` sections.

Source: Coding guidelines

test/test_aggregate.py (1)

1671-1723: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider covering the gather/fallback equivalence.

The new tests cover dim order, label gathering, dtype, and error paths, but nothing asserts that the vectorized sliced path produces the same values as the per-slice tsam fallback (e.g. slices with differing stored time_index, which makes _is_gatherable return False). A single equivalence test would lock in the fast path's correctness.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/test_aggregate.py` around lines 1671 - 1723, Add a test alongside the
existing disaggregate tests that constructs slices with differing stored
time_index values so _is_gatherable returns False, then compares vectorized
sliced disaggregation against the per-slice tsam fallback. Assert the resulting
values are identical, while preserving the current coverage for dimension order,
labels, dtype, and errors.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/tsam_xarray/_clustering.py`:
- Around line 713-741: Add Google-style docstrings to `_is_gatherable` and
`_same_time_index`, documenting each parameter under `Args:` and the boolean
result under `Returns:`; preserve the existing behavior and prose where useful,
and do not add unsupported `Raises:` or `Attributes:` sections.

In `@test/test_aggregate.py`:
- Around line 1671-1723: Add a test alongside the existing disaggregate tests
that constructs slices with differing stored time_index values so _is_gatherable
returns False, then compares vectorized sliced disaggregation against the
per-slice tsam fallback. Assert the resulting values are identical, while
preserving the current coverage for dimension order, labels, dtype, and errors.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c6f94bc2-8e18-49d2-960e-fc11d5eb9556

📥 Commits

Reviewing files that changed from the base of the PR and between 3d7d8b7 and e2f20c3.

📒 Files selected for processing (4)
  • benchmarks/test_bench_aggregate.py
  • src/tsam_xarray/_clustering.py
  • src/tsam_xarray/_result.py
  • test/test_aggregate.py

FBumann and others added 2 commits July 27, 2026 13:25
Expanding an unsegmented clustering back to the full time axis is a gather
along the cluster axis. The wrapper expressed it as a pandas round-trip —
build a MultiIndexed DataFrame, hand it to tsam's disaggregate() (which does
unstack -> .loc[assignments] -> stack), convert back — once per slice-dim
combination. Callers expand every variable of a solution, so a 12-period
model paid n_variables x 12 round-trips for what is one gather.

The gather now runs in numpy across all slices at once. xarray guarantees
the regular grid the fast path needs (dense cluster x timestep block, equal
block sizes, one shared timestep order), so no structural guards are needed;
the path declines only what it cannot express as one rectangular result:
segmented clusterings, and slices whose stored time indices disagree. Those
fall back to the per-slice tsam path unchanged.

Two things the gather preserves, both easy to get wrong:

- Dim order stays (*slice_dims, time, *other_dims), matching the per-slice
  concat. The naive vectorized form yields (time, *slice_dims), which
  compares equal under xarray ops but silently transposes .values.
- Cluster assignments are labels, not positions — .loc[assignments] indexes
  by label, so labels are mapped to positions before indexing.

Validated against the previous implementation on 19 shapes (slice-dim
counts, segmentation, custom dim names, JSON round-trip, shuffled cluster
and timestep coords, int/float32/bool/NaN payloads, extra trailing dims):
values, dtype, shape, dim order and coords identical in every case. Error
type and text for mismatched cluster IDs are unchanged.

365 days, 36 clusters, 100 variables: 481 ms -> 20 ms (24x) without slice
dims, 6.2 s -> 146 ms (42x) across 12 slices.

AggregationResult.disaggregate() now delegates to the identical loop on
ClusteringResult instead of building a per-slice result view.

Closes #115.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review feedback on #116.

_same_time_index had no docstring; both it and _is_gatherable now carry the
project's Google-style Args/Returns, since which inputs make a clustering
gatherable is the non-obvious part of the fast path.

The time-index guard had no test. Slices whose stored indices disagree now
have one: it asserts the gather is declined and that each slice still
matches its own expansion through tsam.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@FBumann
FBumann force-pushed the perf/disaggregate-gather branch from e2f20c3 to e8e95f2 Compare July 27, 2026 11:28
@FBumann
FBumann merged commit 0fc2de6 into main Jul 27, 2026
10 checks passed
@github-actions

Copy link
Copy Markdown

Benchmarks

Δ% vs base, one table sorted by biggest change first
(min walltime and memray peak). Peak memory is
deterministic — any delta there is real; walltime on GitHub
runners is noisy, so only large time deltas are meaningful.
The interactive plot is attached as the benchmark-plot artifact.

Full table

benchmarks/test_bench_aggregate.py

name metric base head
test_config_extremes[append] time 0.05572s +3.0%
peak 1.9 MiB 0.0%
test_config_extremes[replace] time 0.05569s +2.8%
peak 1.9 MiB 0.0%
test_user_disaggregate_segmented time 0.016s +2.8%
peak 6.88 MiB 0.0%
test_wrapper_concat_results time 0.02174s +2.5%
peak 8.72 MiB 0.0%
test_config_representation[mean] time 0.05128s +1.6%
peak 1.78 MiB 0.0%
test_wrapper_to_dataframe time 0.0003539s +1.5%
peak 8.55 MiB 0.0%
test_e2e_wide[dist_global] time 2.027s +1.3%
peak 104 MiB 0.0%
test_config_representation[dist_global_minmax] time 0.1427s +1.2%
peak 1.78 MiB 0.0%
test_user_apply time 0.4437s +0.8%
peak 104 MiB 0.0%
test_e2e_wide[medoid] time 0.5559s +0.8%
peak 113 MiB 0.0%
test_config_representation[dist_cluster] time 0.05315s +0.6%
peak 1.78 MiB 0.0%
test_e2e_full time 0.1622s +0.5%
peak 6.6 MiB 0.0%
test_e2e_default time 0.08701s +0.5%
peak 7.11 MiB 0.0%
test_config_representation[medoid] time 0.05415s +0.4%
peak 1.9 MiB 0.0%
test_e2e_slices time 1.322s 0.0%
peak 14.3 MiB 0.0%
test_config_representation[dist_global] time 0.1405s -0.8%
peak 1.78 MiB 0.0%
test_wrapper_result_conversion time 0.04014s -5.0%
peak 60.3 MiB 0.0%
test_e2e_multidim time 0.2515s -9.2%
peak 26.7 MiB 0.0%
test_user_disaggregate time 0.01343s -90.2%
peak 26.7 MiB -67.7%
test_user_disaggregate_sliced time 0.1365s -95.4%
peak 51.3 MiB +0.1%

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

disaggregate(): skip the pandas round-trip for non-segmented clusterings (~35x)

1 participant