Repository navigation
perf: stratified charts linear in strata; lane dividers in one assignment - #138
Merged
Merged
Conversation
cnicholas
force-pushed
the
perf/stratified-linear
branch
from
October 13, 2026 22:59
14825d3 to
0b450d8
Compare
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
force-pushed
the
perf/stratified-linear
branch
from
October 13, 2026 23:34
0b450d8 to
c672584
Compare
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.
4 tasks done
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.
Summary
analysis.pystratified 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.
_stratum_row_positions/_stratum_rows), replacingdf[df[col] == stratum].calculate_limits_vectorized(_mr_family_limits)._mr_stratum_statistics), keeping numpy float64 values.focus_onreadsresult.strataonce.n_mode='average'; a 30,165-row gene file). Data, statistics, metadata, lane boundaries, strata andfocus()output are identical, value types included.2. Lane dividers: 14825d3
Each divider was its own
fig.add_shapecall, and plotly re-validates every shape already on the figure, so drawing them was O(lanes²): 800 dividers took 27 s.add_shape/add_annotationto resolve subplot axis references; the rest are appended in one assignment. The figure is identical (to_dictequal: single and faceted, labels on and off, two themes). 800 dividers now take 0.03 s.MAX_LANE_DIVIDERS(1,000) none are drawn: they would merge into a solid band at any width.Verification
docs/reference/validation.mdunchanged.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)
focus()still scans the full data per call.