Skip to content

perf: stratified charts linear in strata; lane dividers in one assignment - #138

Merged
cnicholas merged 5 commits into
feat/vas-manual-10-1from
perf/stratified-linear
Oct 14, 2026
Merged

cnicholas merged 5 commits into
feat/vas-manual-10-1from
perf/stratified-linear

Conversation

@cnicholas

@cnicholas cnicholas commented Oct 12, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • What: two fixes so a factor with thousands of levels doesn't stall: stratified charts take time in proportion to the data, and lane dividers are drawn in one batch.
  • Why: a 6,033-gene study (30,165 and 307,683 rows) took the stratified X/mR chart from seconds to about 80 minutes, and an X chart with one lane per gene to about 25 minutes of plotting.
  • Scope: analysis.py stratified X/mR, Xbar, S and lane-boundary loops; focused_result.focus_on; plotting/lane_boundaries.py; benchmark scenario and tests. Into the 0.4.0 branch (Align with Bishop's 10-1 VAS manual (0.4.0) #137).

1. Stratified charts: d7c8106

Stratified X/mR scanned the whole table once per stratum. With every row its own condition, an X/mR pair took 48 s at 30,165 rows and an estimated 80 minutes at 307,683. It now takes 0.2 s and 1.9 s.

  • One groupby gives every stratum's row positions (_stratum_row_positions / _stratum_rows), replacing df[df[col] == stratum].
  • Per-stratum and per-phase X/mR limits use calculate_limits_vectorized (_mr_family_limits).
  • The statistics dict is built in one pass (_mr_stratum_statistics), keeping numpy float64 values.
  • Set membership for strata lists; one-row strata skip the lane-boundary call; focus_on reads result.strata once.
  • Results unchanged: 66 stratified scenarios compared before and after (T100 PM SDS 1–6 and PM INERT; X/mR by one and both factors, phased, R2, mR alone; Xbar/S by time, n_mode='average'; a 30,165-row gene file). Data, statistics, metadata, lane boundaries, strata and focus() output are identical, value types included.

2. Lane dividers: 14825d3

Each divider was its own fig.add_shape call, and plotly re-validates every shape already on the figure, so drawing them was O(lanes²): 800 dividers took 27 s.

  • The first divider goes through add_shape / add_annotation to resolve subplot axis references; the rest are appended in one assignment. The figure is identical (to_dict equal: single and faceted, labels on and off, two themes). 800 dividers now take 0.03 s.
  • Above MAX_LANE_DIVIDERS (1,000) none are drawn: they would merge into a solid band at any width.

Verification

  • Full suite: 2,517 passed, 0 failed.
  • e2e gate: 1511 pass, 0 fail, 43 pending; docs/reference/validation.md unchanged.
  • Every app Primary Analyses fold on the 30,165-row gene file renders (figure, table, signals) in at most 2.4 s each; at 307,683 rows the slowest fold is about 20 s.
  • New tests: one-row-strata scaling (test_performance.py, slow), one-row-strata contract (test_stratified_insufficiency.py), batched-divider equality and the 1,000 limit (test_plotting_decorations.py).

Follow-ups (not in this PR)

  • Stratified Xbar/S is still about 2.5 ms per stratum (linear, not yet vectorised). It touches the n_mode and unequal-N logic, so it needs its own e2e run.
  • focus() still scans the full data per call.

@cnicholas cnicholas changed the title perf(stratified): linear in the number of strata, not strata x rows perf: stratified charts linear in strata; lane dividers in one assignment Oct 13, 2026
@cnicholas
cnicholas force-pushed the perf/stratified-linear branch from 14825d3 to 0b450d8 Compare October 13, 2026 22:59
Stratified X/mR looked each stratum up with a scan of the whole table, so with every row its
own condition an X/mR pair took 48 s at 30,165 rows and an estimated 80 minutes at 307,683.
It now takes 0.2 s and 1.9 s.

- One groupby gives every stratum's row positions (_stratum_row_positions / _stratum_rows);
  the X/mR, Xbar, S and lane-boundary loops use it instead of df[df[col] == stratum].
- Per-stratum and per-phase X/mR limits use calculate_limits_vectorized (_mr_family_limits)
  instead of one calculate_limits call per row through DataFrame.apply.
- The per-stratum statistics dict is built in one pass (_mr_stratum_statistics), keeping the
  numpy float64 values the row lookup returned.
- Strata lists are searched as sets; a one-row stratum skips the lane-boundary call;
  focus_on reads result.strata once.

Results are unchanged. 66 stratified scenarios (seven T100 responses; X/mR by one and both
factors, phased, R2, mR alone; Xbar/S by time and n_mode='average'; a 30,165-row gene file)
give identical data, statistics, metadata, strata and focus() output before and after,
including value types. Full suite 2,511 passed; e2e gate 1511 pass, 0 fail, 43 pending,
docs/reference/validation.md unchanged.

scripts/benchmark.py adds a one-row-strata scenario (SDS-2) and tests/test_performance.py
checks it scales linearly (per-row ratio 0.94 from 10K to 100K rows).
test_stratified_insufficiency.py checks the one-row-strata result: nothing published,
every stratum reported.

Stratified Xbar/S is still about 2.5 ms per stratum (linear, not yet vectorised).
Each lane divider was added with its own fig.add_shape call, and plotly re-validates every shape
already on the figure, so drawing them cost O(lanes^2): 200 dividers 1.3 s, 400 5.7 s, 800 27 s.
An X chart of a 6,033-gene study (by=[], one lane per gene) would have taken about 25 minutes;
the app's first Primary Analyses fold draws exactly that chart.

- The first shape or annotation goes through add_shape / add_annotation so plotly resolves the
  subplot's axis references for row/col; the rest copy them and are appended in one
  assignment. The figure is identical to the call-by-call one (to_dict equal for single and
  faceted charts, with and without labels, two themes); 800 dividers now take 0.03 s.
- Above MAX_LANE_DIVIDERS (1,000) none are drawn: they would merge into a solid band at any
  chart width, and the browser still draws every one.

Every Primary Analyses fold on a 30,165-row, 6,033-gene file now renders (figure, table,
signals) in at most 2.4 s, 8 s for all of them; at 307,683 rows the slowest fold is about 20 s.
Suite 2,517 passed; e2e 1511 pass, 0 fail, 43 pending, validation.md unchanged.
@cnicholas
cnicholas force-pushed the perf/stratified-linear branch from 0b450d8 to c672584 Compare October 13, 2026 23:34
detect_signals() raised TypeError on every phased=True chart. Each phase has its own centre,
so the statistics carry center=None, and the per-row zone calculation subtracted that scalar.
The chart marks run rules not applicable (metadata run_rules_applicable=False, 70503be), but
only the renderer read the flag.

- result_signals passes run_rules_applicable to SignalDetector.detect. When it is False and
  the rules are the chart-type default, the default is Rule 1 only: rules 2-8 are not
  applicable rather than skipped, so the result is not marked partial on their account.
- An explicit rule list runs as given (SignalConfig's "explicit rules, no filtering"
  contract). The per-row zones, the run rule (rule 4) and the violation records read each
  row's own centre when the scalar centre is None.

The limits and the points the chart flags were always correct; Rule 1 now reports the same
points (flagged_observations equals the rows with beyond_limits != 0). Unphased charts are
unchanged: X still evaluates rules 1-8, Xbar/S Rule 1.

tests/test_phased_limits.py TestPhasedSignals: combined, companion and stratified (via
focus) phased charts; the violation records carry the phase's centre and limits; explicit
rules 2 and 4 judged against each phase's centre; unphased X keeps every rule.
execute(value='response') is in the signature (Literal['response', 'R1', ...]) and the
docstring, but every chart refused it: "Valid charts for RESPONSE: None".
_validate_execute_request tells residuals apart by value.upper().startswith('R'), which
'RESPONSE' also satisfies, so the value went down the residual path and failed the pairing
check. why_not() and supports_calibration() refused it the same way.

_normalise_value turns 'response' (any case) into None, the response's own spelling, at the
start of execute(), supports_calibration() and why_not(); _resolve_value_column already
treats None as the response. recentered=True with the response is still refused, and the
message now says why: the response is already on the measurement scale.

tests/test_execute_validation.py TestValueResponse: 'response', 'Response' and 'RESPONSE'
give the same charts, statistics and metadata as value=None on Xbar, S, Xbar+S, X, mR and
Histogram; why_not and supports_calibration agree; recentered stays refused.
fix: detect_signals() on phased charts; value='response' (#141, #142)
@cnicholas
cnicholas merged commit 4ad83e5 into feat/vas-manual-10-1 Oct 14, 2026
1 check passed
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.

1 participant