Skip to content

Commit 79987d2

Browse files
jaydu1claude
andcommitted
Speed up LFC by 1.48x via Stage-1 subsample and joint Stage-2 fit
Three changes to _fit_glm_fast_per_perturbation in nb_glm_fast.py: - Stage 1 max_iter reduced from 50 to 5 (NB) / 10 (Poisson) - Stage 1 now fits on a random 3000-cell mixed (ctrl+pert) subsample instead of all cells; column scaling and covariate offsets still use all cells, preserving accuracy - Stage 2 per-perturbation loop replaced by a single joint fit_batch_with_joint_offsets(design=A) call, which is mathematically equivalent for one-hot treatment designs (block-diagonal IRLS) Validated on Replogle tutorial (79865 cells x 8563 genes, 200 perts, 14 batches): 146.9 min vs 217.5 min = 1.48x speedup; tau Pearson r=0.9994, Jaccard=0.975, 221657 vs 222090 sig pairs (-0.2%). Replogle tutorial notebook re-executed with optimized code: 149.2 min. CHANGELOG updated under v0.0.6 Performance. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 8180519 commit 79987d2

3 files changed

Lines changed: 2698 additions & 428 deletions

File tree

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,29 @@ On synthetic data (n = 500, p = 200): 61.5× GLM fit speedup, 7.1× imputation s
112112

113113
Latent factor recovery: mean canonical correlation 0.998. LFC correlation: 0.856 (expected difference due to per-perturbation vs. joint model specification).
114114

115+
**Additional LFC throughput improvements** (`nb_glm_fast.py`, `_fit_glm_fast_per_perturbation`):
116+
117+
Three targeted changes reduce end-to-end `gcate_lfc_batch` wall time by **1.48×** on the Replogle tutorial dataset (79,865 cells × 8,563 genes, 200 perturbations, 14 batches):
118+
119+
| Change | File | Speedup contribution | Accuracy impact |
120+
|--------|------|----------------------|-----------------|
121+
| Stage 1 `max_iter` 50 → 5 (NB) / 10 (Poisson) | `nb_glm_fast.py` | −10 min | identical (r=1.000) |
122+
| Stage 1 ≤3,000-cell mixed subsample (ctrl+pert) | `nb_glm_fast.py` | −55 min | tau r=0.992, Jaccard=0.80 |
123+
| Stage 2 joint fit: single `fit_batch_with_joint_offsets(design=A)` | `nb_glm_fast.py` | −5 min | tau r=0.9994, Jaccard=0.975 |
124+
| **Combined (A+G+B)** | | **−70.6 min / 1.48×** | **tau r=0.9994, Jaccard=0.975** |
125+
126+
Full-run comparison (14 batches, Replogle subset):
127+
128+
| Metric | Original | Optimized | Change |
129+
|--------|----------|-----------|--------|
130+
| Wall time | 217.5 min | **146.9 min** | **1.48×** faster |
131+
| Sig pairs (padj < 0.05) | 222,090 | 221,657 | −0.2% |
132+
| Perts with ≥1 hit | 173/200 | 172/200 | −0.6% |
133+
| Tau Pearson r || 0.9994 | near-identical |
134+
| Jaccard similarity || 0.975 | near-identical |
135+
136+
The Stage 1 subsample uses a random draw of up to 3,000 cells from both control and perturbation cells; column scaling and covariate offsets still use all cells. The Stage 2 joint fit is mathematically equivalent to the original per-perturbation loop for one-hot treatment designs (block-diagonal IRLS normal equations).
137+
115138
## [0.0.5] - 2025-01-30
116139

117140
- GCATE model for gene-level causal effect estimation from CRISPR screens.

causarray/nb_glm_fast.py

Lines changed: 89 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -407,30 +407,49 @@ def _fit_glm_fast_per_perturbation(
407407
# tau_0 and tau_1 clip to thres_diff=0.01 → |diff|=0 → guard fires. ✓
408408
_IRLS_MIN_MU = 1e-4
409409

410+
# Stage 1 subsample: use up to _N_STAGE1 cells drawn from both ctrl and pert.
411+
# For small batches (n ≤ _N_STAGE1) all cells are used. For large batches a
412+
# random subset is drawn so that memory-bandwidth cost stays bounded (~17×
413+
# speedup vs fitting all n=4000 cells at p=8563). Including pert cells gives
414+
# better covariate estimates than ctrl-only when the batch is small.
415+
# Column scaling and cov_offset_all still operate on all n cells.
416+
_N_STAGE1 = 3000
417+
if n <= _N_STAGE1:
418+
stage1_mask = np.ones(n, dtype=bool)
419+
else:
420+
rng = np.random.default_rng(0)
421+
stage1_idx = np.sort(rng.choice(n, size=_N_STAGE1, replace=False))
422+
stage1_mask = np.zeros(n, dtype=bool)
423+
stage1_mask[stage1_idx] = True
424+
425+
X_scaled_s1 = X_scaled_global[stage1_mask]
426+
offset_arr_s1 = offset_arr[stage1_mask]
427+
Y_float_s1 = Y_float[stage1_mask]
428+
410429
if family == "nb":
411430
fitter_global = NBGLMBatchFitter(
412-
design=X_scaled_global,
413-
offset=offset_arr,
414-
max_iter=min(maxiter, 50),
431+
design=X_scaled_s1,
432+
offset=offset_arr_s1,
433+
max_iter=min(maxiter, 5),
415434
poisson_init_iter=5,
416435
dispersion_method="moments",
417436
min_mu=_IRLS_MIN_MU,
418437
)
419-
result_global = fitter_global.fit_batch(Y_float)
438+
result_global = fitter_global.fit_batch(Y_float_s1)
420439
B_cov_global = result_global.coef / col_scale_global # (p, d) original-space
421440
global_alpha = result_global.dispersion # (p,)
422441
global_alpha = np.clip(global_alpha, 1e-8, 100.0)
423442
global_alpha[~np.isfinite(global_alpha)] = 1.0
424443
elif family == "poisson":
425444
fitter_global = NBGLMBatchFitter(
426-
design=X_scaled_global,
427-
offset=offset_arr,
428-
max_iter=min(maxiter, 50),
445+
design=X_scaled_s1,
446+
offset=offset_arr_s1,
447+
max_iter=min(maxiter, 10),
429448
poisson_init_iter=10,
430449
dispersion_method="moments",
431450
min_mu=_IRLS_MIN_MU,
432451
)
433-
result_global = fitter_global.fit_batch(Y_float)
452+
result_global = fitter_global.fit_batch(Y_float_s1)
434453
B_cov_global = result_global.coef / col_scale_global # (p, d) original-space
435454
global_alpha = None
436455

@@ -443,8 +462,7 @@ def _fit_glm_fast_per_perturbation(
443462
B_full[:, :d] = B_cov_global
444463

445464
# Initialise residuals and Yhat from the Stage-1 global covariate model.
446-
# The loop below overwrites only treated-cell rows so that control cells
447-
# remain anchored to this global fit throughout.
465+
# Treated-cell rows are overwritten below; ctrl rows stay anchored here.
448466
eta_global = cov_offset_all + offset_arr[:, None] # (n, p)
449467
Yhat_global = np.exp(np.clip(eta_global, -20, 20))
450468
if family == "nb":
@@ -492,74 +510,76 @@ def _fit_glm_fast_per_perturbation(
492510
Yhat_0 = np.zeros((n_test, p, a), dtype=_impu_dtype)
493511
Yhat_1 = np.zeros((n_test, p, a), dtype=_impu_dtype)
494512

513+
# ── Stage 2: Joint treatment-effect fit ──────────────────────────────
514+
# Single fit with design=A (n, a) over all cells. For one-hot A the
515+
# IRLS normal equations A^T W A are exactly block-diagonal: ctrl rows
516+
# are all-zero so they contribute nothing to treatment coefficients, and
517+
# each pert cell appears in exactly one column. Estimates are therefore
518+
# mathematically equivalent to the old sequential per-pert loop, but
519+
# Y_float is read from RAM once instead of a times (~8× memory-bandwidth
520+
# reduction).
521+
A_float = A.astype(np.float64)
522+
523+
if family == "nb":
524+
fitter_joint = NBGLMBatchFitter(
525+
design=A_float,
526+
offset=offset_arr,
527+
max_iter=min(maxiter, 50),
528+
poisson_init_iter=5,
529+
dispersion_method="moments",
530+
min_mu=_IRLS_MIN_MU,
531+
)
532+
result_joint = fitter_joint.fit_batch_with_joint_offsets(
533+
Y_float,
534+
covariate_offset=cov_offset_all,
535+
fixed_dispersion=global_alpha,
536+
)
537+
elif family == "poisson":
538+
fitter_joint = NBGLMBatchFitter(
539+
design=A_float,
540+
offset=offset_arr,
541+
max_iter=min(maxiter, 50),
542+
poisson_init_iter=10,
543+
dispersion_method="moments",
544+
min_mu=_IRLS_MIN_MU,
545+
)
546+
result_joint = fitter_joint.fit_batch_with_joint_offsets(
547+
Y_float,
548+
covariate_offset=cov_offset_all,
549+
)
550+
551+
B_trt_all = result_joint.coef # (p, a)
552+
B_full[:, d:] = B_trt_all
553+
joint_disp = result_joint.dispersion # (p,); equals global_alpha when fixed
554+
555+
# Fitted values for all cells: ctrl rows recover Yhat_global exactly.
556+
Yhat_joint = np.exp(np.clip(
557+
A_float @ B_trt_all.T + cov_offset_all + offset_arr[:, None], -20, 20
558+
)) # (n, p)
559+
560+
# Per-pert bookkeeping: overwrite treated rows, accumulate dispersion,
561+
# fill imputation arrays. This loop is cheap — no GLM fitting.
495562
for k in range(a):
496563
pert_mask = A[:, k] == 1
497-
cell_mask = ctrl_mask | pert_mask
498-
n_sub = cell_mask.sum()
564+
n_sub_k = int(ctrl_mask.sum()) + int(pert_mask.sum())
499565

500-
# Treatment-only design (d=1): just the treatment indicator
501-
A_k = A[cell_mask, k : k + 1] # (n_sub, 1)
502-
Y_sub = Y_float[cell_mask]
503-
offset_sub = offset_arr[cell_mask]
504-
cov_offset_sub = cov_offset_all[cell_mask] # (n_sub, p)
566+
Yhat_full[pert_mask] = Yhat_joint[pert_mask]
505567

506568
if family == "nb":
507-
fitter = NBGLMBatchFitter(
508-
design=A_k,
509-
offset=offset_sub,
510-
max_iter=min(maxiter, 50),
511-
poisson_init_iter=5,
512-
dispersion_method="moments",
513-
min_mu=_IRLS_MIN_MU,
514-
)
515-
result = fitter.fit_batch_with_joint_offsets(
516-
Y_sub,
517-
covariate_offset=cov_offset_sub,
518-
fixed_dispersion=global_alpha,
519-
)
520-
B_trt_k = result.coef[:, 0] # (p,) single treatment coefficient
521-
522-
# Fitted values: eta = B_trt * A_k + cov_offset + offset
523-
eta_sub = A_k @ result.coef.T + cov_offset_sub + offset_sub[:, None]
524-
Yhat_sub = np.exp(np.clip(eta_sub, -20, 20))
525-
526-
fitter_disp = result.dispersion
527-
resid_sub = _compute_nb_deviance_residuals(Y_sub, Yhat_sub, fitter_disp)
528-
disp_alpha += n_sub * fitter_disp
529-
disp_alpha_cells += n_sub
530-
531-
elif family == "poisson":
532-
fitter = NBGLMBatchFitter(
533-
design=A_k,
534-
offset=offset_sub,
535-
max_iter=min(maxiter, 50),
536-
poisson_init_iter=10,
537-
dispersion_method="moments",
538-
min_mu=_IRLS_MIN_MU,
569+
resid_deviance[pert_mask] = _compute_nb_deviance_residuals(
570+
Y_float[pert_mask], Yhat_joint[pert_mask], joint_disp
539571
)
540-
result = fitter.fit_batch_with_joint_offsets(
541-
Y_sub,
542-
covariate_offset=cov_offset_sub,
572+
disp_alpha += n_sub_k * joint_disp
573+
disp_alpha_cells += n_sub_k
574+
else:
575+
resid_deviance[pert_mask] = _compute_poisson_deviance_residuals(
576+
Y_float[pert_mask], Yhat_joint[pert_mask]
543577
)
544-
B_trt_k = result.coef[:, 0]
545-
546-
eta_sub = A_k @ result.coef.T + cov_offset_sub + offset_sub[:, None]
547-
Yhat_sub = np.exp(np.clip(eta_sub, -20, 20))
548-
resid_sub = _compute_poisson_deviance_residuals(Y_sub, Yhat_sub)
549-
550-
B_full[:, d + k] = B_trt_k
551-
552-
# Update only treated cells; control-cell rows keep the global-model values.
553-
treated_in_sub = np.where(cell_mask)[0][A_k[:, 0] == 1]
554-
resid_deviance[treated_in_sub] = resid_sub[A_k[:, 0] == 1]
555-
Yhat_full[treated_in_sub] = Yhat_sub[A_k[:, 0] == 1]
556578

557-
# Imputation: global cov + per-perturbation treatment
558579
if do_impute:
559-
Yhat_0[:, :, k] = Yhat_0_base # same for all k
560-
# Y_hat_1 = exp(cov_offset + B_trt_k + offset)
561-
eta_1_test = eta_0_test + B_trt_k[None, :] # broadcast (n_test, p)
562-
Yhat_1[:, :, k] = np.exp(np.clip(eta_1_test, -20, 20))
580+
Yhat_0[:, :, k] = Yhat_0_base
581+
eta_1_test_k = eta_0_test + B_trt_all[:, k][None, :]
582+
Yhat_1[:, :, k] = np.exp(np.clip(eta_1_test_k, -20, 20))
563583

564584
if family == "nb":
565585
# Weighted average of alpha; convert to r = 1/alpha (causarray convention).

0 commit comments

Comments
 (0)