Skip to content

Commit ec90ca3

Browse files
committed
fix bug
Joint Stage-2 treatment fit silently changes estimands for combinatorial/multi-perturbation rows. Stage-1 subsample ignores caller `random_state`.
1 parent 79987d2 commit ec90ca3

2 files changed

Lines changed: 179 additions & 64 deletions

File tree

causarray/nb_glm_fast.py

Lines changed: 64 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,8 @@ def fit_glm_fast(
234234
disp_glm=disp_glm, impute=impute, offset=offset,
235235
shrinkage=shrinkage, alpha=alpha, maxiter=maxiter,
236236
thres_disp=thres_disp, n_jobs=n_jobs,
237-
random_state=random_state, verbose=verbose, **kwargs,
237+
random_state=random_state, verbose=verbose,
238+
mem_limit_gb=mem_limit_gb, **kwargs,
238239
)
239240

240241
# Estimate dispersion for NB — use covariate-only design (X, not X_full)
@@ -261,6 +262,7 @@ def fit_glm_fast(
261262
Y_float, X, A, a, d, p, n, family, disp_glm,
262263
impute, X_test, offset_arr, offsets, maxiter, verbose,
263264
mem_limit_gb=mem_limit_gb, offset_test=offset_test,
265+
random_state=random_state,
264266
)
265267

266268
B, Yhat, disp_glm_out, resid_deviance = _fit_glm_fast_single(
@@ -371,7 +373,7 @@ def _fit_glm_fast_single(
371373
def _fit_glm_fast_per_perturbation(
372374
Y_float, X, A, a, d, p, n, family, disp_glm,
373375
impute, X_test, offset_arr, offsets, maxiter, verbose,
374-
mem_limit_gb=None, offset_test=None,
376+
mem_limit_gb=None, offset_test=None, random_state=0,
375377
):
376378
"""Per-perturbation GLM fitting with global covariate model.
377379
@@ -417,7 +419,7 @@ def _fit_glm_fast_per_perturbation(
417419
if n <= _N_STAGE1:
418420
stage1_mask = np.ones(n, dtype=bool)
419421
else:
420-
rng = np.random.default_rng(0)
422+
rng = np.random.default_rng(random_state)
421423
stage1_idx = np.sort(rng.choice(n, size=_N_STAGE1, replace=False))
422424
stage1_mask = np.zeros(n, dtype=bool)
423425
stage1_mask[stage1_idx] = True
@@ -510,76 +512,75 @@ def _fit_glm_fast_per_perturbation(
510512
Yhat_0 = np.zeros((n_test, p, a), dtype=_impu_dtype)
511513
Yhat_1 = np.zeros((n_test, p, a), dtype=_impu_dtype)
512514

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.
515+
# ── Stage 2: treatment-effect fit ───────────────────────────────────
516+
# Future-dev note: a single joint fit over all treatment columns is only
517+
# equivalent to this loop when A is raw 0/1 one-hot. Some future callers
518+
# may pass standardized or otherwise transformed treatment designs, where
519+
# detecting one-hot structure from A itself would be unreliable. Keep the
520+
# historical per-perturbation GLM until raw assignment/support masks are
521+
# carried alongside any transformed design.
562522
for k in range(a):
563523
pert_mask = A[:, k] == 1
564-
n_sub_k = int(ctrl_mask.sum()) + int(pert_mask.sum())
524+
cell_mask = ctrl_mask | pert_mask
525+
n_sub = cell_mask.sum()
565526

566-
Yhat_full[pert_mask] = Yhat_joint[pert_mask]
527+
A_k = A[cell_mask, k : k + 1]
528+
Y_sub = Y_float[cell_mask]
529+
offset_sub = offset_arr[cell_mask]
530+
cov_offset_sub = cov_offset_all[cell_mask]
567531

568532
if family == "nb":
569-
resid_deviance[pert_mask] = _compute_nb_deviance_residuals(
570-
Y_float[pert_mask], Yhat_joint[pert_mask], joint_disp
533+
fitter = NBGLMBatchFitter(
534+
design=A_k,
535+
offset=offset_sub,
536+
max_iter=min(maxiter, 50),
537+
poisson_init_iter=5,
538+
dispersion_method="moments",
539+
min_mu=_IRLS_MIN_MU,
571540
)
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]
541+
result = fitter.fit_batch_with_joint_offsets(
542+
Y_sub,
543+
covariate_offset=cov_offset_sub,
544+
fixed_dispersion=global_alpha,
545+
)
546+
B_trt_k = result.coef[:, 0]
547+
548+
eta_sub = A_k @ result.coef.T + cov_offset_sub + offset_sub[:, None]
549+
Yhat_sub = np.exp(np.clip(eta_sub, -20, 20))
550+
fitter_disp = result.dispersion
551+
resid_sub = _compute_nb_deviance_residuals(Y_sub, Yhat_sub, fitter_disp)
552+
disp_alpha += n_sub * fitter_disp
553+
disp_alpha_cells += n_sub
554+
555+
elif family == "poisson":
556+
fitter = NBGLMBatchFitter(
557+
design=A_k,
558+
offset=offset_sub,
559+
max_iter=min(maxiter, 50),
560+
poisson_init_iter=10,
561+
dispersion_method="moments",
562+
min_mu=_IRLS_MIN_MU,
563+
)
564+
result = fitter.fit_batch_with_joint_offsets(
565+
Y_sub,
566+
covariate_offset=cov_offset_sub,
577567
)
568+
B_trt_k = result.coef[:, 0]
569+
570+
eta_sub = A_k @ result.coef.T + cov_offset_sub + offset_sub[:, None]
571+
Yhat_sub = np.exp(np.clip(eta_sub, -20, 20))
572+
resid_sub = _compute_poisson_deviance_residuals(Y_sub, Yhat_sub)
573+
574+
B_full[:, d + k] = B_trt_k
575+
576+
treated_in_sub = np.where(cell_mask)[0][A_k[:, 0] == 1]
577+
resid_deviance[treated_in_sub] = resid_sub[A_k[:, 0] == 1]
578+
Yhat_full[treated_in_sub] = Yhat_sub[A_k[:, 0] == 1]
578579

579580
if do_impute:
580581
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))
582+
eta_1_test = eta_0_test + B_trt_k[None, :]
583+
Yhat_1[:, :, k] = np.exp(np.clip(eta_1_test, -20, 20))
583584

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

tests/test_nb_glm_fast.py

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
G13 – mem_limit_gb triggers float32 allocation in imputation arrays
1414
G14 – mem_limit_gb=None preserves default float64 allocation
1515
G15 – fit_glm_fast accepts and passes mem_limit_gb through **kwargs
16+
G16 – multi-treatment Stage 2 uses per-perturbation GLMs, not joint fit
17+
G17 – Stage-1 subsample honors caller random_state
1618
"""
1719
from __future__ import annotations
1820

@@ -335,6 +337,119 @@ def test_control_cell_resid_not_overwritten(self):
335337
assert np.all(Yhat[ctrl_idx] > 0)
336338

337339

340+
# ---------------------------------------------------------------------------
341+
# G16 – Stage 2 per-perturbation fallback
342+
# ---------------------------------------------------------------------------
343+
344+
class TestG16PerPerturbationStage2:
345+
def test_multi_treatment_uses_one_stage2_fit_per_perturbation(self):
346+
"""Multi-column A should use the historical per-perturbation GLM loop."""
347+
if not gcate_glm._CRISPYX_AVAILABLE:
348+
pytest.skip("crispyx not installed")
349+
350+
from causarray.nb_glm_fast import _fit_glm_fast_per_perturbation
351+
from crispyx.glm import NBGLMBatchFitter
352+
353+
rng = np.random.default_rng(123)
354+
n, p, a = 72, 10, 3
355+
Y = rng.poisson(2.0, (n, p)).astype(np.float64)
356+
X = np.ones((n, 1))
357+
A = np.zeros((n, a))
358+
for k in range(a):
359+
A[18 + k * 12 : 18 + (k + 1) * 12, k] = 1
360+
361+
real = NBGLMBatchFitter.fit_batch_with_joint_offsets
362+
363+
def _spy(self, *args, **kwargs):
364+
return real(self, *args, **kwargs)
365+
366+
with patch.object(
367+
NBGLMBatchFitter,
368+
"fit_batch_with_joint_offsets",
369+
autospec=True,
370+
side_effect=_spy,
371+
) as mock_fit:
372+
_fit_glm_fast_per_perturbation(
373+
Y, X, A, a=a, d=1, p=p, n=n,
374+
family="poisson", disp_glm=None,
375+
impute=False, X_test=None,
376+
offset_arr=np.zeros(n),
377+
offsets=None,
378+
maxiter=3, verbose=False,
379+
)
380+
381+
assert mock_fit.call_count == a
382+
383+
384+
# ---------------------------------------------------------------------------
385+
# G17 – Stage-1 subsample RNG
386+
# ---------------------------------------------------------------------------
387+
388+
class TestG17Stage1RandomState:
389+
def test_fit_glm_fast_passes_random_state_to_per_perturbation(self):
390+
"""Public fit_glm_fast must thread random_state into the multi-A path."""
391+
if not gcate_glm._CRISPYX_AVAILABLE:
392+
pytest.skip("crispyx not installed")
393+
394+
from causarray import nb_glm_fast as _nbgf
395+
396+
rng = np.random.default_rng(456)
397+
n, p, a = 40, 5, 2
398+
Y = rng.poisson(2.0, (n, p)).astype(np.float64)
399+
X = np.ones((n, 1))
400+
A = np.zeros((n, a))
401+
A[10:20, 0] = 1
402+
A[20:30, 1] = 1
403+
404+
observed = {}
405+
406+
def _stub(*args, **kwargs):
407+
observed["random_state"] = kwargs.get("random_state")
408+
_, _, _, a_arg, d_arg, p_arg, n_arg = args[:7]
409+
return (
410+
np.zeros((p_arg, d_arg + a_arg)),
411+
np.zeros((n_arg, p_arg)),
412+
None,
413+
None,
414+
np.zeros((n_arg, p_arg)),
415+
)
416+
417+
with patch.object(_nbgf, "_fit_glm_fast_per_perturbation", side_effect=_stub):
418+
_nbgf.fit_glm_fast(
419+
Y, X, A=A, family="poisson", random_state=123,
420+
)
421+
422+
assert observed["random_state"] == 123
423+
424+
def test_stage1_subsample_uses_caller_random_state(self):
425+
"""For n > 3000, Stage-1 sampling must seed from random_state."""
426+
if not gcate_glm._CRISPYX_AVAILABLE:
427+
pytest.skip("crispyx not installed")
428+
429+
from causarray.nb_glm_fast import _fit_glm_fast_per_perturbation
430+
431+
rng = np.random.default_rng(321)
432+
n, p, a = 3001, 2, 2
433+
Y = rng.poisson(2.0, (n, p)).astype(np.float64)
434+
X = np.ones((n, 1))
435+
A = np.zeros((n, a))
436+
A[1000:2000, 0] = 1
437+
A[2000:, 1] = 1
438+
439+
with patch("numpy.random.default_rng", wraps=np.random.default_rng) as mock_rng:
440+
_fit_glm_fast_per_perturbation(
441+
Y, X, A, a=a, d=1, p=p, n=n,
442+
family="poisson", disp_glm=None,
443+
impute=False, X_test=None,
444+
offset_arr=np.zeros(n),
445+
offsets=None,
446+
maxiter=1, verbose=False,
447+
random_state=123,
448+
)
449+
450+
assert any(call.args == (123,) for call in mock_rng.call_args_list)
451+
452+
338453
# ---------------------------------------------------------------------------
339454
# G13 – mem_limit_gb triggers float32 imputation arrays
340455
# ---------------------------------------------------------------------------
@@ -613,4 +728,3 @@ def _spy(*args, **kw):
613728
f"mem_limit_gb={observed.get('mem_limit_gb')!r}; "
614729
f"expected {tiny_limit!r}."
615730
)
616-

0 commit comments

Comments
 (0)