Skip to content

Speed up unstratified AE-term confidence intervals (~18x) - #257

Merged
LittleBeannie merged 9 commits into
mainfrom
perf/vectorize-biroot-grid
Oct 6, 2026
Merged

LittleBeannie merged 9 commits into
mainfrom
perf/vectorize-biroot-grid

Conversation

@yihui

@yihui yihui commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Follow-up to #253. Computing the Miettinen-Nurminen risk-difference confidence intervals in extend_ae_specific_inference() was the dominant cost of preparing an interactive AE forest plot on large analyses. Two layers of overhead remained:

  1. biroot() called the objective func_d() once per grid point (~100× per CI).
  2. extend_ae_specific_inference() called rate_compare_sum() once per AE term — thousands of independent scans, each rebuilding the scalar machinery and running its own bisection.

This PR removes both, for the unstratified case (the forest-plot path):

  • Vectorized grid scan (commit 1): biroot() evaluates the whole scan grid in one vectorized func_d() call, then bisects only the intervals whose endpoints change sign. Stratified inputs (which reduce over strata with sum() and can't take a vector d) still evaluate the grid point by point.
  • Batched across terms (commit 2): new internal rate_compare_sum_batch() reimplements rate_compare_sum()'s unstratified path over vectors of (n0, n1, x0, x1). It evaluates the grid for all terms at once (an nt × E matrix) and refines every sign-change bracket with a single vectorized bisection loop. extend_ae_specific_inference() uses it whenever there is no stratification; stratified inputs still use rate_compare_sum() per term. The public rate_compare_sum() API is unchanged.

Correctness

Value-preserving. Against rate_compare_sum():

  • All 9,510 terms of a large forestly example: bit-identical (max abs diff 0) on est, z_score, p, lower, upper.
  • Random (n0, n1, x0, x1) (one-sided, two-sided, delta = 0.1) and edge cases (zero events, tiny n, delta = ±): max abs diff 0.

End-to-end, the prepared ci_lower / ci_upper match a direct scalar rate_compare_sum() computation exactly.

(One behavior change: the batch path does not emit the per-term "no CI limit found" message() that the scalar path prints for terms with no root in range; the returned NA limits are the same.)

Impact

On a 9,510-term forestly example, prepare_ae_forestly():

Time
Before (#253 baseline, per-term scan) ~132 s
+ vectorized grid scan (commit 1) ~39 s
+ batched across terms (commit 2) ~2 s

~60× overall; ~18× beyond #253.

🤖 Generated with Claude Code

biroot() called the objective func_d() once per grid point -- ~100 times
per confidence interval, and once per AE term -- so the scan was ~1M
interpreted scalar calls on a large forestly example, dominating
extend_ae_specific_inference().

func_d() is already written with vectorized arithmetic. In the
unstratified case its argument `d` can be a vector, so evaluate the whole
scan grid in one call, then bisect only the intervals whose endpoints
change sign. The stratified case (which reduces over strata with sum()
and cannot take a vector `d`) still evaluates the grid point by point.
func_d()'s unstratified special case is rewritten as a vectorized mask.

Results are unchanged up to floating-point tolerance: max abs difference
4.4e-16 across 4,000 unstratified and 500 stratified random cases. On a
9,510-term forestly example prepare drops from ~132s to ~39s.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Even with the vectorized grid scan, extend_ae_specific_inference() called
rate_compare_sum() once per AE term -- thousands of scans, each building
the scalar machinery and running its own bisection. That per-term call
overhead dominated prepare on large analyses.

Add rate_compare_sum_batch(), a vectorized-across-terms reimplementation
of rate_compare_sum()'s unstratified path: it evaluates the bisection
grid for all terms at once (an nt x E matrix) and refines every
sign-change bracket with a single vectorized bisection loop. When there
is no stratification, extend_ae_specific_inference() now computes all
terms' confidence intervals with one call instead of a per-term loop;
stratified inputs still use rate_compare_sum().

Value-preserving: max abs difference 0 (bit-identical est/z_score/p/
lower/upper) against rate_compare_sum() across all 9,510 terms of a
large forestly example, plus random and edge-case (zero-event, tiny n,
nonzero delta, two-sided) checks. On that example prepare_ae_forestly()
drops from ~39s to ~2s, on top of the earlier grid vectorization.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@yihui yihui changed the title Vectorize the bisection CI grid scan in rate_compare_sum() Speed up unstratified AE-term confidence intervals (~18x) Sep 30, 2026
yihui and others added 2 commits September 30, 2026 22:04
The constrained-MLE variance algebra (the l0..l3 -> q -> p -> r0t -> r1t
-> vart block) was copy-pasted four times: the score statistic and CI
objective in rate_compare_sum(), and again in rate_compare_sum_batch()'s
func_pts() and point-estimate block. Pull it into a single vectorized
mn_vart() helper that every caller shares; each op is elementwise, so the
CI grid scan (scalar term params, vector d) and the across-terms batch
(equal-length vectors) both use it via recycling.

An adjust_p flag reproduces each caller's exact prior behavior: the score
statistic path never nudged p off zero, the CI objective did. The batch
point-estimate previously applied the nudge but its z_score matched the
scalar path bit-for-bit on all test data (the nudge never fired); it now
uses adjust_p = FALSE to match the scalar path by construction.

Results unchanged: scalar (one/two-sided, delta != 0), stratified, and
batch-vs-scalar all match the pre-refactor code to maxdiff 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@yihui
yihui requested a review from LittleBeannie September 30, 2026 22:44
@LittleBeannie

Copy link
Copy Markdown
Collaborator

Stratified inputs (which reduce over strata with sum() and can't take a vector d) still evaluate the grid point by point.
extend_ae_specific_inference() uses it whenever there is no stratification; stratified inputs still use rate_compare_sum() per term.

If it is stratified, then will we have the same running speed as before?

Comment thread R/rate_compare.R
Comment thread R/extend_ae_specific.R Outdated
…ce tests

Review follow-up on #257:

- Rename rate_compare_sum_batch() to rate_compare_sum_unstratified() (and
  the local use_batch -> use_unstratified, roxygen title) so the name reads
  in domain terms rather than algorithm-engineering jargon.

- Move the chisq_crit and unstratified definitions above biroot() so
  `unstratified` is defined before biroot() reads it, instead of appearing
  below the function that uses it.

- Add test-independent-testing-rate_compare_sum_unstratified.R: assert the
  vectorized unstratified path matches looping the scalar rate_compare_sum()
  one term at a time, to 1e-10 (est/z_score/p) and 1e-8 (CI limits), across
  random inputs, edge cases (zero/all events, single subject, extreme
  split), nonzero delta + two-sided, and NA handling. rate_compare() and
  biroot() are exercised through rate_compare_sum(); extend_ae_specific()'s
  unstratified path runs through this function and is covered by the
  existing extend_ae_specific tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@yihui

yihui commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

If it is stratified, then will we have the same running speed as before?

Essentially yes — stratified inputs run at about the same speed as before this PR. The two big wins here (evaluating the whole bisection grid in one vectorized call, and computing all AE terms together in rate_compare_sum_unstratified()) only apply when there is no stratification: the objective func_d can take a vector argument d only in the unstratified case, whereas the stratified case reduces over strata with sum() and must be evaluated point by point. Stratified terms still go through per-term rate_compare_sum() with a vapply grid scan.

They do keep the path-agnostic improvements (hoisted loop invariants, each grid edge evaluated once and shared between adjacent intervals, and the shared mn_vart() helper), so stratified is not slower — just not meaningfully faster from this PR. In practice stratified analyses have few strata and were not the bottleneck we profiled.

Comment thread tests/testthat/test-independent-testing-rate_compare_sum_unstratified.R Outdated
yihui and others added 2 commits October 6, 2026 10:32
Review follow-up on #257:

- Add tests/testthat/helper-rate_compare_sum_old.R: a verbatim frozen copy
  of rate_compare_sum() from 26ba273^ (before this PR vectorized the grid
  scan and added the across-terms path), renamed rate_compare_sum_old().
  The equivalence tests now compare BOTH the current scalar
  rate_compare_sum() and the new rate_compare_sum_unstratified() against
  this genuine original, instead of against each other.

- Run every case at alpha = 0.025 and 0.05.

- Drop the explicit weight = "ss" from the reference loop. For a single
  unstratified term the stratum weight normalizes to 1, so est/z_score/p
  and the CI do not depend on weight; a comment notes this.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reflow the 0.1.4 NEWS entries to one line per bullet per the AGENTS.md
convention, credit #253 and #257 to @yihui, and fix the "#203thanks"
typo in the 0.1.3 entry. Add AGENTS.md documenting the no-hard-wrap
NEWS.md guideline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@LittleBeannie
LittleBeannie self-requested a review October 6, 2026 16:01

@LittleBeannie LittleBeannie left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is an amazing PR! I can't imagine how much thought, intelligence, and effort went into this.

@LittleBeannie
LittleBeannie merged commit bcf3dea into main Oct 6, 2026
9 checks passed
@LittleBeannie
LittleBeannie deleted the perf/vectorize-biroot-grid branch October 6, 2026 16:03
@yihui

yihui commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

The hard work was mostly done by AI. The PRs on the forestly side are more demanding in effort, but I think I've finished the hardest bones now. I just need to review all ae_forestly examples one more time and make sure the migration to {lt} doesn't lose anything.

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.

2 participants