causarray 0.0.6__tar.gz → 0.0.8__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {causarray-0.0.6 → causarray-0.0.8}/.gitignore +8 -1
- {causarray-0.0.6 → causarray-0.0.8}/PKG-INFO +33 -13
- {causarray-0.0.6 → causarray-0.0.8}/README.md +32 -12
- {causarray-0.0.6 → causarray-0.0.8}/causarray/DR_estimation.py +201 -54
- {causarray-0.0.6 → causarray-0.0.8}/causarray/DR_learner.py +192 -37
- causarray-0.0.8/causarray/__about__.py +1 -0
- {causarray-0.0.6 → causarray-0.0.8}/causarray/__init__.py +5 -1
- causarray-0.0.8/causarray/diagnostics.py +172 -0
- causarray-0.0.6/causarray/__about__.py +0 -1
- {causarray-0.0.6 → causarray-0.0.8}/LICENSE +0 -0
- {causarray-0.0.6 → causarray-0.0.8}/causarray/DR_inference.py +0 -0
- {causarray-0.0.6 → causarray-0.0.8}/causarray/gcate.py +0 -0
- {causarray-0.0.6 → causarray-0.0.8}/causarray/gcate_glm.py +0 -0
- {causarray-0.0.6 → causarray-0.0.8}/causarray/gcate_likelihood.py +0 -0
- {causarray-0.0.6 → causarray-0.0.8}/causarray/gcate_opt.py +0 -0
- {causarray-0.0.6 → causarray-0.0.8}/causarray/nb_glm_fast.py +0 -0
- {causarray-0.0.6 → causarray-0.0.8}/causarray/utils.py +0 -0
- {causarray-0.0.6 → causarray-0.0.8}/pyproject.toml +0 -0
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# Planning documents
|
|
2
2
|
plan/
|
|
3
3
|
|
|
4
|
+
# Local Codex project guidance
|
|
5
|
+
/AGENTS.md
|
|
6
|
+
|
|
4
7
|
# Byte-compiled / optimized / DLL files
|
|
5
8
|
__pycache__/
|
|
6
9
|
*.py[cod]
|
|
@@ -170,11 +173,15 @@ cython_debug/
|
|
|
170
173
|
tests/benchmark_adamson.py
|
|
171
174
|
|
|
172
175
|
causarray/___*.py
|
|
173
|
-
|
|
176
|
+
|
|
177
|
+
# Adamson tutorial is currently a local analysis and is not part of the
|
|
178
|
+
# committed documentation.
|
|
179
|
+
/docs/source/tutorial/adamson/
|
|
174
180
|
|
|
175
181
|
# Replogle tutorial: large data files (regenerate with prep_tutorial_data.py or
|
|
176
182
|
# download from the project data repository; replogle_subset.h5ad is ~2 GB)
|
|
177
183
|
docs/source/tutorial/replogle/replogle_subset.h5ad
|
|
184
|
+
docs/source/tutorial/replogle/replogle_subset_norm*.h5ad
|
|
178
185
|
docs/source/tutorial/replogle/replogle_normed.h5ad
|
|
179
186
|
docs/source/tutorial/replogle/replogle_results.h5
|
|
180
187
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: causarray
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.8
|
|
4
4
|
Summary: causarray is a Python module for simultaneous causal inference with an array of outcomes.
|
|
5
5
|
Author-email: Jin-Hong Du <jinhongd@hku.hk>, Maya Shen <myshen@andrew.cmu.edu>, Hansruedi Mathys <mathysh@pitt.edu>, Kathryn Roeder <jinhongd@hku.hk>
|
|
6
6
|
Maintainer-email: Jin-Hong Du <jinhongd@hku.hk>
|
|
@@ -30,7 +30,7 @@ Description-Content-Type: text/markdown
|
|
|
30
30
|
|
|
31
31
|
# causarray
|
|
32
32
|
|
|
33
|
-
Advances in single-cell sequencing and CRISPR technologies have enabled detailed case-control comparisons and experimental perturbations at single-cell resolution. However, uncovering causal relationships in observational genomic data remains challenging due to selection bias and inadequate adjustment for unmeasured confounders, particularly in heterogeneous datasets. To address these challenges, we introduce `causarray` [
|
|
33
|
+
Advances in single-cell sequencing and CRISPR technologies have enabled detailed case-control comparisons and experimental perturbations at single-cell resolution. However, uncovering causal relationships in observational genomic data remains challenging due to selection bias and inadequate adjustment for unmeasured confounders, particularly in heterogeneous datasets. To address these challenges, we introduce `causarray` [Du26], a doubly robust causal inference framework for analyzing array-based genomic data at both bulk-cell and single-cell levels. `causarray` integrates a generalized confounder adjustment method to account for unmeasured confounders and employs semiparametric inference with flexible machine learning techniques to ensure robust statistical estimation of treatment effects.
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
## Usage
|
|
@@ -54,15 +54,33 @@ For optimal parallel performance, we recommend installing `llvm-openmp` if using
|
|
|
54
54
|
conda install -c conda-forge llvm-openmp
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
-
For `R` users, `reticulate` can be used to call `causarray` from
|
|
57
|
+
For `R` users, `reticulate` can be used to call `causarray` from R while
|
|
58
|
+
keeping NumPy >2 in the Python environment. Create the separate R environment
|
|
59
|
+
with a current NumPy-2-compatible reticulate build:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
conda env create -f environment-r.yaml
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The R tutorial runs from `causarray-r` and connects to the Python package in
|
|
66
|
+
the `causarray` environment.
|
|
58
67
|
The documentation and tutorials using both `Python` and `R` are available at [causarray.readthedocs.io](https://causarray.readthedocs.io/en/latest/).
|
|
59
68
|
|
|
60
69
|
|
|
61
70
|
|
|
62
|
-
##
|
|
71
|
+
## Tutorials
|
|
72
|
+
|
|
73
|
+
| Tutorial | Language | Description | Link |
|
|
74
|
+
|----------|----------|-------------|------|
|
|
75
|
+
| Perturb-seq [Jin20] | Python | CRISPR screen analysis on excitatory neurons | [Notebook](https://causarray.readthedocs.io/en/latest/tutorial/perturbseq/perturbseq-py.html) |
|
|
76
|
+
| Perturb-seq [Jin20] | R | Same analysis using `reticulate` | [Notebook](https://causarray.readthedocs.io/en/latest/tutorial/perturbseq/perturbseq-r.html) |
|
|
77
|
+
| Genome-wide CRISPRi screen [Replogle22] | Python | Batch fitting on 200 perturbations from a K562 genome-wide CRISPRi screen | [Notebook](https://causarray.readthedocs.io/en/latest/tutorial/replogle/replogle-py.html) |
|
|
78
|
+
| Case-control: SEA-AD [Gabitto24] | Python | Causal inference on observational single-cell data (Alzheimer's disease) | [Notebook](https://causarray.readthedocs.io/en/latest/tutorial/case_control/sea_ad_case_control.html) |
|
|
63
79
|
|
|
64
|
-
|
|
65
|
-
|
|
80
|
+
### Batch fitting API
|
|
81
|
+
|
|
82
|
+
For screens with hundreds to thousands of perturbations, use `gcate_lfc_batch` so
|
|
83
|
+
that peak memory is bounded by one batch at a time:
|
|
66
84
|
|
|
67
85
|
```python
|
|
68
86
|
from causarray import gcate_lfc_batch
|
|
@@ -77,16 +95,12 @@ df_res = gcate_lfc_batch(
|
|
|
77
95
|
)
|
|
78
96
|
```
|
|
79
97
|
|
|
80
|
-
See the [Replogle-E-K562 tutorial](https://causarray.readthedocs.io/en/latest/)
|
|
98
|
+
See the [Replogle-E-K562 tutorial](https://causarray.readthedocs.io/en/latest/tutorial/replogle/replogle-py.html)
|
|
81
99
|
for a demonstration on 200 perturbations from a genome-wide CRISPRi screen.
|
|
82
100
|
|
|
83
101
|
## Changelog
|
|
84
102
|
|
|
85
|
-
|
|
86
|
-
- [x] (2025-02-01) Code for reproducing figures in paper
|
|
87
|
-
- [x] (2025-02-02) Tutorial for Python and R
|
|
88
|
-
- [x] (2026-05-31) Batch fitting API (`gcate_lfc_batch`) for large-scale screens
|
|
89
|
-
- [x] (2026-05-31) Documentation at [causarray.readthedocs.io](https://causarray.readthedocs.io/en/latest/)
|
|
103
|
+
See [CHANGELOG](https://causarray.readthedocs.io/en/latest/changelog.html) for a full version history.
|
|
90
104
|
|
|
91
105
|
|
|
92
106
|
<!--
|
|
@@ -128,4 +142,10 @@ rmarkdown::render("perturbseq.Rmd", rmarkdown::md_document(variant = "markdown_g
|
|
|
128
142
|
|
|
129
143
|
|
|
130
144
|
## References
|
|
131
|
-
[
|
|
145
|
+
[Du26] Jin-Hong Du, Maya Shen, Hansruedi Mathys, and Kathryn Roeder. "Uncovering causal relationships in single cell omic studies with causarray". In: Briefings in Bioinformatics (2026).
|
|
146
|
+
|
|
147
|
+
[Gabitto24] Mariano I. Gabitto et al. "Integrated multimodal cell atlas of Alzheimer's disease". In: Nature Neuroscience (2024).
|
|
148
|
+
|
|
149
|
+
[Jin20] Xin Jin et al. "In vivo Perturb-seq reveals neuronal and glial abnormalities associated with autism risk genes". In: Nature Neuroscience (2020).
|
|
150
|
+
|
|
151
|
+
[Replogle22] Joseph M. Replogle et al. "Mapping information-rich genotype-phenotype landscapes with genome-scale Perturb-seq". In: Cell (2022).
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
# causarray
|
|
7
7
|
|
|
8
|
-
Advances in single-cell sequencing and CRISPR technologies have enabled detailed case-control comparisons and experimental perturbations at single-cell resolution. However, uncovering causal relationships in observational genomic data remains challenging due to selection bias and inadequate adjustment for unmeasured confounders, particularly in heterogeneous datasets. To address these challenges, we introduce `causarray` [
|
|
8
|
+
Advances in single-cell sequencing and CRISPR technologies have enabled detailed case-control comparisons and experimental perturbations at single-cell resolution. However, uncovering causal relationships in observational genomic data remains challenging due to selection bias and inadequate adjustment for unmeasured confounders, particularly in heterogeneous datasets. To address these challenges, we introduce `causarray` [Du26], a doubly robust causal inference framework for analyzing array-based genomic data at both bulk-cell and single-cell levels. `causarray` integrates a generalized confounder adjustment method to account for unmeasured confounders and employs semiparametric inference with flexible machine learning techniques to ensure robust statistical estimation of treatment effects.
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
## Usage
|
|
@@ -29,15 +29,33 @@ For optimal parallel performance, we recommend installing `llvm-openmp` if using
|
|
|
29
29
|
conda install -c conda-forge llvm-openmp
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
For `R` users, `reticulate` can be used to call `causarray` from
|
|
32
|
+
For `R` users, `reticulate` can be used to call `causarray` from R while
|
|
33
|
+
keeping NumPy >2 in the Python environment. Create the separate R environment
|
|
34
|
+
with a current NumPy-2-compatible reticulate build:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
conda env create -f environment-r.yaml
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The R tutorial runs from `causarray-r` and connects to the Python package in
|
|
41
|
+
the `causarray` environment.
|
|
33
42
|
The documentation and tutorials using both `Python` and `R` are available at [causarray.readthedocs.io](https://causarray.readthedocs.io/en/latest/).
|
|
34
43
|
|
|
35
44
|
|
|
36
45
|
|
|
37
|
-
##
|
|
46
|
+
## Tutorials
|
|
47
|
+
|
|
48
|
+
| Tutorial | Language | Description | Link |
|
|
49
|
+
|----------|----------|-------------|------|
|
|
50
|
+
| Perturb-seq [Jin20] | Python | CRISPR screen analysis on excitatory neurons | [Notebook](https://causarray.readthedocs.io/en/latest/tutorial/perturbseq/perturbseq-py.html) |
|
|
51
|
+
| Perturb-seq [Jin20] | R | Same analysis using `reticulate` | [Notebook](https://causarray.readthedocs.io/en/latest/tutorial/perturbseq/perturbseq-r.html) |
|
|
52
|
+
| Genome-wide CRISPRi screen [Replogle22] | Python | Batch fitting on 200 perturbations from a K562 genome-wide CRISPRi screen | [Notebook](https://causarray.readthedocs.io/en/latest/tutorial/replogle/replogle-py.html) |
|
|
53
|
+
| Case-control: SEA-AD [Gabitto24] | Python | Causal inference on observational single-cell data (Alzheimer's disease) | [Notebook](https://causarray.readthedocs.io/en/latest/tutorial/case_control/sea_ad_case_control.html) |
|
|
38
54
|
|
|
39
|
-
|
|
40
|
-
|
|
55
|
+
### Batch fitting API
|
|
56
|
+
|
|
57
|
+
For screens with hundreds to thousands of perturbations, use `gcate_lfc_batch` so
|
|
58
|
+
that peak memory is bounded by one batch at a time:
|
|
41
59
|
|
|
42
60
|
```python
|
|
43
61
|
from causarray import gcate_lfc_batch
|
|
@@ -52,16 +70,12 @@ df_res = gcate_lfc_batch(
|
|
|
52
70
|
)
|
|
53
71
|
```
|
|
54
72
|
|
|
55
|
-
See the [Replogle-E-K562 tutorial](https://causarray.readthedocs.io/en/latest/)
|
|
73
|
+
See the [Replogle-E-K562 tutorial](https://causarray.readthedocs.io/en/latest/tutorial/replogle/replogle-py.html)
|
|
56
74
|
for a demonstration on 200 perturbations from a genome-wide CRISPRi screen.
|
|
57
75
|
|
|
58
76
|
## Changelog
|
|
59
77
|
|
|
60
|
-
|
|
61
|
-
- [x] (2025-02-01) Code for reproducing figures in paper
|
|
62
|
-
- [x] (2025-02-02) Tutorial for Python and R
|
|
63
|
-
- [x] (2026-05-31) Batch fitting API (`gcate_lfc_batch`) for large-scale screens
|
|
64
|
-
- [x] (2026-05-31) Documentation at [causarray.readthedocs.io](https://causarray.readthedocs.io/en/latest/)
|
|
78
|
+
See [CHANGELOG](https://causarray.readthedocs.io/en/latest/changelog.html) for a full version history.
|
|
65
79
|
|
|
66
80
|
|
|
67
81
|
<!--
|
|
@@ -103,4 +117,10 @@ rmarkdown::render("perturbseq.Rmd", rmarkdown::md_document(variant = "markdown_g
|
|
|
103
117
|
|
|
104
118
|
|
|
105
119
|
## References
|
|
106
|
-
[
|
|
120
|
+
[Du26] Jin-Hong Du, Maya Shen, Hansruedi Mathys, and Kathryn Roeder. "Uncovering causal relationships in single cell omic studies with causarray". In: Briefings in Bioinformatics (2026).
|
|
121
|
+
|
|
122
|
+
[Gabitto24] Mariano I. Gabitto et al. "Integrated multimodal cell atlas of Alzheimer's disease". In: Nature Neuroscience (2024).
|
|
123
|
+
|
|
124
|
+
[Jin20] Xin Jin et al. "In vivo Perturb-seq reveals neuronal and glial abnormalities associated with autism risk genes". In: Nature Neuroscience (2020).
|
|
125
|
+
|
|
126
|
+
[Replogle22] Joseph M. Replogle et al. "Mapping information-rich genotype-phenotype landscapes with genome-scale Perturb-seq". In: Cell (2022).
|
|
@@ -9,6 +9,7 @@ from causarray.utils import _filter_params
|
|
|
9
9
|
from joblib import Parallel, delayed
|
|
10
10
|
from tqdm import tqdm
|
|
11
11
|
import pprint
|
|
12
|
+
import warnings
|
|
12
13
|
|
|
13
14
|
from sklearn.model_selection import KFold, ShuffleSplit
|
|
14
15
|
|
|
@@ -18,13 +19,18 @@ def _get_func_ps(ps_model, **kwargs):
|
|
|
18
19
|
func_ps = lambda X, Y, X_test:fit_rf_ind_ps(X, Y[:,None], X_test=X_test, **params_ps)[:,0]
|
|
19
20
|
elif ps_model=='logistic':
|
|
20
21
|
clf_ps = LogisticRegression
|
|
21
|
-
kwargs = {**{'fit_intercept':False, 'C':1e0, 'class_weight':
|
|
22
|
+
kwargs = {**{'fit_intercept':False, 'C':1e0, 'class_weight':None, 'random_state':0}, **kwargs}
|
|
22
23
|
params_ps = _filter_params(clf_ps().get_params(), kwargs)
|
|
23
24
|
func_ps = lambda X, Y, X_test: clf_ps(**params_ps).fit(X, Y).predict_proba(X_test)[:,1]
|
|
24
25
|
elif ps_model=='ensemble':
|
|
26
|
+
kwargs = dict(kwargs)
|
|
27
|
+
logistic_class_weight = kwargs.pop('class_weight', None)
|
|
25
28
|
params_ps_rf = _filter_params(fit_rf, kwargs)
|
|
26
29
|
clf_ps = LogisticRegression
|
|
27
|
-
kwargs = {**{
|
|
30
|
+
kwargs = {**{
|
|
31
|
+
'fit_intercept': False, 'C': 1e0,
|
|
32
|
+
'class_weight': logistic_class_weight, 'random_state': 0,
|
|
33
|
+
}, **kwargs}
|
|
28
34
|
params_ps_lr = _filter_params(clf_ps().get_params(), kwargs)
|
|
29
35
|
params_ps = {'params_ps_rf':params_ps_rf, 'params_ps_lr':params_ps_lr}
|
|
30
36
|
func_ps = lambda X, Y, X_test:(fit_rf_ind(X, Y[:,None], X_test=X_test, **params_ps_rf)[:,0] + clf_ps(**params_ps_lr).fit(X, Y).predict_proba(X_test)[:,1])/2
|
|
@@ -34,10 +40,149 @@ def _get_func_ps(ps_model, **kwargs):
|
|
|
34
40
|
return func_ps, params_ps
|
|
35
41
|
|
|
36
42
|
|
|
43
|
+
def estimate_propensity_scores(
|
|
44
|
+
A, X_A, K=1, ps_model='logistic', mask=None, clip=None,
|
|
45
|
+
random_state=0, verbose=False, class_weight='balanced', **kwargs,
|
|
46
|
+
):
|
|
47
|
+
"""Estimate per-treatment propensity scores.
|
|
48
|
+
|
|
49
|
+
Each treatment is compared with the shared all-zero control group. With
|
|
50
|
+
``K > 1``, every returned score is predicted by a model that did not train
|
|
51
|
+
on that cell. Logistic models use ``class_weight='balanced'`` by default,
|
|
52
|
+
matching :func:`LFC` and historical causarray fits. Pass
|
|
53
|
+
``class_weight=None`` for calibrated treatment probabilities.
|
|
54
|
+
|
|
55
|
+
Parameters
|
|
56
|
+
----------
|
|
57
|
+
A : array-like, shape (n,) or (n, a)
|
|
58
|
+
Binary treatment indicators. Rows containing only zeros are controls.
|
|
59
|
+
X_A : array-like, shape (n, d_A)
|
|
60
|
+
Covariates used by the propensity model, including an intercept column
|
|
61
|
+
when ``fit_intercept=False``.
|
|
62
|
+
K : int, optional
|
|
63
|
+
Number of folds. ``1`` fits and predicts on all eligible cells;
|
|
64
|
+
values greater than one produce out-of-fold predictions.
|
|
65
|
+
ps_model : {'logistic', 'random_forest_cv', 'ensemble'}, optional
|
|
66
|
+
Propensity model.
|
|
67
|
+
mask : array-like or None, shape (n,) or (n, a)
|
|
68
|
+
Optional per-treatment eligibility mask for model fitting.
|
|
69
|
+
clip : tuple(float, float) or None, optional
|
|
70
|
+
Bounds applied after prediction. ``None`` returns raw probabilities.
|
|
71
|
+
random_state : int, optional
|
|
72
|
+
Random seed used for fold construction and supported estimators.
|
|
73
|
+
class_weight : str, dict or None, optional
|
|
74
|
+
Class weighting for logistic propensity estimation. The default
|
|
75
|
+
``'balanced'`` matches :func:`LFC`; pass ``None`` for calibrated
|
|
76
|
+
probabilities.
|
|
77
|
+
|
|
78
|
+
Returns
|
|
79
|
+
-------
|
|
80
|
+
pi_hat : ndarray, shape (n, a)
|
|
81
|
+
Estimated probabilities ``P(A_j=1 | X_A)``.
|
|
82
|
+
"""
|
|
83
|
+
A = np.asarray(A)
|
|
84
|
+
if A.ndim == 1:
|
|
85
|
+
A = A[:, None]
|
|
86
|
+
X_A = np.asarray(X_A)
|
|
87
|
+
if A.ndim != 2 or X_A.ndim != 2 or A.shape[0] != X_A.shape[0]:
|
|
88
|
+
raise ValueError('A and X_A must be two-dimensional with matching rows')
|
|
89
|
+
if not np.all(np.isin(A, (0, 1))):
|
|
90
|
+
raise ValueError('A must contain only binary treatment indicators')
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
K_int = int(K)
|
|
94
|
+
except (TypeError, ValueError) as exc:
|
|
95
|
+
raise ValueError('K must be a positive integer') from exc
|
|
96
|
+
if K_int != K:
|
|
97
|
+
raise ValueError('K must be a positive integer')
|
|
98
|
+
K = K_int
|
|
99
|
+
if K < 1:
|
|
100
|
+
raise ValueError('K must be a positive integer')
|
|
101
|
+
if K > A.shape[0]:
|
|
102
|
+
raise ValueError('K cannot exceed the number of samples')
|
|
103
|
+
|
|
104
|
+
if mask is not None:
|
|
105
|
+
mask = np.asarray(mask, dtype=bool)
|
|
106
|
+
if mask.ndim == 1:
|
|
107
|
+
mask = mask[:, None]
|
|
108
|
+
if mask.shape != A.shape:
|
|
109
|
+
raise ValueError('Mask must have the same shape as the treatment matrix')
|
|
110
|
+
|
|
111
|
+
func_ps, params_ps = _get_func_ps(
|
|
112
|
+
ps_model, verbose=False, random_state=random_state,
|
|
113
|
+
class_weight=class_weight, **kwargs)
|
|
114
|
+
if verbose:
|
|
115
|
+
pprint.pprint(params_ps)
|
|
116
|
+
|
|
117
|
+
if ps_model == 'random_forest_cv':
|
|
118
|
+
info_ecv = run_ecv(X_A, A, **params_ps)
|
|
119
|
+
func_ps, params_ps = _get_func_ps(
|
|
120
|
+
ps_model, verbose=False, ecv=False,
|
|
121
|
+
kwargs_ensemble=info_ecv['best_params_ensemble'],
|
|
122
|
+
kwargs_regr=info_ecv['best_params_regr'],
|
|
123
|
+
)
|
|
124
|
+
if verbose:
|
|
125
|
+
pprint.pprint('Best parameters for the regression model:')
|
|
126
|
+
pprint.pprint(info_ecv['best_params_regr'])
|
|
127
|
+
pprint.pprint('Best parameters for the ensemble model:')
|
|
128
|
+
pprint.pprint(info_ecv['best_params_ensemble'])
|
|
129
|
+
|
|
130
|
+
n = A.shape[0]
|
|
131
|
+
if K == 1:
|
|
132
|
+
folds = [(np.arange(n), np.arange(n))]
|
|
133
|
+
elif K == n:
|
|
134
|
+
folds = [
|
|
135
|
+
(np.delete(np.arange(n), j), np.array([j])) for j in range(n)
|
|
136
|
+
]
|
|
137
|
+
else:
|
|
138
|
+
folds = KFold(
|
|
139
|
+
n_splits=K, random_state=random_state, shuffle=True,
|
|
140
|
+
).split(X_A)
|
|
141
|
+
|
|
142
|
+
pi_hat = np.zeros(A.shape, dtype=float)
|
|
143
|
+
for train_index, test_index in folds:
|
|
144
|
+
A_train = A[train_index]
|
|
145
|
+
XA_train, XA_test = X_A[train_index], X_A[test_index]
|
|
146
|
+
i_ctrl = np.sum(A_train, axis=1) == 0
|
|
147
|
+
|
|
148
|
+
for j in range(A.shape[1]):
|
|
149
|
+
i_case = A_train[:, j] == 1
|
|
150
|
+
eligible = mask[train_index, j] if mask is not None else (i_ctrl | i_case)
|
|
151
|
+
y_train = A_train[eligible, j]
|
|
152
|
+
if y_train.size == 0 or np.unique(y_train).size != 2:
|
|
153
|
+
raise ValueError(
|
|
154
|
+
f'Treatment {j} needs at least one eligible control and case '
|
|
155
|
+
'in every training fold'
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
x_train = XA_train[eligible]
|
|
159
|
+
constant_design = np.all(np.ptp(x_train, axis=0) == 0)
|
|
160
|
+
if ps_model == 'logistic' and constant_design:
|
|
161
|
+
if class_weight == 'balanced':
|
|
162
|
+
pi_hat[test_index, j] = 0.5
|
|
163
|
+
elif isinstance(class_weight, dict):
|
|
164
|
+
sample_weight = np.asarray([
|
|
165
|
+
class_weight.get(int(value), 1.0) for value in y_train
|
|
166
|
+
])
|
|
167
|
+
pi_hat[test_index, j] = np.average(
|
|
168
|
+
y_train, weights=sample_weight)
|
|
169
|
+
else:
|
|
170
|
+
pi_hat[test_index, j] = np.mean(y_train)
|
|
171
|
+
else:
|
|
172
|
+
pi_hat[test_index, j] = func_ps(x_train, y_train, XA_test)
|
|
173
|
+
|
|
174
|
+
if clip is not None:
|
|
175
|
+
if len(clip) != 2 or not 0 <= clip[0] < clip[1] <= 1:
|
|
176
|
+
raise ValueError('clip must be None or a pair 0 <= lower < upper <= 1')
|
|
177
|
+
pi_hat = np.clip(pi_hat, clip[0], clip[1])
|
|
178
|
+
return pi_hat
|
|
179
|
+
|
|
180
|
+
|
|
37
181
|
def cross_fitting(
|
|
38
182
|
Y, A, X, X_A, family='poisson', K=1, glm_alpha=1e-4,
|
|
39
|
-
ps_model='logistic',
|
|
40
|
-
Y_hat=None, pi_hat=None, mask=None,
|
|
183
|
+
ps_model='logistic', ps_class_weight='balanced',
|
|
184
|
+
Y_hat=None, pi_hat=None, mask=None, ps_clip=(0.01, 0.99),
|
|
185
|
+
return_raw_pi=False, verbose=False, **kwargs):
|
|
41
186
|
'''
|
|
42
187
|
Cross-fitting for causal estimands.
|
|
43
188
|
|
|
@@ -59,6 +204,10 @@ def cross_fitting(
|
|
|
59
204
|
The regularization parameter for the generalized linear model. The default is 1e-4.
|
|
60
205
|
ps_model : str, optional
|
|
61
206
|
The propensity score model. The default is 'logistic'.
|
|
207
|
+
ps_class_weight : str, dict or None, optional
|
|
208
|
+
Class weighting used by the propensity model. ``'balanced'`` preserves
|
|
209
|
+
the established ``LFC`` nuisance fit; pass ``None`` for calibrated
|
|
210
|
+
treatment probabilities.
|
|
62
211
|
|
|
63
212
|
Y_hat : array, optional
|
|
64
213
|
Estimated potential outcome of shape (n, p, a, 2). The default is None.
|
|
@@ -66,8 +215,11 @@ def cross_fitting(
|
|
|
66
215
|
Propensity score of shape (n, a). The default is None.
|
|
67
216
|
mask : array, optional
|
|
68
217
|
Boolean mask of shape (n, a) for the treatment, indicating which samples are used for
|
|
69
|
-
|
|
70
|
-
|
|
218
|
+
propensity-model fitting and the downstream estimand.
|
|
219
|
+
ps_clip : tuple(float, float) or None, optional
|
|
220
|
+
Bounds applied to scores used by AIPW. ``None`` disables clipping.
|
|
221
|
+
return_raw_pi : bool, optional
|
|
222
|
+
Return raw scores as a third result when true.
|
|
71
223
|
|
|
72
224
|
**kwargs : dict
|
|
73
225
|
Additional arguments to pass to the model.
|
|
@@ -78,12 +230,22 @@ def cross_fitting(
|
|
|
78
230
|
Estimated potential outcome under control.
|
|
79
231
|
pi_hat : array
|
|
80
232
|
Estimated propensity score.
|
|
233
|
+
pi_hat_raw : array
|
|
234
|
+
Unclipped propensity score, returned only when ``return_raw_pi=True``.
|
|
81
235
|
'''
|
|
82
|
-
|
|
236
|
+
kwargs = dict(kwargs)
|
|
237
|
+
if 'class_weight' in kwargs:
|
|
238
|
+
legacy_class_weight = kwargs.pop('class_weight')
|
|
239
|
+
warnings.warn(
|
|
240
|
+
'Passing class_weight through LFC/cross_fitting is deprecated; '
|
|
241
|
+
'use ps_class_weight instead.',
|
|
242
|
+
FutureWarning, stacklevel=2,
|
|
243
|
+
)
|
|
244
|
+
ps_class_weight = legacy_class_weight
|
|
245
|
+
|
|
83
246
|
params_glm = _filter_params(fit_glm, {**kwargs, 'verbose': verbose})
|
|
84
247
|
|
|
85
248
|
if verbose:
|
|
86
|
-
pprint.pprint(params_ps)
|
|
87
249
|
pprint.pprint(params_glm)
|
|
88
250
|
|
|
89
251
|
if K > 1:
|
|
@@ -99,14 +261,31 @@ def cross_fitting(
|
|
|
99
261
|
folds = [(np.arange(X.shape[0]), np.arange(X.shape[0]))]
|
|
100
262
|
|
|
101
263
|
# Initialize lists to store results
|
|
102
|
-
|
|
103
|
-
|
|
264
|
+
if pi_hat is None:
|
|
265
|
+
if verbose:
|
|
266
|
+
pprint.pprint('Fit propensity score models...')
|
|
267
|
+
ps_kwargs = {k: v for k, v in kwargs.items() if k != 'random_state'}
|
|
268
|
+
if ps_model in ('logistic', 'ensemble'):
|
|
269
|
+
ps_kwargs['class_weight'] = ps_class_weight
|
|
270
|
+
pi_hat_raw = estimate_propensity_scores(
|
|
271
|
+
A, X_A, K=K, ps_model=ps_model, mask=mask,
|
|
272
|
+
random_state=kwargs.get('random_state', 0), verbose=verbose,
|
|
273
|
+
**ps_kwargs,
|
|
274
|
+
)
|
|
275
|
+
else:
|
|
276
|
+
pi_hat_raw = np.asarray(pi_hat, dtype=float).reshape(A.shape)
|
|
277
|
+
if ps_clip is None:
|
|
278
|
+
pi_hat = pi_hat_raw.copy()
|
|
279
|
+
else:
|
|
280
|
+
if len(ps_clip) != 2 or not 0 <= ps_clip[0] < ps_clip[1] <= 1:
|
|
281
|
+
raise ValueError(
|
|
282
|
+
'ps_clip must be None or a pair 0 <= lower < upper <= 1')
|
|
283
|
+
pi_hat = np.clip(pi_hat_raw, ps_clip[0], ps_clip[1])
|
|
104
284
|
fit_Y = True if Y_hat is None else False
|
|
105
285
|
if fit_Y:
|
|
106
286
|
_yhat_gb = Y.shape[0] * Y.shape[1] * A.shape[1] * 2 * 8 / 1e9
|
|
107
287
|
_mem_limit_gb = kwargs.get('mem_limit_gb', None)
|
|
108
288
|
if _mem_limit_gb is not None and _yhat_gb > _mem_limit_gb:
|
|
109
|
-
import warnings
|
|
110
289
|
warnings.warn(
|
|
111
290
|
f"Y_hat allocation ({_yhat_gb:.1f} GB as float64) exceeds "
|
|
112
291
|
f"mem_limit_gb={_mem_limit_gb} GB; using float32 to halve peak memory.",
|
|
@@ -116,16 +295,6 @@ def cross_fitting(
|
|
|
116
295
|
else:
|
|
117
296
|
Y_hat = np.zeros((Y.shape[0], Y.shape[1], A.shape[1], 2), dtype=float)
|
|
118
297
|
|
|
119
|
-
# perform ECV at once
|
|
120
|
-
if fit_pi and ps_model == 'random_forest_cv':
|
|
121
|
-
info_ecv = run_ecv(X_A, A, **params_ps)
|
|
122
|
-
func_ps, params_ps = _get_func_ps(ps_model, verbose=False, ecv=False,
|
|
123
|
-
kwargs_ensemble=info_ecv['best_params_ensemble'], kwargs_regr=info_ecv['best_params_regr'])
|
|
124
|
-
pprint.pprint('Best parameters for the regression model:')
|
|
125
|
-
pprint.pprint(info_ecv['best_params_regr'])
|
|
126
|
-
pprint.pprint('Best parameters for the ensemble model:')
|
|
127
|
-
pprint.pprint(info_ecv['best_params_ensemble'])
|
|
128
|
-
|
|
129
298
|
# Perform cross-fitting
|
|
130
299
|
for train_index, test_index in folds:
|
|
131
300
|
# Split data
|
|
@@ -134,28 +303,6 @@ def cross_fitting(
|
|
|
134
303
|
A_train, A_test = A[train_index], A[test_index]
|
|
135
304
|
Y_train, Y_test = Y[train_index], Y[test_index]
|
|
136
305
|
|
|
137
|
-
if fit_pi:
|
|
138
|
-
if verbose: pprint.pprint('Fit propensity score models...')
|
|
139
|
-
i_ctrl = (np.sum(A_train, axis=1) == 0.)
|
|
140
|
-
|
|
141
|
-
pi = np.zeros_like(A_test, dtype=float)
|
|
142
|
-
for j in range(A.shape[1]):
|
|
143
|
-
i_case = (A_train[:,j] == 1.)
|
|
144
|
-
|
|
145
|
-
if mask is not None:
|
|
146
|
-
i_cells = mask[:, j]
|
|
147
|
-
else:
|
|
148
|
-
i_ctrl = (np.sum(A_train, axis=1) == 0.)
|
|
149
|
-
i_cells = i_ctrl | i_case
|
|
150
|
-
|
|
151
|
-
if ps_model=='logistic' and XA_train.shape[1]==1 and np.all(XA_train==1):
|
|
152
|
-
prob = np.sum(i_case)/np.sum(i_cells)
|
|
153
|
-
pi[A_train[:,j] == 1., j] = prob
|
|
154
|
-
pi[A_train[:,j] == 0., j] = 1 - prob
|
|
155
|
-
else:
|
|
156
|
-
pi[:,j] = func_ps(XA_train[i_cells], A_train[i_cells][:,j], XA_test)
|
|
157
|
-
pi_hat[test_index] = pi
|
|
158
|
-
|
|
159
306
|
if fit_Y:
|
|
160
307
|
if verbose: pprint.pprint('Fit outcome models...')
|
|
161
308
|
# Subset offset to training fold (for fitting) and test fold (for
|
|
@@ -174,15 +321,16 @@ def cross_fitting(
|
|
|
174
321
|
Y_hat[test_index,:,:,0] = res[1][0]
|
|
175
322
|
Y_hat[test_index,:,:,1] = res[1][1]
|
|
176
323
|
|
|
177
|
-
pi_hat = np.clip(pi_hat, 0.01, 0.99)
|
|
178
324
|
Y_hat = np.clip(Y_hat, None, 1e5)
|
|
325
|
+
if return_raw_pi:
|
|
326
|
+
return Y_hat, pi_hat, pi_hat_raw
|
|
179
327
|
return Y_hat, pi_hat
|
|
180
328
|
|
|
181
329
|
|
|
182
330
|
|
|
183
331
|
|
|
184
332
|
|
|
185
|
-
def AIPW_mean(Y, A, mu, pi
|
|
333
|
+
def AIPW_mean(Y, A, mu, pi):
|
|
186
334
|
'''
|
|
187
335
|
Augmented inverse probability weighted estimator (AIPW)
|
|
188
336
|
|
|
@@ -196,9 +344,6 @@ def AIPW_mean(Y, A, mu, pi, positive=False):
|
|
|
196
344
|
Conditional outcome distribution estimate of shape (n, p, a, 2).
|
|
197
345
|
pi : array
|
|
198
346
|
Propensity score of shape (n, a, 2).
|
|
199
|
-
positive : bool, optional
|
|
200
|
-
Whether to restrict the pseudo-outcome to be positive.
|
|
201
|
-
|
|
202
347
|
Returns
|
|
203
348
|
-------
|
|
204
349
|
tau : array
|
|
@@ -207,16 +352,18 @@ def AIPW_mean(Y, A, mu, pi, positive=False):
|
|
|
207
352
|
Pseudo-outcome of shape (n, p, a, 2).
|
|
208
353
|
'''
|
|
209
354
|
|
|
210
|
-
|
|
355
|
+
with np.errstate(divide='ignore', invalid='ignore', over='ignore'):
|
|
356
|
+
weight = A / pi
|
|
211
357
|
weight = weight[:, None, ...]
|
|
212
358
|
Y = Y[:, :, None, None]
|
|
213
359
|
|
|
360
|
+
# Influence-function values are intentionally left unconstrained. Even
|
|
361
|
+
# for a nonnegative outcome, individual AIPW pseudo-outcomes may be
|
|
362
|
+
# negative; projecting them cell by cell changes their mean and biases the
|
|
363
|
+
# estimator. Parameter-space constraints belong after aggregation.
|
|
214
364
|
pseudo_y = weight * (Y - mu) + mu
|
|
215
|
-
|
|
216
|
-
if positive:
|
|
217
|
-
pseudo_y = np.clip(pseudo_y, 0, None)
|
|
218
365
|
|
|
219
|
-
tau = np.mean(pseudo_y, axis=0)
|
|
366
|
+
tau = np.mean(pseudo_y, axis=0, dtype=np.float64)
|
|
220
367
|
|
|
221
368
|
return tau, pseudo_y
|
|
222
369
|
|
|
@@ -350,4 +497,4 @@ def fit_rf_ind_outcome(W, Y, A, *args, **kwargs):
|
|
|
350
497
|
Y_pred = Y_pred.reshape(X.shape[0],1+a,Y.shape[1])
|
|
351
498
|
Yhat_1 = Y_pred[:,1:,:].transpose(0,2,1)
|
|
352
499
|
Yhat_0 = np.tile(Y_pred[:,0,:][:,:,None], (1,1,a))
|
|
353
|
-
return Yhat_0, Yhat_1
|
|
500
|
+
return Yhat_0, Yhat_1
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import numpy as np
|
|
2
2
|
import contextlib
|
|
3
3
|
import pandas as pd
|
|
4
|
+
import warnings
|
|
5
|
+
from typing import Literal
|
|
4
6
|
from causarray.DR_estimation import AIPW_mean, cross_fitting
|
|
5
7
|
from causarray.gcate_glm import loess_fit, ls_fit
|
|
6
8
|
import causarray.gcate_glm as _gcate_glm # for _backend_override
|
|
@@ -8,13 +10,33 @@ from causarray.DR_inference import fdx_control, bh_correction
|
|
|
8
10
|
from causarray.utils import reset_random_seeds, pprint, tqdm, comp_size_factor, _filter_params
|
|
9
11
|
|
|
10
12
|
|
|
13
|
+
def _add_log2fc_columns(df_res):
|
|
14
|
+
"""Add base-2 LFC aliases while retaining natural-log result columns."""
|
|
15
|
+
log2 = np.log(2.0)
|
|
16
|
+
log2fc = df_res['tau'].to_numpy() / log2
|
|
17
|
+
log2fc_se = df_res['std'].to_numpy() / log2
|
|
18
|
+
|
|
19
|
+
# Recompute existing aliases as well as missing ones. This normalizes
|
|
20
|
+
# mixed old/new frames loaded from resumable batch caches and guarantees
|
|
21
|
+
# that the aliases remain exact transformations of tau and std.
|
|
22
|
+
for name in ('log2fc', 'log2fc_se'):
|
|
23
|
+
if name in df_res.columns:
|
|
24
|
+
df_res.pop(name)
|
|
25
|
+
insert_at = df_res.columns.get_loc('std') + 1
|
|
26
|
+
df_res.insert(insert_at, 'log2fc', log2fc)
|
|
27
|
+
df_res.insert(insert_at + 1, 'log2fc_se', log2fc_se)
|
|
28
|
+
return df_res
|
|
29
|
+
|
|
30
|
+
|
|
11
31
|
|
|
12
32
|
def compute_causal_estimand(
|
|
13
33
|
estimand,
|
|
14
34
|
Y, W, A, W_A=None, family='nb', offset=False,
|
|
15
35
|
Y_hat=None, pi_hat=None, mask=None,
|
|
16
36
|
fdx=False, fdx_B=1000, fdx_alpha=0.05, fdx_c=0.1,
|
|
17
|
-
verbose=False, random_state=0, backend: str = "auto",
|
|
37
|
+
verbose=False, random_state=0, backend: str = "auto", K=1,
|
|
38
|
+
ps_clip=(0.01, 0.99), ps_class_weight='balanced',
|
|
39
|
+
**kwargs):
|
|
18
40
|
"""Estimate causal treatment effects using AIPW with a user-supplied estimand.
|
|
19
41
|
|
|
20
42
|
Parameters
|
|
@@ -42,9 +64,17 @@ def compute_causal_estimand(
|
|
|
42
64
|
pi_hat : array or None, shape (n, a)
|
|
43
65
|
Pre-computed propensity scores. When provided, propensity fitting is
|
|
44
66
|
skipped.
|
|
67
|
+
K : int
|
|
68
|
+
Number of folds used for nuisance estimation. The default ``1``
|
|
69
|
+
preserves in-sample fitting.
|
|
70
|
+
ps_clip : tuple(float, float)
|
|
71
|
+
Bounds applied to propensity scores used by AIPW.
|
|
72
|
+
ps_class_weight : str, dict or None
|
|
73
|
+
Class weighting for the propensity model. ``'balanced'`` preserves the
|
|
74
|
+
established nuisance fit; pass ``None`` for calibrated probabilities.
|
|
45
75
|
mask : array or None, shape (n, a)
|
|
46
|
-
Boolean mask indicating
|
|
47
|
-
|
|
76
|
+
Boolean mask indicating eligible cells for each treatment. It limits
|
|
77
|
+
propensity-model fitting and final estimand computation.
|
|
48
78
|
fdx : bool
|
|
49
79
|
Whether to apply FDX control (``P(FDP > fdx_c) < fdx_alpha``).
|
|
50
80
|
fdx_B : int
|
|
@@ -64,12 +94,17 @@ def compute_causal_estimand(
|
|
|
64
94
|
Returns
|
|
65
95
|
-------
|
|
66
96
|
df_res : DataFrame
|
|
67
|
-
Test results
|
|
68
|
-
|
|
69
|
-
``padj_emp_null_adj`` (and ``trt`` when ``A`` has multiple columns).
|
|
97
|
+
Test results produced by ``estimand``. An estimand may optionally return
|
|
98
|
+
a fifth dictionary whose arrays are added as diagnostic columns.
|
|
70
99
|
"""
|
|
71
100
|
reset_random_seeds(random_state)
|
|
72
101
|
|
|
102
|
+
if 'clip_pseudo_outcomes' in kwargs:
|
|
103
|
+
raise TypeError(
|
|
104
|
+
'clip_pseudo_outcomes has been removed; AIPW pseudo-outcomes are '
|
|
105
|
+
'always left unclipped'
|
|
106
|
+
)
|
|
107
|
+
|
|
73
108
|
ctx = _gcate_glm._backend_override(backend) if backend != "auto" else contextlib.nullcontext()
|
|
74
109
|
|
|
75
110
|
# check the input data
|
|
@@ -85,7 +120,6 @@ def compute_causal_estimand(
|
|
|
85
120
|
_mem_limit = kwargs.get('mem_limit_gb', None)
|
|
86
121
|
_use_f32 = _mem_limit is not None and _yhat_gb > _mem_limit
|
|
87
122
|
if _use_f32:
|
|
88
|
-
import warnings
|
|
89
123
|
warnings.warn(
|
|
90
124
|
f"AIPW intermediate Y ({_yhat_gb:.1f} GB as float64) exceeds "
|
|
91
125
|
f"mem_limit_gb={_mem_limit} GB; downcasting Y, A, and pi_hat to "
|
|
@@ -94,6 +128,8 @@ def compute_causal_estimand(
|
|
|
94
128
|
)
|
|
95
129
|
Y = Y.astype(np.float32 if _use_f32 else float)
|
|
96
130
|
n, p = Y.shape
|
|
131
|
+
if not np.all(np.isfinite(Y)):
|
|
132
|
+
raise ValueError('Y must contain only finite values')
|
|
97
133
|
|
|
98
134
|
if len(A.shape) == 1:
|
|
99
135
|
A = A.reshape(-1,1)
|
|
@@ -135,11 +171,25 @@ def compute_causal_estimand(
|
|
|
135
171
|
else:
|
|
136
172
|
offset = None
|
|
137
173
|
size_factors = np.ones(n)
|
|
174
|
+
size_factors = np.asarray(size_factors, dtype=float)
|
|
175
|
+
if size_factors.shape != (n,) or not np.all(np.isfinite(size_factors)) or np.any(size_factors <= 0):
|
|
176
|
+
raise ValueError('Size factors must be finite, positive, and have length n')
|
|
138
177
|
|
|
139
178
|
with ctx:
|
|
140
|
-
Y_hat, pi_hat = cross_fitting(
|
|
141
|
-
|
|
179
|
+
Y_hat, pi_hat, pi_hat_raw = cross_fitting(
|
|
180
|
+
Y, A, W, W_A, family=family, K=K, offset=offset,
|
|
181
|
+
Y_hat=Y_hat, pi_hat=pi_hat, mask=mask, ps_clip=ps_clip,
|
|
182
|
+
ps_class_weight=ps_class_weight,
|
|
183
|
+
return_raw_pi=True, random_state=random_state, verbose=verbose,
|
|
184
|
+
**kwargs,
|
|
185
|
+
)
|
|
142
186
|
pi_hat = pi_hat.reshape(*A.shape)
|
|
187
|
+
if not np.all(np.isfinite(Y_hat)):
|
|
188
|
+
raise ValueError('Outcome-model predictions must contain only finite values')
|
|
189
|
+
if not np.all(np.isfinite(pi_hat)) or np.any((pi_hat <= 0) | (pi_hat >= 1)):
|
|
190
|
+
raise ValueError(
|
|
191
|
+
'Propensity scores used by AIPW must be finite and strictly between 0 and 1; '
|
|
192
|
+
'pass ps_clip bounds or provide valid pi_hat values')
|
|
143
193
|
|
|
144
194
|
if verbose: pprint.pprint('Estimating AIPW mean...')
|
|
145
195
|
_aipw_dtype = Y_hat.dtype if _use_f32 else None
|
|
@@ -147,8 +197,12 @@ def compute_causal_estimand(
|
|
|
147
197
|
pi_hat_aipw = pi_hat.astype(_aipw_dtype) if _aipw_dtype is not None else pi_hat
|
|
148
198
|
|
|
149
199
|
# point estimation of the treatment effect
|
|
150
|
-
_, etas = AIPW_mean(
|
|
151
|
-
|
|
200
|
+
_, etas = AIPW_mean(
|
|
201
|
+
Y,
|
|
202
|
+
np.stack([1-A_aipw, A_aipw], axis=-1),
|
|
203
|
+
Y_hat,
|
|
204
|
+
np.stack([1-pi_hat_aipw, pi_hat_aipw], axis=-1),
|
|
205
|
+
)
|
|
152
206
|
|
|
153
207
|
# normalize the influence function values
|
|
154
208
|
etas /= size_factors[:,None,None,None]
|
|
@@ -165,6 +219,7 @@ def compute_causal_estimand(
|
|
|
165
219
|
_ret = estimand(etas[i_cells,:,j], A[i_cells,j], **kwargs)
|
|
166
220
|
eta_est, tau_est, var_est = _ret[:3]
|
|
167
221
|
df_est = _ret[3] if len(_ret) > 3 else None
|
|
222
|
+
estimand_info = _ret[4] if len(_ret) > 4 else None
|
|
168
223
|
|
|
169
224
|
std_est = np.sqrt(var_est)
|
|
170
225
|
tvalues_init = tau_est / std_est
|
|
@@ -187,20 +242,41 @@ def compute_causal_estimand(
|
|
|
187
242
|
'pvalue_emp_null_adj': pvals_adj,
|
|
188
243
|
'padj_emp_null_adj': qvals_adj,
|
|
189
244
|
})
|
|
245
|
+
if estimand_info is not None:
|
|
246
|
+
for name, values in estimand_info.items():
|
|
247
|
+
df_res[name] = values
|
|
248
|
+
n_nonestimable = int(np.sum(~np.asarray(estimand_info['estimable'], dtype=bool)))
|
|
249
|
+
if n_nonestimable:
|
|
250
|
+
treatment = trt_names[j] if A.shape[1] > 1 else 0
|
|
251
|
+
warnings.warn(
|
|
252
|
+
f'{n_nonestimable} gene(s) for treatment {treatment!r} have '
|
|
253
|
+
'nonfinite or nonpositive AIPW counterfactual means and are '
|
|
254
|
+
'reported as non-estimable.',
|
|
255
|
+
RuntimeWarning, stacklevel=2,
|
|
256
|
+
)
|
|
190
257
|
if A.shape[1]>1:
|
|
191
258
|
df_res['trt'] = trt_names[j]
|
|
192
259
|
res.append(df_res)
|
|
193
260
|
df_res = pd.concat(res, axis=0).reset_index(drop=True)
|
|
194
|
-
estimation = {**{
|
|
261
|
+
estimation = {**{
|
|
262
|
+
'pi_hat': pi_hat,
|
|
263
|
+
'pi_hat_raw': pi_hat_raw,
|
|
264
|
+
'Y_hat': Y_hat,
|
|
265
|
+
'offset': offset,
|
|
266
|
+
'size_factors': size_factors,
|
|
267
|
+
'ps_class_weight': ps_class_weight,
|
|
268
|
+
}, **kwargs}
|
|
195
269
|
return df_res, estimation
|
|
196
270
|
|
|
197
271
|
|
|
198
272
|
def LFC(
|
|
199
273
|
Y, W, A, W_A=None, family='nb', offset=False,
|
|
200
|
-
Y_hat=None, pi_hat=None, cross_est=False,
|
|
274
|
+
Y_hat=None, pi_hat=None, cross_est=False, K=None, mask=None,
|
|
275
|
+
usevar: Literal['unequal', 'pooled'] = 'unequal',
|
|
201
276
|
thres_min=1e-2, thres_diff=1e-2, eps_var=1e-4,
|
|
202
277
|
fdx=False, fdx_alpha=0.05, fdx_c=0.1,
|
|
203
|
-
verbose=False, backend: str = "auto",
|
|
278
|
+
verbose=False, backend: str = "auto", ps_clip=(0.01, 0.99),
|
|
279
|
+
ps_class_weight='balanced', **kwargs):
|
|
204
280
|
"""Estimate log-fold changes of treatment effects (LFCs) using AIPW.
|
|
205
281
|
|
|
206
282
|
Fits a doubly-robust AIPW estimator for the log-ratio of counterfactual
|
|
@@ -230,17 +306,48 @@ def LFC(
|
|
|
230
306
|
Pre-computed propensity scores. When provided, propensity fitting is
|
|
231
307
|
skipped.
|
|
232
308
|
cross_est : bool
|
|
233
|
-
Whether to use cross-estimation for nuisance parameters.
|
|
309
|
+
Whether to use two-fold cross-estimation for nuisance parameters.
|
|
310
|
+
An explicit ``K`` takes precedence.
|
|
311
|
+
K : int or None
|
|
312
|
+
Number of nuisance-estimation folds. ``None`` uses 2 when
|
|
313
|
+
``cross_est=True`` and 1 otherwise.
|
|
234
314
|
mask : array or None, shape (n, a)
|
|
235
|
-
Boolean mask indicating
|
|
236
|
-
|
|
315
|
+
Boolean mask indicating eligible cells for each treatment. It limits
|
|
316
|
+
propensity-model fitting and final estimand computation.
|
|
237
317
|
usevar : str
|
|
238
318
|
Variance estimator for the AIPW pseudo-outcomes:
|
|
239
319
|
|
|
240
320
|
* ``'unequal'`` (default, v0.0.6+): Welch variance
|
|
241
321
|
``s₀²/n₀ + s₁²/n₁`` with Welch-Satterthwaite degrees of
|
|
242
|
-
freedom; p-values use the t-distribution.
|
|
322
|
+
freedom; p-values use the t-distribution. Prefer this estimator when
|
|
323
|
+
treatment and control sample sizes or effective sample sizes are
|
|
324
|
+
meaningfully unbalanced, when arm-specific pseudo-outcome variances
|
|
325
|
+
may differ, and for case-control, bulk, and donor-level pseudo-bulk
|
|
326
|
+
analyses. Independence of the rows does not imply equal
|
|
327
|
+
treatment-arm variances, and biological heterogeneity or imbalance
|
|
328
|
+
can make pooled inference anti-conservative.
|
|
243
329
|
* ``'pooled'``: pooled-variance estimator ``(s² + eps_var) / n``.
|
|
330
|
+
For a small, approximately balanced perturbation comparison, this
|
|
331
|
+
estimator may provide better power when the independent sampling
|
|
332
|
+
units and arm-specific pseudo-outcome variances are reasonably
|
|
333
|
+
comparable. Treat it as an opt-in, empirically justified analysis,
|
|
334
|
+
not as an automatic choice for every small study. Balanced sample
|
|
335
|
+
counts alone are insufficient for case-control data, where biological
|
|
336
|
+
heterogeneity commonly favors ``'unequal'``. Pooled inference can
|
|
337
|
+
produce substantially smaller standard errors and many more
|
|
338
|
+
discoveries.
|
|
339
|
+
|
|
340
|
+
There is no universal arm-size ratio at which the choice should switch.
|
|
341
|
+
Inspect nominal and propensity-weighted effective sample sizes,
|
|
342
|
+
arm-specific pseudo-outcome variability, and sensitivity of the
|
|
343
|
+
discoveries. When those diagnostics are uncertain, retain
|
|
344
|
+
``'unequal'``.
|
|
345
|
+
|
|
346
|
+
``'unequal'`` accommodates arm-specific variance but does not model
|
|
347
|
+
within-donor or within-subject correlation. Repeated cells from the
|
|
348
|
+
same biological unit should still be pseudo-bulked or analyzed with a
|
|
349
|
+
cluster-aware method; changing ``usevar`` alone does not remove
|
|
350
|
+
pseudoreplication.
|
|
244
351
|
|
|
245
352
|
.. versionchanged:: 0.0.6
|
|
246
353
|
Default changed from ``'pooled'`` to ``'unequal'``. The
|
|
@@ -267,25 +374,49 @@ def LFC(
|
|
|
267
374
|
backend : str
|
|
268
375
|
GLM backend: ``"auto"`` (default), ``"fast"`` (force crispyx),
|
|
269
376
|
or ``"original"`` (force statsmodels).
|
|
377
|
+
ps_clip : tuple(float, float)
|
|
378
|
+
Bounds applied to propensity scores used by AIPW. Raw, unclipped scores
|
|
379
|
+
remain available as ``estimation['pi_hat_raw']``.
|
|
380
|
+
ps_class_weight : str, dict or None
|
|
381
|
+
Class weighting for the propensity model. ``'balanced'`` remains the
|
|
382
|
+
default to limit nuisance-model drift; pass ``None`` for calibrated
|
|
383
|
+
probabilities.
|
|
270
384
|
**kwargs
|
|
271
385
|
Additional arguments forwarded to the GLM fitting functions.
|
|
272
386
|
|
|
273
387
|
Returns
|
|
274
388
|
-------
|
|
275
389
|
df_res : DataFrame
|
|
276
|
-
Test results with
|
|
277
|
-
|
|
278
|
-
``
|
|
390
|
+
Test results with natural-log effect estimate ``tau`` and standard
|
|
391
|
+
error ``std``, together with their base-2 equivalents ``log2fc`` and
|
|
392
|
+
``log2fc_se``. The fold change is treatment relative to control, and
|
|
393
|
+
``log2fc_se`` is a standard error (not a sample standard deviation).
|
|
394
|
+
The result also contains inference columns, raw ``mean_control`` and
|
|
395
|
+
``mean_treated`` counterfactual means, and an ``estimable`` flag (plus
|
|
396
|
+
``trt`` for multiple treatments).
|
|
397
|
+
|
|
398
|
+
.. versionadded:: 0.0.8
|
|
399
|
+
Added the ``log2fc`` and ``log2fc_se`` convenience columns. The
|
|
400
|
+
original natural-log ``tau`` and ``std`` columns remain unchanged.
|
|
279
401
|
"""
|
|
280
402
|
|
|
281
403
|
def estimand(etas, A, **kwargs):
|
|
282
404
|
eta_0, eta_1 = etas[..., 0], etas[..., 1]
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
405
|
+
mean_0 = np.mean(eta_0, axis=0, dtype=np.float64)
|
|
406
|
+
mean_1 = np.mean(eta_1, axis=0, dtype=np.float64)
|
|
407
|
+
finite_means = np.isfinite(mean_0) & np.isfinite(mean_1)
|
|
408
|
+
estimable = finite_means & (mean_0 > 0) & (mean_1 > 0)
|
|
409
|
+
|
|
410
|
+
# Apply the count-mean parameter-space constraint only after averaging
|
|
411
|
+
# the unmodified AIPW pseudo-outcomes. The floor makes the logarithm
|
|
412
|
+
# and its delta-method denominator numerically safe. Nonpositive raw
|
|
413
|
+
# means remain non-estimable and are filtered below, so this safeguard
|
|
414
|
+
# cannot turn an invalid aggregate into a discovery.
|
|
415
|
+
tau_0 = np.where(estimable, np.maximum(mean_0, thres_diff), 1.0)
|
|
416
|
+
tau_1 = np.where(estimable, np.maximum(mean_1, thres_diff), 1.0)
|
|
417
|
+
with np.errstate(invalid='ignore', divide='ignore', over='ignore'):
|
|
418
|
+
tau_est = np.log(tau_1/tau_0)
|
|
419
|
+
eta_est = eta_1 / tau_1[None,:] - eta_0 / tau_0[None,:]
|
|
289
420
|
|
|
290
421
|
df_eff = None
|
|
291
422
|
if usevar == 'pooled':
|
|
@@ -316,8 +447,13 @@ def LFC(
|
|
|
316
447
|
else:
|
|
317
448
|
raise ValueError('usevar must be either "pooled" or "unequal"')
|
|
318
449
|
|
|
319
|
-
#
|
|
320
|
-
|
|
450
|
+
# Filter on the raw aggregate estimates, not on cell-level projections
|
|
451
|
+
# or the numerical floor used for the log transform.
|
|
452
|
+
idx = (
|
|
453
|
+
~estimable |
|
|
454
|
+
(np.maximum(mean_0, mean_1) < thres_min) |
|
|
455
|
+
(np.abs(mean_1 - mean_0) < thres_diff)
|
|
456
|
+
)
|
|
321
457
|
tau_est[idx] = 0.; eta_est[:,idx] = 0.; var_est[idx] = np.inf
|
|
322
458
|
if df_eff is not None:
|
|
323
459
|
df_eff[idx] = np.nan
|
|
@@ -338,13 +474,23 @@ def LFC(
|
|
|
338
474
|
RuntimeWarning, stacklevel=3,
|
|
339
475
|
)
|
|
340
476
|
|
|
341
|
-
|
|
477
|
+
info = {
|
|
478
|
+
'mean_control': mean_0,
|
|
479
|
+
'mean_treated': mean_1,
|
|
480
|
+
'estimable': estimable,
|
|
481
|
+
}
|
|
482
|
+
return eta_est, tau_est, var_est, df_eff, info
|
|
483
|
+
|
|
484
|
+
if K is None:
|
|
485
|
+
K = 2 if cross_est else 1
|
|
342
486
|
|
|
343
|
-
|
|
487
|
+
df_res, estimation = compute_causal_estimand(
|
|
344
488
|
estimand, Y, W, A, W_A, family, offset,
|
|
345
489
|
Y_hat=Y_hat, pi_hat=pi_hat, mask=mask,
|
|
346
490
|
fdx=fdx, fdx_alpha=fdx_alpha, fdx_c=fdx_c,
|
|
347
|
-
verbose=verbose, backend=backend,
|
|
491
|
+
verbose=verbose, backend=backend, K=K, ps_clip=ps_clip,
|
|
492
|
+
ps_class_weight=ps_class_weight, **kwargs)
|
|
493
|
+
return _add_log2fc_columns(df_res), estimation
|
|
348
494
|
|
|
349
495
|
|
|
350
496
|
|
|
@@ -524,7 +670,14 @@ def gcate_lfc_batch(
|
|
|
524
670
|
|
|
525
671
|
lfc_kwargs : dict or None
|
|
526
672
|
Extra keyword arguments forwarded to :func:`LFC`
|
|
527
|
-
(e.g. ``usevar``, ``fdx``, ``thres_min``).
|
|
673
|
+
(e.g. ``usevar``, ``fdx``, ``thres_min``). Retain the default
|
|
674
|
+
``usevar='unequal'`` when arm sizes or effective sample sizes are
|
|
675
|
+
meaningfully unbalanced, when arm-specific variability may differ, and
|
|
676
|
+
for case-control, bulk, or pseudo-bulk analyses. For a small,
|
|
677
|
+
approximately balanced perturbation comparison with comparable
|
|
678
|
+
pseudo-outcome variability, ``lfc_kwargs=dict(usevar='pooled')`` may
|
|
679
|
+
improve power. There is no universal balance threshold; compare the
|
|
680
|
+
relevant diagnostics and retain ``'unequal'`` when uncertain.
|
|
528
681
|
**kwargs
|
|
529
682
|
Additional arguments forwarded to both :func:`fit_gcate_batch` and
|
|
530
683
|
:func:`LFC`. When a key collides with ``gcate_kwargs`` /
|
|
@@ -536,8 +689,10 @@ def gcate_lfc_batch(
|
|
|
536
689
|
Returns
|
|
537
690
|
-------
|
|
538
691
|
df_res : DataFrame
|
|
539
|
-
Concatenated result from all batches.
|
|
540
|
-
|
|
692
|
+
Concatenated result from all batches. Includes natural-log ``tau`` and
|
|
693
|
+
``std`` columns, base-2 ``log2fc`` and ``log2fc_se`` columns, and a
|
|
694
|
+
``'batch'`` column with the 0-based batch index. Older compatible
|
|
695
|
+
caches containing only ``tau`` and ``std`` are upgraded in memory.
|
|
541
696
|
"""
|
|
542
697
|
import gc
|
|
543
698
|
from causarray.gcate import fit_gcate_batch
|
|
@@ -708,7 +863,7 @@ def gcate_lfc_batch(
|
|
|
708
863
|
result = pd.concat(
|
|
709
864
|
[new_dfs[i] for i in all_indices], axis=0
|
|
710
865
|
).reset_index(drop=True)
|
|
711
|
-
return result
|
|
866
|
+
return _add_log2fc_columns(result)
|
|
712
867
|
|
|
713
868
|
|
|
714
869
|
def LFC_batch(*args, **kwargs):
|
|
@@ -725,4 +880,4 @@ def LFC_batch(*args, **kwargs):
|
|
|
725
880
|
DeprecationWarning,
|
|
726
881
|
stacklevel=2,
|
|
727
882
|
)
|
|
728
|
-
return gcate_lfc_batch(*args, **kwargs)
|
|
883
|
+
return gcate_lfc_batch(*args, **kwargs)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.8"
|
|
@@ -10,10 +10,14 @@ __all__ = [
|
|
|
10
10
|
'LFC', 'gcate_lfc_batch', 'LFC_batch',
|
|
11
11
|
'fit_glm', 'fit_glm_fast', 'fit_glm_ondisk',
|
|
12
12
|
'reset_random_seeds', 'fit_gcate', 'fit_gcate_batch',
|
|
13
|
+
'estimate_propensity_scores', 'summarize_propensity_scores',
|
|
14
|
+
'plot_propensity_scores',
|
|
13
15
|
]
|
|
14
16
|
|
|
15
17
|
|
|
16
18
|
from causarray.DR_learner import LFC, gcate_lfc_batch, LFC_batch # ATE, SATE, FC
|
|
19
|
+
from causarray.DR_estimation import estimate_propensity_scores
|
|
20
|
+
from causarray.diagnostics import summarize_propensity_scores, plot_propensity_scores
|
|
17
21
|
from causarray.gcate_glm import fit_glm
|
|
18
22
|
from causarray.nb_glm_fast import fit_glm_fast, fit_glm_ondisk
|
|
19
23
|
from causarray.utils import prep_causarray_data, reset_random_seeds, comp_size_factor
|
|
@@ -28,4 +32,4 @@ __maintainer__ = "Jin-Hong Du"
|
|
|
28
32
|
__maintainer_email__ = "jinhongd@andrew.cmu.edu"
|
|
29
33
|
__description__ = ("Causarray: A Python package for simultaneous causal inference"
|
|
30
34
|
" with an array of outcomes."
|
|
31
|
-
)
|
|
35
|
+
)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Diagnostics for treatment overlap and propensity-score quality."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
import matplotlib.pyplot as plt
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
from sklearn.metrics import brier_score_loss, roc_auc_score
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _coerce_treatment_inputs(A, pi_hat, treatment_names=None):
|
|
12
|
+
if isinstance(A, pd.DataFrame):
|
|
13
|
+
if treatment_names is None:
|
|
14
|
+
treatment_names = list(A.columns)
|
|
15
|
+
A = A.values
|
|
16
|
+
else:
|
|
17
|
+
A = np.asarray(A)
|
|
18
|
+
if A.ndim == 1:
|
|
19
|
+
A = A[:, None]
|
|
20
|
+
|
|
21
|
+
pi_hat = np.asarray(pi_hat, dtype=float)
|
|
22
|
+
if pi_hat.ndim == 1:
|
|
23
|
+
pi_hat = pi_hat[:, None]
|
|
24
|
+
if A.shape != pi_hat.shape:
|
|
25
|
+
raise ValueError('A and pi_hat must have the same shape')
|
|
26
|
+
if not np.all(np.isin(A, (0, 1))):
|
|
27
|
+
raise ValueError('A must contain only binary treatment indicators')
|
|
28
|
+
if np.any(~np.isfinite(pi_hat)) or np.any((pi_hat < 0) | (pi_hat > 1)):
|
|
29
|
+
raise ValueError('pi_hat must contain finite probabilities in [0, 1]')
|
|
30
|
+
|
|
31
|
+
if treatment_names is None:
|
|
32
|
+
treatment_names = list(range(A.shape[1]))
|
|
33
|
+
if len(treatment_names) != A.shape[1]:
|
|
34
|
+
raise ValueError('treatment_names must match the number of treatments')
|
|
35
|
+
return A, pi_hat, list(treatment_names)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _effective_sample_size(weights):
|
|
39
|
+
weights = np.asarray(weights, dtype=float)
|
|
40
|
+
denom = np.sum(weights ** 2)
|
|
41
|
+
return float(np.sum(weights) ** 2 / denom) if denom > 0 else np.nan
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def summarize_propensity_scores(
|
|
45
|
+
A, pi_hat, treatment_names=None, overlap_bounds=(0.05, 0.95),
|
|
46
|
+
clip_bounds=(0.01, 0.99), bins=40,
|
|
47
|
+
):
|
|
48
|
+
"""Summarize overlap and inverse-weight stability for each treatment.
|
|
49
|
+
|
|
50
|
+
Other perturbations are excluded from a treatment's diagnostic comparison;
|
|
51
|
+
each row compares that treatment with shared all-zero controls.
|
|
52
|
+
"""
|
|
53
|
+
A, pi_hat, treatment_names = _coerce_treatment_inputs(
|
|
54
|
+
A, pi_hat, treatment_names)
|
|
55
|
+
lo, hi = overlap_bounds
|
|
56
|
+
if not 0 <= lo < hi <= 1:
|
|
57
|
+
raise ValueError('overlap_bounds must satisfy 0 <= lower < upper <= 1')
|
|
58
|
+
if bins < 2:
|
|
59
|
+
raise ValueError('bins must be at least 2')
|
|
60
|
+
|
|
61
|
+
ctrl = np.sum(A, axis=1) == 0
|
|
62
|
+
if not np.any(ctrl):
|
|
63
|
+
raise ValueError('At least one all-zero control row is required')
|
|
64
|
+
rows = []
|
|
65
|
+
for j, name in enumerate(treatment_names):
|
|
66
|
+
case = A[:, j] == 1
|
|
67
|
+
if not np.any(case):
|
|
68
|
+
raise ValueError(f'Treatment {name} has no treated rows')
|
|
69
|
+
eligible = ctrl | case
|
|
70
|
+
y = A[eligible, j].astype(int)
|
|
71
|
+
p = pi_hat[eligible, j]
|
|
72
|
+
p_ctrl, p_case = p[y == 0], p[y == 1]
|
|
73
|
+
|
|
74
|
+
h_ctrl, edges = np.histogram(p_ctrl, bins=bins, range=(0, 1))
|
|
75
|
+
h_case, _ = np.histogram(p_case, bins=edges)
|
|
76
|
+
h_ctrl = h_ctrl / h_ctrl.sum() if h_ctrl.sum() else h_ctrl
|
|
77
|
+
h_case = h_case / h_case.sum() if h_case.sum() else h_case
|
|
78
|
+
overlap_ratio = float(np.minimum(h_ctrl, h_case).sum())
|
|
79
|
+
|
|
80
|
+
eps = np.finfo(float).eps
|
|
81
|
+
ess_ctrl = _effective_sample_size(1 / np.clip(1 - p_ctrl, eps, None))
|
|
82
|
+
ess_case = _effective_sample_size(1 / np.clip(p_case, eps, None))
|
|
83
|
+
clipped = np.zeros(p.shape, dtype=bool)
|
|
84
|
+
if clip_bounds is not None:
|
|
85
|
+
clipped = np.isclose(p, clip_bounds[0]) | np.isclose(p, clip_bounds[1])
|
|
86
|
+
|
|
87
|
+
rows.append({
|
|
88
|
+
'treatment': name,
|
|
89
|
+
'n_control': int((y == 0).sum()),
|
|
90
|
+
'n_treated': int((y == 1).sum()),
|
|
91
|
+
'prevalence': float(y.mean()),
|
|
92
|
+
'overlap_ratio': overlap_ratio,
|
|
93
|
+
'auc': float(roc_auc_score(y, p)),
|
|
94
|
+
'brier_score': float(brier_score_loss(y, p)),
|
|
95
|
+
'outside_overlap_fraction': float(np.mean((p < lo) | (p > hi))),
|
|
96
|
+
'clipped_fraction': float(clipped.mean()),
|
|
97
|
+
'ess_control': ess_ctrl,
|
|
98
|
+
'ess_treated': ess_case,
|
|
99
|
+
'ess_control_fraction': ess_ctrl / max(len(p_ctrl), 1),
|
|
100
|
+
'ess_treated_fraction': ess_case / max(len(p_case), 1),
|
|
101
|
+
'score_q01': float(np.quantile(p, 0.01)),
|
|
102
|
+
'score_median': float(np.median(p)),
|
|
103
|
+
'score_q99': float(np.quantile(p, 0.99)),
|
|
104
|
+
})
|
|
105
|
+
return pd.DataFrame(rows)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def plot_propensity_scores(
|
|
109
|
+
A, pi_hat, treatments=None, treatment_names=None, overlap_bounds=(0.05, 0.95),
|
|
110
|
+
bins=40, max_panels=4, axes=None,
|
|
111
|
+
):
|
|
112
|
+
"""Plot propensity distributions for treatment and control cells."""
|
|
113
|
+
A, pi_hat, treatment_names = _coerce_treatment_inputs(
|
|
114
|
+
A, pi_hat, treatment_names)
|
|
115
|
+
summary = summarize_propensity_scores(
|
|
116
|
+
A, pi_hat, treatment_names=treatment_names,
|
|
117
|
+
overlap_bounds=overlap_bounds, bins=bins,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if treatments is None:
|
|
121
|
+
indices = list(range(min(A.shape[1], max_panels)))
|
|
122
|
+
else:
|
|
123
|
+
indices = []
|
|
124
|
+
for treatment in treatments:
|
|
125
|
+
if treatment in treatment_names:
|
|
126
|
+
indices.append(treatment_names.index(treatment))
|
|
127
|
+
else:
|
|
128
|
+
index = int(treatment)
|
|
129
|
+
if index < 0 or index >= A.shape[1]:
|
|
130
|
+
raise ValueError(f'Unknown treatment: {treatment}')
|
|
131
|
+
indices.append(index)
|
|
132
|
+
if not indices:
|
|
133
|
+
raise ValueError('At least one treatment must be selected')
|
|
134
|
+
|
|
135
|
+
if axes is None:
|
|
136
|
+
ncols = min(2, len(indices))
|
|
137
|
+
nrows = math.ceil(len(indices) / ncols)
|
|
138
|
+
fig, axes = plt.subplots(
|
|
139
|
+
nrows, ncols, figsize=(5 * ncols, 3.5 * nrows), squeeze=False,
|
|
140
|
+
)
|
|
141
|
+
axes_flat = axes.ravel()
|
|
142
|
+
else:
|
|
143
|
+
axes_flat = np.asarray(axes, dtype=object).ravel()
|
|
144
|
+
if len(axes_flat) < len(indices):
|
|
145
|
+
raise ValueError('Not enough axes for the selected treatments')
|
|
146
|
+
fig = axes_flat[0].figure
|
|
147
|
+
|
|
148
|
+
ctrl = np.sum(A, axis=1) == 0
|
|
149
|
+
for ax, j in zip(axes_flat, indices):
|
|
150
|
+
case = A[:, j] == 1
|
|
151
|
+
ax.hist(
|
|
152
|
+
pi_hat[ctrl, j], bins=bins, range=(0, 1), density=True,
|
|
153
|
+
histtype='step', linewidth=1.6, label='control', color='#4c78a8',
|
|
154
|
+
)
|
|
155
|
+
ax.hist(
|
|
156
|
+
pi_hat[case, j], bins=bins, range=(0, 1), density=True,
|
|
157
|
+
histtype='step', linewidth=1.6, label=str(treatment_names[j]),
|
|
158
|
+
color='#e45756',
|
|
159
|
+
)
|
|
160
|
+
ax.axvline(overlap_bounds[0], color='#777777', linestyle='--', linewidth=0.8)
|
|
161
|
+
ax.axvline(overlap_bounds[1], color='#777777', linestyle='--', linewidth=0.8)
|
|
162
|
+
overlap = summary.loc[j, 'overlap_ratio']
|
|
163
|
+
ess = summary.loc[j, 'ess_treated_fraction']
|
|
164
|
+
ax.set_title(f'{treatment_names[j]} | overlap={overlap:.2f}, ESS={ess:.2f}')
|
|
165
|
+
ax.set_xlim(0, 1)
|
|
166
|
+
ax.set_xlabel('Estimated propensity score')
|
|
167
|
+
ax.set_ylabel('Density')
|
|
168
|
+
ax.legend(frameon=False)
|
|
169
|
+
for ax in axes_flat[len(indices):]:
|
|
170
|
+
ax.set_visible(False)
|
|
171
|
+
fig.tight_layout()
|
|
172
|
+
return fig, axes, summary
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.0.6"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|