This page was generated from docs/tutorial/perturbseq/perturbseq-py.ipynb. Interactive online version: Binder badge.

Joint Perturb-seq Perturbation Analysis#

Jin et al. 2020 (Nature Neuroscience) used Perturb-seq to map the transcriptional effects of neuronal gene knockdowns in excitatory neurons.

This tutorial uses a subset of the data to demonstrate the full causarray workflow:

Pipeline overview

perturbseq-exneu.h5ad
      |
      v  prep_causarray_data
   Y, A, X
      |
      v  estimate_r  →  select r (JIC criterion)
      |
      v  fit_gcate   →  estimate latent confounders U
      |
      v  LFC         →  doubly-robust log-fold changes
[1]:
import os
import sys
sys.path.append('../../..')

import numpy as np
import pandas as pd
from scipy import stats
from statsmodels.stats.multitest import multipletests
import seaborn as sns
import matplotlib.pyplot as plt

import scanpy as sc

from causarray import (
    prep_causarray_data, fit_gcate, LFC, estimate_propensity_scores,
    summarize_propensity_scores, plot_propensity_scores, refit_propensity_scores,
    summarize_treatment_associations, plot_treatment_associations,
)

The data can be downloaded from the Broad Single Cell Portal (https://singlecell.broadinstitute.org/single_cell/study/SCP1184). Here we use a pre-processed subset saved as perturbseq-exneu.h5ad.

[2]:
adata = sc.read_h5ad('perturbseq-exneu.h5ad')
adata
[2]:
AnnData object with n_obs × n_vars = 2926 × 3221
    obs: 'orig.ident', 'nCount_RNA', 'nFeature_RNA', 'NAME', 'nGene', 'nUMI', 'Cluster', 'Batch', 'CellType', 'Perturbation', 'isKey', 'isAnalysed', 'SCRUBLET'

For running causarray, we require the following inputs:

  • Y: the cell-by-gene gene expression matrix.

  • A: the cell-by-condition binary matrix of the perturbation/treatment conditions.

  • X, X_A: (optional) the cell-by-covariate matrix of the covariates of interest for outcome and propensity models.

Here, Y and A can be dataframes.

[3]:
Y = pd.DataFrame(adata.X.copy(), columns=adata.var.index)
A = pd.get_dummies(adata.obs['Perturbation'], columns=['Perturbation'], drop_first=False).drop(columns=['GFP'])

Y, A, X, X_A = prep_causarray_data(Y, A)
a = A.shape[1]
a
[3]:
29

Number of latent factors#

We estimate the number of unmeasured confounders r using the JIC criterion: JIC fits GCATE for each candidate r and scores the fit with a likelihood penalty, and we take the r that minimises it.

JIC on its own can keep favouring larger r, but r is a count of parameters and must stay small relative to the sample size. With about 100 treated cells per perturbation, fitting tens of factors would overfit, so we search only up to r = 20 and rely on regularisation (the propensity section below) rather than on adding more factors. A useful rule of thumb is to keep r well under the number of cases — for example r 5 when n 30. Here the minimum sits at r = 10, which is already generous for this sample.

[4]:
from causarray import estimate_r, plot_r
# df_r = estimate_r(Y, X, A, np.arange(5,55,5))
# df_r.to_csv('perturbseq-r.csv', index=False)
df_r = pd.read_csv('perturbseq-r.csv')
# ~100 treated cells per perturbation: cap the search well below the sample size
# and read the minimum from that range instead of chasing ever-larger r.
df_r = df_r[df_r['r'] <= 20]
fig = plot_r(df_r)
../../_images/tutorial_perturbseq_perturbseq-py_7_0.png

Estimate unmeasured confounders#

We run GCATE with the selected r to estimate latent factors that capture unmeasured confounders (e.g. cell-cycle phase, technical variation). The estimated factors are appended to the covariate matrix before calling LFC.

[5]:
r = 10
res_1, res_2 = fit_gcate(Y, X, A, r, verbose=True,
    kwargs_es_1=dict(rel_tol=2e-4, max_iters=30),
    kwargs_es_2=dict(rel_tol=2e-4, max_iters=30),
)
U = res_2['U']
print(f"\nStep 1  -- epochs: {res_1['n_iter']},  best NLL: {min(res_1['hist']):.6f}")
print(f"Step 2  -- epochs: {res_2['n_iter']},  best NLL: {min(res_2['hist']):.6f}")
{'d': 30, 'n': 2926, 'p': 3221, 'r': 10}
'Estimating initial latent variables with GLMs...'
'Fitting nb GLM (fast)...'
'Estimating initial coefficients with GLMs...'
'Fitting nb GLM (fast)...'
{'kwargs_es': {'max_iters': 30,
               'patience': 5,
               'rel_tol': 0.0002,
               'tolerance': 0.0,
               'warmup': 0},
 'kwargs_glm': {'disp_glm': array([ 1.11673516,  1.06870944,  1.16716468, ..., 12.58818245,
       16.46897663,  1.70852614], shape=(3221,)),
                'family': 'nb',
                'size_factor': array([0.53193358, 0.87362742, 1.2235467 , ..., 0.5593801 , 0.73025856,
       0.77857223], shape=(2926,))},
 'kwargs_ls': {'C': 1000.0,
               'alpha': 0.1,
               'beta': 0.5,
               'max_iters': 20,
               'recheck_interval': 10,
               'sparsity_boost': 2.0,
               'sparsity_threshold': 0.5,
               'tol': 0.0001,
               'tol_cell': 0.0001,
               'tol_gene': 0.0001,
               'warmup_iters': 0}}
'Fitting GCATE (step 1)...'
 97%|█████████▋| 29/30 [00:19<00:00,  1.52it/s, Early stopped. Best Epoch: 23. Best Metric: 1.706115.]
{'d': 30, 'n': 2926, 'p': 3221, 'r': 10}
{'kwargs_es': {'max_iters': 30,
               'patience': 5,
               'rel_tol': 0.0002,
               'tolerance': 0.0,
               'warmup': 0},
 'kwargs_glm': {'disp_glm': array([ 1.11673516,  1.06870944,  1.16716468, ..., 12.58818245,
       16.46897663,  1.70852614], shape=(3221,)),
                'family': 'nb',
                'size_factor': array([0.53193358, 0.87362742, 1.2235467 , ..., 0.5593801 , 0.73025856,
       0.77857223], shape=(2926,))},
 'kwargs_ls': {'C': 1000.0,
               'alpha': 0.1,
               'beta': 0.5,
               'max_iters': 20,
               'recheck_interval': 10,
               'sparsity_boost': 2.0,
               'sparsity_threshold': 0.5,
               'tol': 0.0001,
               'tol_cell': 0.0001,
               'tol_gene': 0.0001,
               'warmup_iters': 0}}
'Fitting GCATE (step 2)...'
100%|██████████| 30/30 [00:15<00:00,  1.92it/s, nll=1.72]

Step 1  -- epochs: 29,  best NLL: 1.705777
Step 2  -- epochs: 29,  best NLL: 1.722559

Estimate log-fold change based on counterfactuals#

Next, we apply causarray to estimate the causal effects of perturbations on gene expression. Here the 106 GFP control cells and the perturbation groups (median 89 cells) are similar in size, so we use pooled variance to retain power in this relatively small comparison. This is a dataset-specific choice: unequal variance is preferable when the treated and control groups differ meaningfully in size or effective sample size, when outcome variability differs between the two groups, and for the Replogle and case-control tutorials.

[6]:
offsets = np.log(res_2['kwargs_glm']['size_factor']) # use the precomputed size factors
df_res, estimation = LFC(Y, np.c_[X, U], A, np.c_[X_A, U], offset=offsets, usevar='pooled', verbose=True)
'Estimating LFC...'
{'a': 29, 'd': 11, 'd_A': 12, 'estimands': 'LFC', 'n': 2926, 'p': 3221}
{'offset': array([-0.63123664, -0.13510128,  0.20175377, ..., -0.58092607,
       -0.31435661, -0.25029351], shape=(2926,)),
 'random_state': 0,
 'verbose': True}
'Fit propensity score models...'
{'C': 1.0,
 'class_weight': 'balanced',
 'fit_intercept': False,
 'random_state': 0,
 'verbose': False}
'Fit outcome models...'
'Fitting nb GLM (fast)...'
('Fast GLM coefficients exceed bound (max|B|=1.53e+05 > 1e+04); falling back '
 'to statsmodels...')
'Estimating dispersion parameter...'
'Fitting poisson GLM with offset...'
'Fitting nb GLM with offset...'
100%|██████████| 3221/3221 [00:19<00:00, 161.33it/s]
'Fitting GLM done.'
'Estimating AIPW mean...'
100%|██████████| 29/29 [00:00<00:00, 59.43it/s]
[7]:
# Filter the results for significant discoveries
significant_discoveries = df_res[df_res['padj'] < 0.1]

# Count the number of discoveries for each perturbation condition
discovery_counts = significant_discoveries['trt'].value_counts().reset_index()
discovery_counts.columns = ['Perturbation', 'Count']

# Plot the number of discoveries for each perturbation condition
plt.figure(figsize=(12, 6))
sns.barplot(data=discovery_counts, x='Perturbation', y='Count')
plt.xticks(rotation=90)
plt.title('Number of Discoveries (padj < 0.1) for Each Perturbation Condition')
plt.xlabel('Perturbation Condition')
plt.ylabel('Number of Discoveries')
plt.show()
print(f"Total significant gene-perturbation pairs (padj < 0.1): {len(significant_discoveries):,}")
print(discovery_counts.to_string(index=False))

direction = df_res.assign(
    significant=df_res['padj'] < 0.1,
    significant_negative=(df_res['padj'] < 0.1) & (df_res['tau'] < 0),
    significant_positive=(df_res['padj'] < 0.1) & (df_res['tau'] > 0),
).groupby('trt').agg(
    negative_fraction=('tau', lambda value: (value < 0).mean()),
    median_tau=('tau', 'median'),
    significant=('significant', 'sum'),
    significant_negative=('significant_negative', 'sum'),
    significant_positive=('significant_positive', 'sum'),
).sort_values('negative_fraction', ascending=False)
display(direction)
../../_images/tutorial_perturbseq_perturbseq-py_13_0.png
Total significant gene-perturbation pairs (padj < 0.1): 15,456
Perturbation  Count
       Satb2   1858
        Cul3    962
       Asxl3    901
       Upf3b    899
      Med13l    855
        Mbd5    682
      Scn2a1    664
       Ddx3x    616
      Fbxo11    593
        Spen    567
      Stard9    566
       Setd5    552
       Setd2    516
       Ash1l    512
      Ctnnb1    491
        Chd8    469
     Syngap1    462
      Qrich1    459
         Wac    424
       Kdm5b    410
      Tnrc6b    371
        Adnp    364
        Pogz    326
       Dscam    270
       Myst4    264
       Tcf20    187
        Pten    179
      Dyrk1a     20
        Mll1     17
negative_fraction median_tau significant significant_negative significant_positive
trt
Med13l 0.668115 -0.097780 855 653 202
Scn2a1 0.667495 -0.081455 664 543 121
Mbd5 0.665011 -0.078835 682 536 146
Upf3b 0.663148 -0.083435 899 715 184
Satb2 0.661596 -0.142076 1858 1398 460
Setd5 0.659112 -0.082058 552 458 94
Chd8 0.646383 -0.063514 469 374 95
Ctnnb1 0.645141 -0.062003 491 371 120
Asxl3 0.643589 -0.073545 901 696 205
Myst4 0.641726 -0.058866 264 228 36
Qrich1 0.640174 -0.066926 459 371 88
Ddx3x 0.637069 -0.069501 616 492 124
Fbxo11 0.637069 -0.066243 593 465 128
Ash1l 0.628376 -0.061589 512 397 115
Wac 0.619994 -0.060711 424 326 98
Adnp 0.615337 -0.048482 364 271 93
Setd2 0.614095 -0.060049 516 401 115
Spen 0.611301 -0.062010 567 438 129
Kdm5b 0.610680 -0.050059 410 316 94
Mll1 0.609749 -0.034270 17 14 3
Tnrc6b 0.604781 -0.040386 371 263 108
Cul3 0.591121 -0.051553 962 639 323
Syngap1 0.590810 -0.043063 462 338 124
Pogz 0.588948 -0.042806 326 236 90
Dscam 0.583049 -0.037321 270 204 66
Stard9 0.576219 -0.034544 566 375 191
Tcf20 0.571251 -0.028675 187 126 61
Dyrk1a 0.538963 -0.016710 20 15 5
Pten 0.511332 -0.007551 179 96 83

Propensity-score and positivity diagnostics#

Positivity requires treated and control cells with comparable covariates. Each perturbation is scored against the shared controls with a five-fold out-of-fold (OOF) logistic model using class_weight='balanced' — the same model LFC uses internally, and out-of-fold means each cell is scored by a model that was not trained on it, so an overfit model cannot flatter its own separation. The table reports the histogram overlap between the treated and control scores, the fraction of scores outside [0.05, 0.95], and the effective sample size (ESS) of the inverse-probability weights.

ESS is reported as a fraction between 0 and 1: the share of a group’s cells that effectively contribute after weighting, so larger is better and a small value means a few cells carry the estimate.

Formally, ESS is Kish’s effective sample size of the inverse-probability weights:

\[\begin{split}\mathrm{ESS} = \frac{\left(\sum_i w_i\right)^2}{\sum_i w_i^2}, \qquad w_i = \begin{cases} 1/\hat{\pi}_i & \text{treated cells} \\ 1/(1-\hat{\pi}_i) & \text{control cells,} \end{cases}\end{split}\]

where \(\hat{\pi}_i = P(\text{treated} \mid X_i)\) is the propensity score. Equal weights give \(\mathrm{ESS}=n\) (fraction 1); a few dominant weights push it toward 0.

The balanced weighting makes the two groups comparable for estimation, so these scores are not literal probabilities of treatment. You would switch to calibrated scores only if you wanted to read a value as P(treated | covariates) — for instance to threshold it or report it directly; then set class_weight=None here and ps_class_weight=None in LFC, and expect better-calibrated but noisier scores.

[8]:
W_A = np.c_[X_A, U]
pi_oof = estimate_propensity_scores(
    A, W_A, K=5, class_weight='balanced', random_state=0,
)
# clip_bounds=None: these are raw out-of-fold scores, nothing has been clipped yet.
ps_summary = summarize_propensity_scores(A, pi_oof, clip_bounds=None)
display(
    ps_summary.sort_values('overlap_ratio')
    [['treatment', 'n_treated', 'overlap_ratio', 'outside_overlap_fraction',
      'ess_control_fraction', 'ess_treated_fraction', 'brier_score']]
    .head(8).round(3)
)

weakest = ps_summary.nsmallest(4, 'overlap_ratio')['treatment'].tolist()
fig, axes, _ = plot_propensity_scores(A, pi_oof, treatments=weakest, clip_bounds=None)
plt.show()
treatment n_treated overlap_ratio outside_overlap_fraction ess_control_fraction ess_treated_fraction brier_score
18 Satb2 51 0.164 0.140 0.395 0.714 0.097
11 Mbd5 119 0.303 0.049 0.365 0.445 0.135
12 Med13l 75 0.313 0.017 0.699 0.458 0.149
27 Upf3b 100 0.318 0.029 0.640 0.699 0.135
2 Asxl3 130 0.319 0.030 0.552 0.865 0.134
19 Scn2a1 93 0.334 0.030 0.644 0.570 0.148
20 Setd2 76 0.335 0.011 0.592 0.758 0.174
17 Qrich1 86 0.362 0.000 0.761 0.573 0.170
../../_images/tutorial_perturbseq_perturbseq-py_15_1.png

Overfitting shows up as a gap between the in-sample and out-of-fold Brier scores. The next cell computes both, next to a more strongly regularised out-of-fold fit (C=0.1), so the table can be read both for overfitting and for whether extra shrinkage actually helps.

[9]:
pi_train = estimate_propensity_scores(
    A, W_A, K=1, class_weight='balanced', random_state=0,
)
ps_train = summarize_propensity_scores(A, pi_train)
pi_oof_regularized = estimate_propensity_scores(
    A, W_A, K=5, C=0.1, class_weight='balanced', random_state=0,
)
ps_regularized = summarize_propensity_scores(A, pi_oof_regularized)
overfit_check = ps_train[['treatment', 'brier_score']].rename(
    columns={'brier_score': 'brier_train_C1'}
).merge(
    ps_summary[['treatment', 'overlap_ratio', 'brier_score']].rename(
        columns={'brier_score': 'brier_oof_C1'}), on='treatment'
).merge(
    ps_regularized[['treatment', 'overlap_ratio', 'brier_score']].rename(
        columns={'overlap_ratio': 'overlap_ratio_C01', 'brier_score': 'brier_oof_C01'}),
    on='treatment',
)
display(overfit_check.sort_values('overlap_ratio').head(10))

# Reuse fitted outcome models for a propensity sensitivity analysis.
# df_oof, _ = LFC(Y, np.c_[X, U], A, W_A, offset=offsets,
#                 Y_hat=estimation['Y_hat'], pi_hat=pi_oof, usevar='pooled')
treatment brier_train_C1 overlap_ratio brier_oof_C1 overlap_ratio_C01 brier_oof_C01
18 Satb2 0.075466 0.164077 0.096756 0.288198 0.146275
11 Mbd5 0.116343 0.303393 0.134982 0.452275 0.178107
12 Med13l 0.131976 0.313208 0.148720 0.448176 0.187060
27 Upf3b 0.118060 0.317547 0.135306 0.446415 0.182605
2 Asxl3 0.117547 0.319448 0.133628 0.512046 0.184543
19 Scn2a1 0.129479 0.333739 0.147952 0.502029 0.190732
20 Setd2 0.147268 0.335402 0.173702 0.514151 0.203231
17 Qrich1 0.148151 0.362440 0.169654 0.544756 0.210978
6 Ddx3x 0.146036 0.367925 0.175877 0.537736 0.206478
9 Fbxo11 0.151591 0.394612 0.168791 0.566633 0.209796

Reading the tables. A large jump from the in-sample to the out-of-fold Brier score flags overfitting; stronger regularisation (a smaller C) is worth adopting only when it lowers the out-of-fold Brier, not merely when it makes the histograms overlap more.

The overlap ratio is descriptive, not a hard pass/fail threshold; however, 0.25 is a reasonable rule-of-thumb floor, and here 28 of the 29 perturbations clear it. Satb2 is the exception: overlap 0.164, 14.0% of its scores outside [0.05, 0.95], and a control ESS of only 0.40. Its estimates deserve more caution than the rest, which makes it the natural candidate for the treatment-specific tuning below.

Treatment-specific association diagnostics#

These helpers apply the same shared-control comparison to observed covariates and latent factors. Spearman correlation captures monotone association, while the standardized mean difference puts it on a familiar effect-size scale. P-values are BH-adjusted across every treatment-by-covariate pair by default; pass bh_scope='per_treatment' to adjust within each treatment instead. No cutoff or drop decision is applied for you.

Log-library size is strongly associated with several perturbations, but it is a prespecified technical adjustment and we keep it — association alone is not a reason to drop a covariate, and dropping a genuine confounder to improve an overlap plot trades a visible problem for an invisible one. For Satb2, two latent factors stand out: U9 (Spearman \(\rho \approx 0.25\), adjusted \(p \approx 0.02\)) and U8 (\(\rho \approx -0.24\), adjusted \(p \approx 0.03\)). We use both below in the sensitivity analysis.

[10]:
factor_names = [f'U{k + 1}' for k in range(U.shape[1])]
propensity_names = ['intercept', 'log_library_size', *factor_names]
propensity_types = ['observed', 'observed', *(['latent'] * U.shape[1])]
association_summary = summarize_treatment_associations(
    A, W_A, covariate_names=propensity_names,
    covariate_types=propensity_types,
)
observed_associations = association_summary.query("covariate_type == 'observed' and not constant")
latent_associations = association_summary.query("covariate_type == 'latent'").copy()
display(observed_associations.sort_values('padj').head(10))
display(
    latent_associations.assign(abs_smd=lambda frame: frame['standardized_mean_difference'].abs())
    .sort_values('abs_smd', ascending=False).drop(columns='abs_smd').head(10)
)
fig, ax = plot_treatment_associations(association_summary)
plt.show()
treatment covariate covariate_type n_control n_treated spearman_rho pvalue padj standardized_mean_difference constant n_tests_in_family
217 Satb2 log_library_size observed 106 51 -0.620601 4.352985e-18 1.388602e-15 -1.670432 False 319
133 Mbd5 log_library_size observed 106 119 -0.525168 2.377952e-17 3.792833e-15 -1.196548 False 319
25 Asxl3 log_library_size observed 106 130 -0.501171 2.061826e-16 2.192409e-14 -1.147867 False 319
325 Upf3b log_library_size observed 106 100 -0.524464 5.917208e-16 4.718973e-14 -1.193805 False 319
229 Scn2a1 log_library_size observed 106 93 -0.479169 8.091610e-13 5.162447e-11 -1.070860 False 319
145 Med13l log_library_size observed 106 75 -0.491124 2.226940e-12 1.183990e-10 -1.072918 False 319
241 Setd2 log_library_size observed 106 76 -0.450423 1.771080e-10 8.071064e-09 -0.971646 False 319
253 Setd5 log_library_size observed 106 71 -0.438344 1.046791e-09 3.710293e-08 -0.952658 False 319
13 Ash1l log_library_size observed 106 122 -0.390531 1.003845e-09 3.710293e-08 -0.806900 False 319
109 Fbxo11 log_library_size observed 106 111 -0.394344 1.730357e-09 5.519838e-08 -0.844210 False 319
treatment covariate covariate_type n_control n_treated spearman_rho pvalue padj standardized_mean_difference constant n_tests_in_family
226 Satb2 U9 latent 106 51 0.247280 0.001795 0.022898 0.520999 False 319
227 Satb2 U10 latent 106 51 -0.190262 0.016997 0.154919 -0.429236 False 319
155 Med13l U10 latent 106 75 -0.205422 0.005534 0.060877 -0.411375 False 319
225 Satb2 U8 latent 106 51 -0.242478 0.002215 0.027178 -0.406490 False 319
143 Mbd5 U10 latent 106 119 -0.175056 0.008499 0.087460 -0.328204 False 319
152 Med13l U7 latent 106 75 -0.101960 0.172006 0.883705 -0.319727 False 319
35 Asxl3 U10 latent 106 130 -0.160805 0.013386 0.125593 -0.314906 False 319
224 Satb2 U7 latent 106 51 0.206467 0.009477 0.094470 0.313717 False 319
239 Scn2a1 U10 latent 106 93 -0.148151 0.036771 0.308679 -0.284932 False 319
335 Upf3b U10 latent 106 100 -0.163987 0.018508 0.163999 -0.272426 False 319
../../_images/tutorial_perturbseq_perturbseq-py_20_2.png

Tuning Satb2’s propensity model#

Propensity scores are fit one treatment at a time against the shared controls, so you can change the model for a single perturbation and leave the other 28 untouched. refit_propensity_scores refits only the treatments you name and returns an audit table next to the updated scores. Four alternatives for Satb2:

  • drop U9 — remove the single most imbalanced latent factor;

  • drop U8 — remove the other flagged latent factor instead;

  • 10x library penalty — keep every covariate, but apply ten times the usual L2 penalty to standardized log-library size (implemented by dividing that column by \(\sqrt{10}\) during both fitting and prediction);

  • Satb2 C=0.1 — keep every covariate and shrink all of them more strongly.

Out-of-fold scores drive the overlap diagnostics; the analysis scores then reuse estimation['Y_hat'], so no outcome model is refitted.

[11]:
# Four treatment-specific alternatives for Satb2. Each refits Satb2 alone and
# leaves the other 28 perturbations' scores exactly as they were. The two single-
# factor drops show that *which* flagged factor you remove matters.
satb2_variants = {
    'drop U9': dict(drop_by_treatment={'Satb2': ['U9']}),
    'drop U8': dict(drop_by_treatment={'Satb2': ['U8']}),
    '10x library penalty': dict(
        penalty_factors_by_treatment={'Satb2': {'log_library_size': 10}}),
    'Satb2 C=0.1': dict(drop_by_treatment={'Satb2': []}, C=0.1),
}


def refit_satb2(pi_hat, K, **kwargs):
    return refit_propensity_scores(
        A, W_A, pi_hat=pi_hat, covariate_names=propensity_names, K=K,
        class_weight='balanced', random_state=0, **kwargs,
    )


# Out-of-fold scores, used only for the overlap diagnostics.
oof_variants, audits = {}, []
for name, options in satb2_variants.items():
    scores, audit = refit_satb2(pi_oof, 5, **options)
    oof_variants[name] = scores
    audits.append(audit.assign(model=name))
display(
    pd.concat(audits)
    [['model', 'n_retained', 'penalty_factors', 'degenerate_design', 'score_std']]
    .round(3)
)

satb2_overlap = pd.concat([
    summarize_propensity_scores(A, scores, clip_bounds=None)
    .query("treatment == 'Satb2'").assign(model=name)
    for name, scores in {'all factors': pi_oof, **oof_variants}.items()
])[['model', 'overlap_ratio', 'outside_overlap_fraction',
    'ess_control_fraction', 'ess_treated_fraction', 'brier_score']]
display(satb2_overlap.round(3))

fig, axes, _ = plot_propensity_scores(
    A, oof_variants['Satb2 C=0.1'], treatments=['Satb2'], clip_bounds=None,
)
fig.suptitle('Satb2 after C=0.1 regularization', y=1.02)
plt.show()

# Analysis scores reuse the cached outcome model, so no outcome model is refitted.
satb2_effects = (
    df_res.query("trt == 'Satb2'")[['gene_names', 'tau', 'padj']]
    .rename(columns={'tau': 'tau_all', 'padj': 'padj_all'})
)
comparison = []
for name, options in satb2_variants.items():
    pi_analysis, _ = refit_satb2(estimation['pi_hat_raw'], 1, **options)
    df_alt, _ = LFC(
        Y, np.c_[X, U], A, W_A, offset=offsets, usevar='pooled',
        Y_hat=estimation['Y_hat'], pi_hat=pi_analysis,
    )
    merged = satb2_effects.merge(
        df_alt.query("trt == 'Satb2'")[['gene_names', 'tau', 'padj']], on='gene_names',
    )
    comparison.append({
        'model': name,
        'effect_correlation': merged[['tau_all', 'tau']].corr().iloc[0, 1],
        'median_absolute_change': (merged['tau_all'] - merged['tau']).abs().median(),
        'discoveries': int((merged['padj'] < 0.1).sum()),
    })
display(
    pd.DataFrame(comparison)
    .assign(discoveries_all=int((satb2_effects['padj_all'] < 0.1).sum()))
    .round(3)
)
model n_retained penalty_factors degenerate_design score_std
0 drop U9 11 {} False 0.322
0 drop U8 11 {} False 0.319
0 10x library penalty 12 {'log_library_size': 10.0} False 0.237
0 Satb2 C=0.1 12 {} False 0.239
model overlap_ratio outside_overlap_fraction ess_control_fraction ess_treated_fraction brier_score
18 all factors 0.164 0.140 0.395 0.714 0.097
18 drop U9 0.183 0.134 0.403 0.712 0.096
18 drop U8 0.306 0.159 0.039 0.563 0.140
18 10x library penalty 0.268 0.019 0.723 0.792 0.137
18 Satb2 C=0.1 0.288 0.006 0.627 0.813 0.146
../../_images/tutorial_perturbseq_perturbseq-py_22_2.png
100%|██████████| 29/29 [00:00<00:00, 58.53it/s]
100%|██████████| 29/29 [00:00<00:00, 60.72it/s]
100%|██████████| 29/29 [00:00<00:00, 61.43it/s]
100%|██████████| 29/29 [00:00<00:00, 61.95it/s]
model effect_correlation median_absolute_change discoveries discoveries_all
0 drop U9 1.000 0.004 1852 1858
1 drop U8 0.840 0.184 653 1858
2 10x library penalty 0.997 0.012 1775 1858
3 Satb2 C=0.1 0.982 0.024 1568 1858

What the four variants show. Which factor you drop matters, and neither single drop is the answer.

Dropping U9 — the single most imbalanced factor — does essentially nothing: overlap barely moves (0.164 to 0.183, still below the 0.25 floor, so it does not fix the problem) and the effects are unchanged (correlation 1.000, 1,852 versus 1,858 discoveries). Dropping U8 instead looks like a win — overlap jumps to 0.306 — but it guts the control effective sample size, from 0.40 to 0.04, and destabilises the effects (correlation 0.84, discoveries 653); a high overlap ratio bought this way is misleading, because the estimate now rests on almost no effective controls. That collapse is a weight problem: dropping U8 pushes a few control cells to propensity scores near 1, so their \(1/(1-\hat{\pi}_i)\) weights become extreme and a handful of controls dominate the arm. So the message is not “drop U9, keep U8”: dropping U9 fixes nothing and dropping U8 does real damage. Factor-dropping is the wrong tool here — keep all the factors and regularise instead.

Penalising the propensity model is reliable. The feature-specific 10x library-size penalty and, more simply, global C=0.1 both raise overlap into the 0.27–0.29 range — above the 0.25 rule of thumb and in line with the other perturbations — while keeping the effective sample sizes healthy (for C=0.1, control 0.63 and treated 0.81) and the effects stable (correlation 0.98). Almost all scores now sit inside [0.05, 0.95]. The only real cost is a modest rise in the out-of-fold Brier score, from 0.097 to 0.146 for C=0.1.

For a small treatment (51 cells) this is a good trade. A penalised model deliberately accepts a little bias in return for propensity scores that are less variable and better supported, which makes the downstream estimate more trustworthy — exactly the overlapping, in-range scores reviewers ask for. The drop in discoveries (1,858 to 1,568) is not a loss: a conservative, well-supported list is the goal, not the largest one.

In practice, applying C=0.1 to every perturbation is a sensible default. It gives up a little power on the well-behaved perturbations but spares you from hand-tuning the few problem cases like Satb2 — a good bargain when you have many analyses to run.