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:
29 perturbations, ~2 900 cells, ~3 200 genes
Data downloaded from the Broad Single Cell Portal
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)
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)
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:
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 |
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 |
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 |
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.