causarray 0.0.8__tar.gz → 0.0.9__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.8 → causarray-0.0.9}/.gitignore +2 -0
- {causarray-0.0.8 → causarray-0.0.9}/PKG-INFO +25 -1
- {causarray-0.0.8 → causarray-0.0.9}/README.md +24 -0
- {causarray-0.0.8 → causarray-0.0.9}/causarray/DR_estimation.py +294 -3
- causarray-0.0.9/causarray/__about__.py +1 -0
- {causarray-0.0.8 → causarray-0.0.9}/causarray/__init__.py +9 -3
- causarray-0.0.9/causarray/diagnostics.py +628 -0
- causarray-0.0.8/causarray/__about__.py +0 -1
- causarray-0.0.8/causarray/diagnostics.py +0 -172
- {causarray-0.0.8 → causarray-0.0.9}/LICENSE +0 -0
- {causarray-0.0.8 → causarray-0.0.9}/causarray/DR_inference.py +0 -0
- {causarray-0.0.8 → causarray-0.0.9}/causarray/DR_learner.py +0 -0
- {causarray-0.0.8 → causarray-0.0.9}/causarray/gcate.py +0 -0
- {causarray-0.0.8 → causarray-0.0.9}/causarray/gcate_glm.py +0 -0
- {causarray-0.0.8 → causarray-0.0.9}/causarray/gcate_likelihood.py +0 -0
- {causarray-0.0.8 → causarray-0.0.9}/causarray/gcate_opt.py +0 -0
- {causarray-0.0.8 → causarray-0.0.9}/causarray/nb_glm_fast.py +0 -0
- {causarray-0.0.8 → causarray-0.0.9}/causarray/utils.py +0 -0
- {causarray-0.0.8 → causarray-0.0.9}/pyproject.toml +0 -0
|
@@ -184,6 +184,8 @@ docs/source/tutorial/replogle/replogle_subset.h5ad
|
|
|
184
184
|
docs/source/tutorial/replogle/replogle_subset_norm*.h5ad
|
|
185
185
|
docs/source/tutorial/replogle/replogle_normed.h5ad
|
|
186
186
|
docs/source/tutorial/replogle/replogle_results.h5
|
|
187
|
+
docs/source/tutorial/replogle/replogle_supt5h_go_*.csv
|
|
188
|
+
docs/source/tutorial/replogle/replogle_propensity_batch12*
|
|
187
189
|
|
|
188
190
|
# Unrelated tutorial directory (not part of causarray package docs)
|
|
189
191
|
docs/source/tutorial/scp/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: causarray
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.9
|
|
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>
|
|
@@ -98,6 +98,30 @@ df_res = gcate_lfc_batch(
|
|
|
98
98
|
See the [Replogle-E-K562 tutorial](https://causarray.readthedocs.io/en/latest/tutorial/replogle/replogle-py.html)
|
|
99
99
|
for a demonstration on 200 perturbations from a genome-wide CRISPRi screen.
|
|
100
100
|
|
|
101
|
+
### Diagnostic masks without refitting effects
|
|
102
|
+
|
|
103
|
+
Treatment-by-gene support or quality-control rules can be aligned to an
|
|
104
|
+
existing causarray result table by label, even when its rows are reordered:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from causarray import align_test_mask
|
|
108
|
+
|
|
109
|
+
keep = align_test_mask(
|
|
110
|
+
df_res,
|
|
111
|
+
support_mask, # treatments × genes, Boolean
|
|
112
|
+
treatment_names=perturbations,
|
|
113
|
+
gene_names=genes,
|
|
114
|
+
)
|
|
115
|
+
df_res_flagged = df_res.assign(support_keep=keep)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
This operation only annotates or subsets existing results. It does not refit
|
|
119
|
+
the causarray LFC or change standard errors and p-values. If a diagnostic rule
|
|
120
|
+
was selected after inspecting the outcomes, retain the original adjusted
|
|
121
|
+
p-values rather than redefining the multiple-testing family post hoc. The
|
|
122
|
+
[Replogle tutorial](https://causarray.readthedocs.io/en/latest/tutorial/replogle/replogle-py.html)
|
|
123
|
+
compares several expression-support rules with marginal Wilcoxon results.
|
|
124
|
+
|
|
101
125
|
## Changelog
|
|
102
126
|
|
|
103
127
|
See [CHANGELOG](https://causarray.readthedocs.io/en/latest/changelog.html) for a full version history.
|
|
@@ -73,6 +73,30 @@ df_res = gcate_lfc_batch(
|
|
|
73
73
|
See the [Replogle-E-K562 tutorial](https://causarray.readthedocs.io/en/latest/tutorial/replogle/replogle-py.html)
|
|
74
74
|
for a demonstration on 200 perturbations from a genome-wide CRISPRi screen.
|
|
75
75
|
|
|
76
|
+
### Diagnostic masks without refitting effects
|
|
77
|
+
|
|
78
|
+
Treatment-by-gene support or quality-control rules can be aligned to an
|
|
79
|
+
existing causarray result table by label, even when its rows are reordered:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from causarray import align_test_mask
|
|
83
|
+
|
|
84
|
+
keep = align_test_mask(
|
|
85
|
+
df_res,
|
|
86
|
+
support_mask, # treatments × genes, Boolean
|
|
87
|
+
treatment_names=perturbations,
|
|
88
|
+
gene_names=genes,
|
|
89
|
+
)
|
|
90
|
+
df_res_flagged = df_res.assign(support_keep=keep)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
This operation only annotates or subsets existing results. It does not refit
|
|
94
|
+
the causarray LFC or change standard errors and p-values. If a diagnostic rule
|
|
95
|
+
was selected after inspecting the outcomes, retain the original adjusted
|
|
96
|
+
p-values rather than redefining the multiple-testing family post hoc. The
|
|
97
|
+
[Replogle tutorial](https://causarray.readthedocs.io/en/latest/tutorial/replogle/replogle-py.html)
|
|
98
|
+
compares several expression-support rules with marginal Wilcoxon results.
|
|
99
|
+
|
|
76
100
|
## Changelog
|
|
77
101
|
|
|
78
102
|
See [CHANGELOG](https://causarray.readthedocs.io/en/latest/changelog.html) for a full version history.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
2
3
|
from sklearn.linear_model import LogisticRegression
|
|
3
4
|
from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor
|
|
4
5
|
from sklearn_ensemble_cv import reset_random_seeds, Ensemble, ECV
|
|
@@ -10,6 +11,7 @@ from joblib import Parallel, delayed
|
|
|
10
11
|
from tqdm import tqdm
|
|
11
12
|
import pprint
|
|
12
13
|
import warnings
|
|
14
|
+
from collections.abc import Mapping
|
|
13
15
|
|
|
14
16
|
from sklearn.model_selection import KFold, ShuffleSplit
|
|
15
17
|
|
|
@@ -40,6 +42,27 @@ def _get_func_ps(ps_model, **kwargs):
|
|
|
40
42
|
return func_ps, params_ps
|
|
41
43
|
|
|
42
44
|
|
|
45
|
+
def _validate_clip(clip):
|
|
46
|
+
"""Validate a propensity clipping bound and return it as ``(lower, upper)``.
|
|
47
|
+
|
|
48
|
+
Raises
|
|
49
|
+
------
|
|
50
|
+
ValueError
|
|
51
|
+
If ``clip`` is not a pair of numbers satisfying
|
|
52
|
+
``0 <= lower < upper <= 1``.
|
|
53
|
+
"""
|
|
54
|
+
message = 'clip must be None or a pair 0 <= lower < upper <= 1'
|
|
55
|
+
if isinstance(clip, (str, bytes)) or np.ndim(clip) != 1:
|
|
56
|
+
raise ValueError(message)
|
|
57
|
+
try:
|
|
58
|
+
lower, upper = (float(bound) for bound in clip)
|
|
59
|
+
except (TypeError, ValueError):
|
|
60
|
+
raise ValueError(message) from None
|
|
61
|
+
if not 0 <= lower < upper <= 1:
|
|
62
|
+
raise ValueError(message)
|
|
63
|
+
return lower, upper
|
|
64
|
+
|
|
65
|
+
|
|
43
66
|
def estimate_propensity_scores(
|
|
44
67
|
A, X_A, K=1, ps_model='logistic', mask=None, clip=None,
|
|
45
68
|
random_state=0, verbose=False, class_weight='balanced', **kwargs,
|
|
@@ -172,12 +195,280 @@ def estimate_propensity_scores(
|
|
|
172
195
|
pi_hat[test_index, j] = func_ps(x_train, y_train, XA_test)
|
|
173
196
|
|
|
174
197
|
if clip is not None:
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
pi_hat = np.clip(pi_hat, clip[0], clip[1])
|
|
198
|
+
lower, upper = _validate_clip(clip)
|
|
199
|
+
pi_hat = np.clip(pi_hat, lower, upper)
|
|
178
200
|
return pi_hat
|
|
179
201
|
|
|
180
202
|
|
|
203
|
+
def refit_propensity_scores(
|
|
204
|
+
A, X_A, drop_by_treatment=None, pi_hat=None, treatment_names=None,
|
|
205
|
+
covariate_names=None, penalty_factors_by_treatment=None, K=1,
|
|
206
|
+
ps_model='logistic', mask=None, clip=None, random_state=0, verbose=False,
|
|
207
|
+
class_weight='balanced', **kwargs,
|
|
208
|
+
):
|
|
209
|
+
"""Refit propensity scores with treatment-specific covariate filtering.
|
|
210
|
+
|
|
211
|
+
This helper supports sensitivity analyses in which different treatments
|
|
212
|
+
omit different observed covariates or latent factors, or apply stronger L2
|
|
213
|
+
regularization to selected covariates. When existing scores are supplied,
|
|
214
|
+
only treatments named in either treatment-specific mapping are refitted;
|
|
215
|
+
the remaining columns are carried over from ``pi_hat`` unchanged, up to the
|
|
216
|
+
shared ``clip`` described below. Outcome models are not fit by this function
|
|
217
|
+
and cached ``Y_hat`` values can be reused in :func:`LFC`.
|
|
218
|
+
|
|
219
|
+
Parameters
|
|
220
|
+
----------
|
|
221
|
+
A : array-like, shape (n, a)
|
|
222
|
+
Binary treatment indicator matrix.
|
|
223
|
+
X_A : array-like, shape (n, d_A)
|
|
224
|
+
Full propensity-model design before treatment-specific filtering.
|
|
225
|
+
drop_by_treatment : mapping or None
|
|
226
|
+
Treatment names or indices mapped to covariate names or indices to
|
|
227
|
+
remove for that treatment. Defaults to no removals.
|
|
228
|
+
pi_hat : array-like or None, shape (n, a)
|
|
229
|
+
Existing raw propensity scores. If supplied, treatments absent from
|
|
230
|
+
``drop_by_treatment`` are not refitted.
|
|
231
|
+
treatment_names, covariate_names : sequence, optional
|
|
232
|
+
Column labels, inferred from DataFrames when possible.
|
|
233
|
+
penalty_factors_by_treatment : mapping or None
|
|
234
|
+
Treatment names or indices mapped to ``{covariate: factor}`` mappings.
|
|
235
|
+
A factor greater than one applies that multiple of the ordinary L2
|
|
236
|
+
penalty to the named coefficient. This is implemented by dividing the
|
|
237
|
+
covariate by ``sqrt(factor)`` during both fitting and prediction and is
|
|
238
|
+
available only for ``ps_model='logistic'`` with an L2 penalty. A factor
|
|
239
|
+
of one leaves the covariate unchanged. Interpret relative penalties on
|
|
240
|
+
a common scale; ``prep_causarray_data`` standardizes log-library size.
|
|
241
|
+
K, ps_model, mask, random_state, verbose, class_weight, **kwargs
|
|
242
|
+
Passed to :func:`estimate_propensity_scores` for each refitted model.
|
|
243
|
+
clip : tuple(float, float) or None, optional
|
|
244
|
+
Bounds applied to the **whole** returned matrix, refitted columns and
|
|
245
|
+
carried-over columns alike, so that a single consistent bound reaches
|
|
246
|
+
:func:`LFC`. Pass ``None`` to leave carried-over scores exactly as
|
|
247
|
+
supplied.
|
|
248
|
+
|
|
249
|
+
Returns
|
|
250
|
+
-------
|
|
251
|
+
pi_updated : ndarray, shape (n, a)
|
|
252
|
+
Updated propensity scores.
|
|
253
|
+
report : DataFrame
|
|
254
|
+
Audit table of refitted treatments, retained/dropped covariates, and
|
|
255
|
+
the resulting score spread. ``degenerate_design`` flags a treatment
|
|
256
|
+
whose retained design is constant, and ``score_std`` reports the
|
|
257
|
+
standard deviation of its refitted scores on eligible rows; both make a
|
|
258
|
+
collapsed propensity model visible without reading the warning stream.
|
|
259
|
+
|
|
260
|
+
Warns
|
|
261
|
+
-----
|
|
262
|
+
RuntimeWarning
|
|
263
|
+
If filtering leaves a constant design for some treatment, in which case
|
|
264
|
+
its scores carry no covariate information.
|
|
265
|
+
|
|
266
|
+
Notes
|
|
267
|
+
-----
|
|
268
|
+
A very large penalty factor shrinks a coefficient towards zero without
|
|
269
|
+
making the design constant. That case leaves ``degenerate_design`` False
|
|
270
|
+
but drives ``score_std`` towards zero.
|
|
271
|
+
|
|
272
|
+
.. versionadded:: 0.0.9
|
|
273
|
+
"""
|
|
274
|
+
if drop_by_treatment is None:
|
|
275
|
+
drop_by_treatment = {}
|
|
276
|
+
if penalty_factors_by_treatment is None:
|
|
277
|
+
penalty_factors_by_treatment = {}
|
|
278
|
+
if not isinstance(drop_by_treatment, Mapping):
|
|
279
|
+
raise ValueError('drop_by_treatment must be a mapping')
|
|
280
|
+
if not isinstance(penalty_factors_by_treatment, Mapping):
|
|
281
|
+
raise ValueError('penalty_factors_by_treatment must be a mapping')
|
|
282
|
+
if penalty_factors_by_treatment:
|
|
283
|
+
if ps_model != 'logistic':
|
|
284
|
+
raise ValueError(
|
|
285
|
+
'penalty_factors_by_treatment is supported only for logistic models'
|
|
286
|
+
)
|
|
287
|
+
if kwargs.get('penalty', 'l2') != 'l2':
|
|
288
|
+
raise ValueError(
|
|
289
|
+
'penalty_factors_by_treatment requires logistic penalty="l2"'
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
if isinstance(A, pd.DataFrame):
|
|
293
|
+
if treatment_names is None:
|
|
294
|
+
treatment_names = list(A.columns)
|
|
295
|
+
A_array = A.to_numpy()
|
|
296
|
+
else:
|
|
297
|
+
A_array = np.asarray(A)
|
|
298
|
+
if A_array.ndim == 1:
|
|
299
|
+
A_array = A_array[:, None]
|
|
300
|
+
if A_array.ndim != 2 or not np.all(np.isin(A_array, (0, 1))):
|
|
301
|
+
raise ValueError('A must be a one- or two-dimensional binary matrix')
|
|
302
|
+
if treatment_names is None:
|
|
303
|
+
treatment_names = list(range(A_array.shape[1]))
|
|
304
|
+
treatment_names = list(treatment_names)
|
|
305
|
+
if len(treatment_names) != A_array.shape[1]:
|
|
306
|
+
raise ValueError('treatment_names must match the number of treatments')
|
|
307
|
+
if len(set(treatment_names)) != len(treatment_names):
|
|
308
|
+
raise ValueError('treatment_names must be unique')
|
|
309
|
+
|
|
310
|
+
if isinstance(X_A, pd.DataFrame):
|
|
311
|
+
if covariate_names is None:
|
|
312
|
+
covariate_names = list(X_A.columns)
|
|
313
|
+
X_array = X_A.to_numpy()
|
|
314
|
+
else:
|
|
315
|
+
X_array = np.asarray(X_A)
|
|
316
|
+
if X_array.ndim != 2 or X_array.shape[0] != A_array.shape[0]:
|
|
317
|
+
raise ValueError('X_A must be two-dimensional with the same rows as A')
|
|
318
|
+
try:
|
|
319
|
+
X_array = np.asarray(X_array, dtype=float)
|
|
320
|
+
except (TypeError, ValueError) as exc:
|
|
321
|
+
raise ValueError('X_A must contain numeric covariates') from exc
|
|
322
|
+
if not np.all(np.isfinite(X_array)):
|
|
323
|
+
raise ValueError('X_A must contain only finite values')
|
|
324
|
+
if covariate_names is None:
|
|
325
|
+
covariate_names = [f'covariate_{j + 1}' for j in range(X_array.shape[1])]
|
|
326
|
+
covariate_names = list(covariate_names)
|
|
327
|
+
if len(covariate_names) != X_array.shape[1]:
|
|
328
|
+
raise ValueError('covariate_names must match the number of covariates')
|
|
329
|
+
if len(set(covariate_names)) != len(covariate_names):
|
|
330
|
+
raise ValueError('covariate_names must be unique')
|
|
331
|
+
|
|
332
|
+
treatment_lookup = {name: j for j, name in enumerate(treatment_names)}
|
|
333
|
+
covariate_lookup = {name: j for j, name in enumerate(covariate_names)}
|
|
334
|
+
|
|
335
|
+
def resolve_treatment(value):
|
|
336
|
+
if value in treatment_lookup:
|
|
337
|
+
return treatment_lookup[value]
|
|
338
|
+
if isinstance(value, (int, np.integer)) and 0 <= int(value) < A_array.shape[1]:
|
|
339
|
+
return int(value)
|
|
340
|
+
raise ValueError(f'Unknown treatment: {value}')
|
|
341
|
+
|
|
342
|
+
def resolve_covariate(value):
|
|
343
|
+
if value in covariate_lookup:
|
|
344
|
+
return covariate_lookup[value]
|
|
345
|
+
if isinstance(value, (int, np.integer)) and 0 <= int(value) < X_array.shape[1]:
|
|
346
|
+
return int(value)
|
|
347
|
+
raise ValueError(f'Unknown covariate: {value}')
|
|
348
|
+
|
|
349
|
+
drops = {}
|
|
350
|
+
for treatment, covariates in drop_by_treatment.items():
|
|
351
|
+
j = resolve_treatment(treatment)
|
|
352
|
+
if j in drops:
|
|
353
|
+
raise ValueError(f'Treatment {treatment_names[j]} is specified more than once')
|
|
354
|
+
if isinstance(covariates, (str, bytes)):
|
|
355
|
+
covariates = [covariates]
|
|
356
|
+
indices = [resolve_covariate(value) for value in covariates]
|
|
357
|
+
if len(set(indices)) != len(indices):
|
|
358
|
+
raise ValueError(
|
|
359
|
+
f'Duplicate dropped covariates for treatment {treatment_names[j]}'
|
|
360
|
+
)
|
|
361
|
+
drops[j] = set(indices)
|
|
362
|
+
|
|
363
|
+
penalty_factors = {}
|
|
364
|
+
for treatment, factors in penalty_factors_by_treatment.items():
|
|
365
|
+
j = resolve_treatment(treatment)
|
|
366
|
+
if j in penalty_factors:
|
|
367
|
+
raise ValueError(f'Treatment {treatment_names[j]} is specified more than once')
|
|
368
|
+
if not isinstance(factors, Mapping):
|
|
369
|
+
raise ValueError(
|
|
370
|
+
f'Penalty factors for treatment {treatment_names[j]} must be a mapping'
|
|
371
|
+
)
|
|
372
|
+
resolved = {}
|
|
373
|
+
for covariate, factor in factors.items():
|
|
374
|
+
k = resolve_covariate(covariate)
|
|
375
|
+
try:
|
|
376
|
+
factor = float(factor)
|
|
377
|
+
except (TypeError, ValueError) as exc:
|
|
378
|
+
raise ValueError('Penalty factors must be finite numbers at least 1') from exc
|
|
379
|
+
if not np.isfinite(factor) or factor < 1:
|
|
380
|
+
raise ValueError('Penalty factors must be finite numbers at least 1')
|
|
381
|
+
if k in resolved:
|
|
382
|
+
raise ValueError(
|
|
383
|
+
f'Duplicate penalty factors for covariate {covariate_names[k]}'
|
|
384
|
+
)
|
|
385
|
+
resolved[k] = factor
|
|
386
|
+
penalty_factors[j] = resolved
|
|
387
|
+
|
|
388
|
+
for j in set(drops).intersection(penalty_factors):
|
|
389
|
+
conflict = drops[j].intersection(penalty_factors[j])
|
|
390
|
+
if conflict:
|
|
391
|
+
names = [covariate_names[k] for k in sorted(conflict)]
|
|
392
|
+
raise ValueError(
|
|
393
|
+
f'Covariates cannot be both dropped and penalized for '
|
|
394
|
+
f'{treatment_names[j]}: {names}'
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
if pi_hat is None:
|
|
398
|
+
pi_updated = np.empty(A_array.shape, dtype=float)
|
|
399
|
+
refit_indices = range(A_array.shape[1])
|
|
400
|
+
else:
|
|
401
|
+
pi_updated = np.asarray(pi_hat, dtype=float).copy()
|
|
402
|
+
if pi_updated.shape != A_array.shape:
|
|
403
|
+
raise ValueError('pi_hat must have the same shape as A')
|
|
404
|
+
if (not np.all(np.isfinite(pi_updated))
|
|
405
|
+
or np.any((pi_updated < 0) | (pi_updated > 1))):
|
|
406
|
+
raise ValueError('pi_hat must contain finite probabilities in [0, 1]')
|
|
407
|
+
refit_indices = sorted(set(drops).union(penalty_factors))
|
|
408
|
+
|
|
409
|
+
mask_array = None
|
|
410
|
+
if mask is not None:
|
|
411
|
+
mask_array = np.asarray(mask, dtype=bool)
|
|
412
|
+
if mask_array.ndim == 1:
|
|
413
|
+
mask_array = mask_array[:, None]
|
|
414
|
+
if mask_array.shape != A_array.shape:
|
|
415
|
+
raise ValueError('Mask must have the same shape as the treatment matrix')
|
|
416
|
+
|
|
417
|
+
ctrl = np.sum(A_array, axis=1) == 0
|
|
418
|
+
rows = []
|
|
419
|
+
for j in refit_indices:
|
|
420
|
+
dropped = drops.get(j, set())
|
|
421
|
+
retained = [k for k in range(X_array.shape[1]) if k not in dropped]
|
|
422
|
+
if not retained:
|
|
423
|
+
raise ValueError(
|
|
424
|
+
f'Filtering removes every covariate for treatment {treatment_names[j]}'
|
|
425
|
+
)
|
|
426
|
+
eligible = ctrl | (A_array[:, j] == 1)
|
|
427
|
+
if mask_array is not None:
|
|
428
|
+
eligible &= mask_array[:, j]
|
|
429
|
+
X_treatment = X_array[:, retained].copy()
|
|
430
|
+
treatment_penalties = penalty_factors.get(j, {})
|
|
431
|
+
for position, k in enumerate(retained):
|
|
432
|
+
factor = treatment_penalties.get(k, 1.0)
|
|
433
|
+
X_treatment[:, position] /= np.sqrt(factor)
|
|
434
|
+
degenerate = bool(np.all(np.ptp(X_treatment[eligible], axis=0) == 0))
|
|
435
|
+
if degenerate:
|
|
436
|
+
warnings.warn(
|
|
437
|
+
f'The propensity design for treatment {treatment_names[j]} is '
|
|
438
|
+
'constant after filtering; its scores fall back to a '
|
|
439
|
+
'class-weighted prevalence and carry no covariate information.',
|
|
440
|
+
RuntimeWarning, stacklevel=2,
|
|
441
|
+
)
|
|
442
|
+
scores = estimate_propensity_scores(
|
|
443
|
+
A_array[:, [j]], X_treatment, K=K,
|
|
444
|
+
ps_model=ps_model, mask=eligible[:, None], clip=None,
|
|
445
|
+
random_state=random_state, verbose=verbose,
|
|
446
|
+
class_weight=class_weight, **kwargs,
|
|
447
|
+
)
|
|
448
|
+
pi_updated[:, j] = scores[:, 0]
|
|
449
|
+
rows.append({
|
|
450
|
+
'treatment': treatment_names[j],
|
|
451
|
+
'dropped_covariates': [covariate_names[k] for k in sorted(dropped)],
|
|
452
|
+
'retained_covariates': [covariate_names[k] for k in retained],
|
|
453
|
+
'penalty_factors': {
|
|
454
|
+
covariate_names[k]: treatment_penalties[k]
|
|
455
|
+
for k in retained if treatment_penalties.get(k, 1.0) != 1.0
|
|
456
|
+
},
|
|
457
|
+
'n_retained': len(retained),
|
|
458
|
+
'degenerate_design': degenerate,
|
|
459
|
+
'score_std': float(np.std(scores[eligible, 0])),
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
if clip is not None:
|
|
463
|
+
lower, upper = _validate_clip(clip)
|
|
464
|
+
pi_updated = np.clip(pi_updated, lower, upper)
|
|
465
|
+
report = pd.DataFrame(rows, columns=[
|
|
466
|
+
'treatment', 'dropped_covariates', 'retained_covariates',
|
|
467
|
+
'penalty_factors', 'n_retained', 'degenerate_design', 'score_std',
|
|
468
|
+
])
|
|
469
|
+
return pi_updated, report
|
|
470
|
+
|
|
471
|
+
|
|
181
472
|
def cross_fitting(
|
|
182
473
|
Y, A, X, X_A, family='poisson', K=1, glm_alpha=1e-4,
|
|
183
474
|
ps_model='logistic', ps_class_weight='balanced',
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.9"
|
|
@@ -11,13 +11,19 @@ __all__ = [
|
|
|
11
11
|
'fit_glm', 'fit_glm_fast', 'fit_glm_ondisk',
|
|
12
12
|
'reset_random_seeds', 'fit_gcate', 'fit_gcate_batch',
|
|
13
13
|
'estimate_propensity_scores', 'summarize_propensity_scores',
|
|
14
|
-
'plot_propensity_scores',
|
|
14
|
+
'plot_propensity_scores', 'refit_propensity_scores',
|
|
15
|
+
'summarize_treatment_associations', 'plot_treatment_associations',
|
|
16
|
+
'align_test_mask',
|
|
15
17
|
]
|
|
16
18
|
|
|
17
19
|
|
|
18
20
|
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
|
|
21
|
+
from causarray.DR_estimation import estimate_propensity_scores, refit_propensity_scores
|
|
22
|
+
from causarray.diagnostics import (
|
|
23
|
+
summarize_propensity_scores, plot_propensity_scores,
|
|
24
|
+
summarize_treatment_associations, plot_treatment_associations,
|
|
25
|
+
align_test_mask,
|
|
26
|
+
)
|
|
21
27
|
from causarray.gcate_glm import fit_glm
|
|
22
28
|
from causarray.nb_glm_fast import fit_glm_fast, fit_glm_ondisk
|
|
23
29
|
from causarray.utils import prep_causarray_data, reset_random_seeds, comp_size_factor
|
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
"""Diagnostics for treatment overlap, propensity scores, and result masks."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from typing import Hashable, Optional, Sequence, Union
|
|
5
|
+
|
|
6
|
+
import matplotlib.pyplot as plt
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
from scipy import stats
|
|
10
|
+
from sklearn.metrics import brier_score_loss, roc_auc_score
|
|
11
|
+
from statsmodels.stats.multitest import multipletests
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_TestMask = Union[np.ndarray, pd.DataFrame, pd.Series]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _normalize_test_labels(values, name):
|
|
18
|
+
raw_labels = pd.Index(values)
|
|
19
|
+
if raw_labels.hasnans:
|
|
20
|
+
raise ValueError(f'{name} names must not be missing')
|
|
21
|
+
labels = pd.Index([str(value) for value in raw_labels], dtype=object)
|
|
22
|
+
if labels.has_duplicates:
|
|
23
|
+
duplicates = labels[labels.duplicated()].unique().tolist()
|
|
24
|
+
raise ValueError(f'{name} names must be unique; duplicates: {duplicates}')
|
|
25
|
+
return labels
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _require_boolean_mask(values):
|
|
29
|
+
values = np.asarray(values)
|
|
30
|
+
if values.dtype.kind != 'b':
|
|
31
|
+
raise ValueError('test_mask must contain only Boolean values')
|
|
32
|
+
return values
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def align_test_mask(
|
|
36
|
+
results: pd.DataFrame,
|
|
37
|
+
test_mask: _TestMask,
|
|
38
|
+
treatment_names: Optional[Sequence[Hashable]] = None,
|
|
39
|
+
gene_names: Optional[Sequence[Hashable]] = None,
|
|
40
|
+
treatment_col: str = 'trt',
|
|
41
|
+
gene_col: str = 'gene_names',
|
|
42
|
+
) -> np.ndarray:
|
|
43
|
+
"""Align a treatment-by-gene diagnostic mask to an LFC result table.
|
|
44
|
+
|
|
45
|
+
Alignment uses treatment and gene labels rather than row position. This
|
|
46
|
+
makes it possible to annotate or subset existing causarray results with a
|
|
47
|
+
post-hoc diagnostic rule without refitting effects or changing their
|
|
48
|
+
standard errors and p-values. Labels are compared after conversion to
|
|
49
|
+
strings, matching the labeling convention of batch LFC results.
|
|
50
|
+
|
|
51
|
+
Parameters
|
|
52
|
+
----------
|
|
53
|
+
results : DataFrame
|
|
54
|
+
Result table containing one row per treatment-gene test.
|
|
55
|
+
test_mask : ndarray, DataFrame, or Series
|
|
56
|
+
Boolean diagnostic mask. A two-dimensional array requires
|
|
57
|
+
``treatment_names`` and ``gene_names``. A DataFrame uses its index as
|
|
58
|
+
treatment names and columns as gene names unless names are supplied
|
|
59
|
+
explicitly. A Series must have a two-level ``(treatment, gene)``
|
|
60
|
+
MultiIndex.
|
|
61
|
+
treatment_names, gene_names : sequence, optional
|
|
62
|
+
Labels for the rows and columns of a two-dimensional mask.
|
|
63
|
+
treatment_col, gene_col : str, optional
|
|
64
|
+
Columns containing treatment and gene labels in ``results``.
|
|
65
|
+
|
|
66
|
+
Returns
|
|
67
|
+
-------
|
|
68
|
+
aligned : ndarray of bool, shape (n_tests,)
|
|
69
|
+
Mask values in the original row order of ``results``.
|
|
70
|
+
|
|
71
|
+
Raises
|
|
72
|
+
------
|
|
73
|
+
ValueError
|
|
74
|
+
If labels are missing or duplicated, the mask is malformed or
|
|
75
|
+
non-Boolean, or a result test is absent from the mask.
|
|
76
|
+
|
|
77
|
+
Notes
|
|
78
|
+
-----
|
|
79
|
+
This function only aligns a diagnostic mask. It does not modify the
|
|
80
|
+
multiple-testing family or recompute adjusted p-values.
|
|
81
|
+
|
|
82
|
+
.. versionadded:: 0.0.9
|
|
83
|
+
"""
|
|
84
|
+
if not isinstance(results, pd.DataFrame):
|
|
85
|
+
raise ValueError('results must be a pandas DataFrame')
|
|
86
|
+
missing_columns = {treatment_col, gene_col}.difference(results.columns)
|
|
87
|
+
if missing_columns:
|
|
88
|
+
raise ValueError(
|
|
89
|
+
f'results is missing required columns: {sorted(missing_columns)}'
|
|
90
|
+
)
|
|
91
|
+
if results[[treatment_col, gene_col]].isna().any().any():
|
|
92
|
+
raise ValueError('result treatment and gene labels must not be missing')
|
|
93
|
+
|
|
94
|
+
result_keys = pd.MultiIndex.from_arrays([
|
|
95
|
+
results[treatment_col].astype(str),
|
|
96
|
+
results[gene_col].astype(str),
|
|
97
|
+
])
|
|
98
|
+
if result_keys.has_duplicates:
|
|
99
|
+
duplicates = result_keys[result_keys.duplicated()].unique().tolist()
|
|
100
|
+
raise ValueError(
|
|
101
|
+
'results contains duplicate treatment-gene tests: '
|
|
102
|
+
f'{duplicates[:5]}'
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if isinstance(test_mask, pd.Series):
|
|
106
|
+
if treatment_names is not None or gene_names is not None:
|
|
107
|
+
raise ValueError(
|
|
108
|
+
'treatment_names and gene_names must be omitted for a Series mask'
|
|
109
|
+
)
|
|
110
|
+
if (
|
|
111
|
+
not isinstance(test_mask.index, pd.MultiIndex)
|
|
112
|
+
or test_mask.index.nlevels != 2
|
|
113
|
+
):
|
|
114
|
+
raise ValueError(
|
|
115
|
+
'a Series test_mask must have a two-level treatment-gene MultiIndex'
|
|
116
|
+
)
|
|
117
|
+
values = _require_boolean_mask(test_mask.to_numpy())
|
|
118
|
+
raw_treatments = pd.Index(test_mask.index.get_level_values(0))
|
|
119
|
+
raw_genes = pd.Index(test_mask.index.get_level_values(1))
|
|
120
|
+
if raw_treatments.hasnans or raw_genes.hasnans:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
'test_mask treatment and gene labels must not be missing'
|
|
123
|
+
)
|
|
124
|
+
treatment_index = pd.Index(
|
|
125
|
+
[str(value) for value in raw_treatments], dtype=object,
|
|
126
|
+
)
|
|
127
|
+
gene_index = pd.Index(
|
|
128
|
+
[str(value) for value in raw_genes], dtype=object,
|
|
129
|
+
)
|
|
130
|
+
mask_index = pd.MultiIndex.from_arrays([treatment_index, gene_index])
|
|
131
|
+
if mask_index.has_duplicates:
|
|
132
|
+
duplicates = mask_index[mask_index.duplicated()].unique().tolist()
|
|
133
|
+
raise ValueError(
|
|
134
|
+
'test_mask contains duplicate treatment-gene tests: '
|
|
135
|
+
f'{duplicates[:5]}'
|
|
136
|
+
)
|
|
137
|
+
lookup = pd.Series(values, index=mask_index)
|
|
138
|
+
else:
|
|
139
|
+
if isinstance(test_mask, pd.DataFrame):
|
|
140
|
+
values = _require_boolean_mask(test_mask.to_numpy())
|
|
141
|
+
if treatment_names is None:
|
|
142
|
+
treatment_names = list(test_mask.index)
|
|
143
|
+
if gene_names is None:
|
|
144
|
+
gene_names = list(test_mask.columns)
|
|
145
|
+
else:
|
|
146
|
+
values = _require_boolean_mask(test_mask)
|
|
147
|
+
if values.ndim != 2:
|
|
148
|
+
raise ValueError('test_mask must be two-dimensional')
|
|
149
|
+
if treatment_names is None or gene_names is None:
|
|
150
|
+
raise ValueError(
|
|
151
|
+
'treatment_names and gene_names are required for an array mask'
|
|
152
|
+
)
|
|
153
|
+
treatments = _normalize_test_labels(treatment_names, 'treatment')
|
|
154
|
+
genes = _normalize_test_labels(gene_names, 'gene')
|
|
155
|
+
expected_shape = (len(treatments), len(genes))
|
|
156
|
+
if values.shape != expected_shape:
|
|
157
|
+
raise ValueError(
|
|
158
|
+
f'test_mask has shape {values.shape}; expected {expected_shape}'
|
|
159
|
+
)
|
|
160
|
+
mask_index = pd.MultiIndex.from_product([treatments, genes])
|
|
161
|
+
lookup = pd.Series(values.ravel(), index=mask_index)
|
|
162
|
+
|
|
163
|
+
aligned = lookup.reindex(result_keys)
|
|
164
|
+
if aligned.isna().any():
|
|
165
|
+
missing = result_keys[aligned.isna().to_numpy()].unique().tolist()
|
|
166
|
+
raise ValueError(
|
|
167
|
+
'test_mask is missing treatment-gene tests required by results: '
|
|
168
|
+
f'{missing[:5]}'
|
|
169
|
+
)
|
|
170
|
+
return aligned.to_numpy(dtype=bool)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _coerce_named_matrix(values, names, name, generated_prefix):
|
|
174
|
+
if isinstance(values, pd.DataFrame):
|
|
175
|
+
if names is None:
|
|
176
|
+
names = list(values.columns)
|
|
177
|
+
values = values.to_numpy()
|
|
178
|
+
else:
|
|
179
|
+
values = np.asarray(values)
|
|
180
|
+
if values.ndim == 1:
|
|
181
|
+
values = values[:, None]
|
|
182
|
+
if values.ndim != 2:
|
|
183
|
+
raise ValueError(f'{name} must be one- or two-dimensional')
|
|
184
|
+
if names is None:
|
|
185
|
+
names = [f'{generated_prefix}{j + 1}' for j in range(values.shape[1])]
|
|
186
|
+
names = list(names)
|
|
187
|
+
if len(names) != values.shape[1]:
|
|
188
|
+
raise ValueError(f'{name} names must match the number of columns')
|
|
189
|
+
if len(set(names)) != len(names):
|
|
190
|
+
raise ValueError(f'{name} names must be unique')
|
|
191
|
+
return values, names
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def summarize_treatment_associations(
|
|
195
|
+
A, Z, treatment_names=None, covariate_names=None, covariate_types=None,
|
|
196
|
+
bh_scope='global',
|
|
197
|
+
):
|
|
198
|
+
"""Summarize covariate association with each treatment.
|
|
199
|
+
|
|
200
|
+
Each treatment is compared with shared all-zero controls; rows assigned to
|
|
201
|
+
other treatments are excluded. Spearman p-values are adjusted with the
|
|
202
|
+
Benjamini-Hochberg procedure over the family selected by ``bh_scope``. The
|
|
203
|
+
returned statistics are diagnostics and do not automatically identify
|
|
204
|
+
variables that should be removed from an adjustment set.
|
|
205
|
+
|
|
206
|
+
Parameters
|
|
207
|
+
----------
|
|
208
|
+
A : array-like, shape (n, a)
|
|
209
|
+
Binary treatment indicator matrix. DataFrame column names are retained.
|
|
210
|
+
Z : array-like, shape (n, q)
|
|
211
|
+
Observed covariates and/or estimated latent factors to diagnose.
|
|
212
|
+
treatment_names : sequence, optional
|
|
213
|
+
Treatment labels. Inferred from a DataFrame or generated when omitted.
|
|
214
|
+
covariate_names : sequence, optional
|
|
215
|
+
Covariate labels. Inferred from a DataFrame or generated when omitted.
|
|
216
|
+
covariate_types : sequence, optional
|
|
217
|
+
Labels such as ``'observed'`` and ``'latent'``. Defaults to
|
|
218
|
+
``'observed'`` for every column.
|
|
219
|
+
bh_scope : {'global', 'per_treatment'}, optional
|
|
220
|
+
Multiple-testing family. ``'global'`` (the default) adjusts across every
|
|
221
|
+
finite treatment-by-covariate test at once, which with many treatments
|
|
222
|
+
is a large and correspondingly conservative family.
|
|
223
|
+
``'per_treatment'`` adjusts within each treatment's own block, which is
|
|
224
|
+
the right choice when each treatment is screened on its own.
|
|
225
|
+
|
|
226
|
+
Returns
|
|
227
|
+
-------
|
|
228
|
+
summary : DataFrame
|
|
229
|
+
Long-form table containing ``spearman_rho``, ``pvalue``, BH-adjusted
|
|
230
|
+
``padj``, ``n_tests_in_family``, and ``standardized_mean_difference``
|
|
231
|
+
for every treatment-by-covariate pair. ``n_tests_in_family`` records how
|
|
232
|
+
many tests entered that row's adjustment, so the correction is
|
|
233
|
+
self-describing.
|
|
234
|
+
|
|
235
|
+
.. versionadded:: 0.0.9
|
|
236
|
+
"""
|
|
237
|
+
if bh_scope not in ('global', 'per_treatment'):
|
|
238
|
+
raise ValueError("bh_scope must be 'global' or 'per_treatment'")
|
|
239
|
+
A, treatment_names = _coerce_named_matrix(
|
|
240
|
+
A, treatment_names, 'treatment', 'treatment_',
|
|
241
|
+
)
|
|
242
|
+
Z, covariate_names = _coerce_named_matrix(
|
|
243
|
+
Z, covariate_names, 'covariate', 'covariate_',
|
|
244
|
+
)
|
|
245
|
+
if A.shape[0] != Z.shape[0]:
|
|
246
|
+
raise ValueError('A and Z must have the same number of rows')
|
|
247
|
+
if not np.all(np.isin(A, (0, 1))):
|
|
248
|
+
raise ValueError('A must contain only binary treatment indicators')
|
|
249
|
+
try:
|
|
250
|
+
Z = np.asarray(Z, dtype=float)
|
|
251
|
+
except (TypeError, ValueError) as exc:
|
|
252
|
+
raise ValueError('Z must contain numeric covariates') from exc
|
|
253
|
+
if not np.all(np.isfinite(Z)):
|
|
254
|
+
raise ValueError('Z must contain only finite values')
|
|
255
|
+
|
|
256
|
+
if covariate_types is None:
|
|
257
|
+
covariate_types = ['observed'] * Z.shape[1]
|
|
258
|
+
covariate_types = list(covariate_types)
|
|
259
|
+
if len(covariate_types) != Z.shape[1]:
|
|
260
|
+
raise ValueError('covariate_types must match the number of covariates')
|
|
261
|
+
|
|
262
|
+
ctrl = np.sum(A, axis=1) == 0
|
|
263
|
+
if not np.any(ctrl):
|
|
264
|
+
raise ValueError('At least one all-zero control row is required')
|
|
265
|
+
|
|
266
|
+
rows = []
|
|
267
|
+
for j, treatment in enumerate(treatment_names):
|
|
268
|
+
case = A[:, j] == 1
|
|
269
|
+
if not np.any(case):
|
|
270
|
+
raise ValueError(f'Treatment {treatment} has no treated rows')
|
|
271
|
+
eligible = ctrl | case
|
|
272
|
+
y = A[eligible, j]
|
|
273
|
+
for k, covariate in enumerate(covariate_names):
|
|
274
|
+
values = Z[eligible, k]
|
|
275
|
+
is_constant = bool(np.ptp(values) == 0)
|
|
276
|
+
if is_constant:
|
|
277
|
+
rho, pvalue = np.nan, np.nan
|
|
278
|
+
else:
|
|
279
|
+
rho, pvalue = stats.spearmanr(values, y)
|
|
280
|
+
|
|
281
|
+
control_values = Z[ctrl, k]
|
|
282
|
+
treated_values = Z[case, k]
|
|
283
|
+
if len(control_values) < 2 or len(treated_values) < 2:
|
|
284
|
+
smd = np.nan
|
|
285
|
+
else:
|
|
286
|
+
pooled_sd = np.sqrt(
|
|
287
|
+
(np.var(control_values, ddof=1)
|
|
288
|
+
+ np.var(treated_values, ddof=1)) / 2
|
|
289
|
+
)
|
|
290
|
+
if pooled_sd == 0:
|
|
291
|
+
smd = np.nan
|
|
292
|
+
else:
|
|
293
|
+
smd = (
|
|
294
|
+
np.mean(treated_values) - np.mean(control_values)
|
|
295
|
+
) / pooled_sd
|
|
296
|
+
rows.append({
|
|
297
|
+
'treatment': treatment,
|
|
298
|
+
'covariate': covariate,
|
|
299
|
+
'covariate_type': covariate_types[k],
|
|
300
|
+
'n_control': int(np.sum(ctrl)),
|
|
301
|
+
'n_treated': int(np.sum(case)),
|
|
302
|
+
'spearman_rho': float(rho),
|
|
303
|
+
'pvalue': float(pvalue),
|
|
304
|
+
'padj': np.nan,
|
|
305
|
+
'standardized_mean_difference': float(smd),
|
|
306
|
+
'constant': is_constant,
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
summary = pd.DataFrame(rows)
|
|
310
|
+
summary['n_tests_in_family'] = 0
|
|
311
|
+
finite = np.isfinite(summary['pvalue'].to_numpy())
|
|
312
|
+
if bh_scope == 'global':
|
|
313
|
+
families = [np.ones(len(summary), dtype=bool)]
|
|
314
|
+
else:
|
|
315
|
+
families = [
|
|
316
|
+
(summary['treatment'] == treatment).to_numpy()
|
|
317
|
+
for treatment in treatment_names
|
|
318
|
+
]
|
|
319
|
+
for family in families:
|
|
320
|
+
adjust = family & finite
|
|
321
|
+
summary.loc[family, 'n_tests_in_family'] = int(adjust.sum())
|
|
322
|
+
if np.any(adjust):
|
|
323
|
+
summary.loc[adjust, 'padj'] = multipletests(
|
|
324
|
+
summary.loc[adjust, 'pvalue'], method='fdr_bh',
|
|
325
|
+
)[1]
|
|
326
|
+
return summary
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def plot_treatment_associations(
|
|
330
|
+
summary, value='spearman_rho', treatments=None, covariates=None,
|
|
331
|
+
alpha=None, vmax=None, ax=None,
|
|
332
|
+
):
|
|
333
|
+
"""Plot a heatmap of treatment-by-covariate associations.
|
|
334
|
+
|
|
335
|
+
Parameters
|
|
336
|
+
----------
|
|
337
|
+
summary : DataFrame
|
|
338
|
+
Output from :func:`summarize_treatment_associations`.
|
|
339
|
+
value : {'spearman_rho', 'standardized_mean_difference'}, optional
|
|
340
|
+
Statistic displayed in the heatmap.
|
|
341
|
+
treatments, covariates : sequence, optional
|
|
342
|
+
Ordered subsets to display.
|
|
343
|
+
alpha : float or None, optional
|
|
344
|
+
If provided, mark cells whose adjusted p-value is at most ``alpha``. No
|
|
345
|
+
significance threshold is applied by default.
|
|
346
|
+
vmax : float or None, optional
|
|
347
|
+
Symmetric colour limit, so the heatmap spans ``(-vmax, vmax)``. When
|
|
348
|
+
omitted, ``'spearman_rho'`` uses the fixed range ``(-1, 1)`` given by
|
|
349
|
+
its natural bounds, which keeps panels comparable across subsets, while
|
|
350
|
+
the unbounded ``'standardized_mean_difference'`` scales to the largest
|
|
351
|
+
finite magnitude on display.
|
|
352
|
+
ax : matplotlib Axes, optional
|
|
353
|
+
Axes on which to draw the heatmap.
|
|
354
|
+
|
|
355
|
+
Returns
|
|
356
|
+
-------
|
|
357
|
+
fig, ax : matplotlib Figure and Axes
|
|
358
|
+
The populated figure and axes.
|
|
359
|
+
|
|
360
|
+
.. versionadded:: 0.0.9
|
|
361
|
+
"""
|
|
362
|
+
if value not in ('spearman_rho', 'standardized_mean_difference'):
|
|
363
|
+
raise ValueError(
|
|
364
|
+
"value must be 'spearman_rho' or 'standardized_mean_difference'"
|
|
365
|
+
)
|
|
366
|
+
if not isinstance(summary, pd.DataFrame):
|
|
367
|
+
raise ValueError('summary must be a pandas DataFrame')
|
|
368
|
+
required = {'treatment', 'covariate', value, 'padj'}
|
|
369
|
+
missing = required.difference(summary.columns)
|
|
370
|
+
if missing:
|
|
371
|
+
raise ValueError(f'summary is missing required columns: {sorted(missing)}')
|
|
372
|
+
if alpha is not None and not 0 < alpha <= 1:
|
|
373
|
+
raise ValueError('alpha must be None or lie in (0, 1]')
|
|
374
|
+
if vmax is not None and not (np.isfinite(vmax) and vmax > 0):
|
|
375
|
+
raise ValueError('vmax must be None or a finite positive number')
|
|
376
|
+
|
|
377
|
+
all_treatments = list(dict.fromkeys(summary['treatment']))
|
|
378
|
+
all_covariates = list(dict.fromkeys(summary['covariate']))
|
|
379
|
+
treatments = all_treatments if treatments is None else list(treatments)
|
|
380
|
+
covariates = all_covariates if covariates is None else list(covariates)
|
|
381
|
+
unknown_treatments = set(treatments).difference(all_treatments)
|
|
382
|
+
unknown_covariates = set(covariates).difference(all_covariates)
|
|
383
|
+
if unknown_treatments:
|
|
384
|
+
raise ValueError(f'Unknown treatments: {sorted(unknown_treatments)}')
|
|
385
|
+
if unknown_covariates:
|
|
386
|
+
raise ValueError(f'Unknown covariates: {sorted(unknown_covariates)}')
|
|
387
|
+
if not treatments or not covariates:
|
|
388
|
+
raise ValueError('At least one treatment and covariate must be selected')
|
|
389
|
+
|
|
390
|
+
subset = summary[
|
|
391
|
+
summary['treatment'].isin(treatments)
|
|
392
|
+
& summary['covariate'].isin(covariates)
|
|
393
|
+
]
|
|
394
|
+
if subset.duplicated(['treatment', 'covariate']).any():
|
|
395
|
+
raise ValueError('summary contains duplicate treatment-covariate pairs')
|
|
396
|
+
matrix = subset.pivot(
|
|
397
|
+
index='treatment', columns='covariate', values=value,
|
|
398
|
+
).reindex(index=treatments, columns=covariates)
|
|
399
|
+
adjusted = subset.pivot(
|
|
400
|
+
index='treatment', columns='covariate', values='padj',
|
|
401
|
+
).reindex(index=treatments, columns=covariates)
|
|
402
|
+
|
|
403
|
+
if ax is None:
|
|
404
|
+
fig, ax = plt.subplots(
|
|
405
|
+
figsize=(max(6, 0.7 * len(covariates)),
|
|
406
|
+
max(3, 0.3 * len(treatments))),
|
|
407
|
+
)
|
|
408
|
+
else:
|
|
409
|
+
fig = ax.figure
|
|
410
|
+
values = matrix.to_numpy(dtype=float)
|
|
411
|
+
if vmax is not None:
|
|
412
|
+
limit = float(vmax)
|
|
413
|
+
elif value == 'spearman_rho':
|
|
414
|
+
limit = 1.0
|
|
415
|
+
else:
|
|
416
|
+
finite_values = np.abs(values[np.isfinite(values)])
|
|
417
|
+
limit = float(np.max(finite_values)) if finite_values.size else 1.0
|
|
418
|
+
if limit == 0:
|
|
419
|
+
limit = 1.0
|
|
420
|
+
image = ax.imshow(values, aspect='auto', cmap='coolwarm',
|
|
421
|
+
vmin=-limit, vmax=limit)
|
|
422
|
+
ax.set_xticks(np.arange(len(covariates)))
|
|
423
|
+
ax.set_xticklabels(covariates, rotation=45, ha='right')
|
|
424
|
+
ax.set_yticks(np.arange(len(treatments)))
|
|
425
|
+
ax.set_yticklabels(treatments)
|
|
426
|
+
ax.set_xlabel('Covariate or latent factor')
|
|
427
|
+
ax.set_ylabel('Treatment')
|
|
428
|
+
colorbar = fig.colorbar(image, ax=ax)
|
|
429
|
+
colorbar.set_label(
|
|
430
|
+
'Spearman correlation' if value == 'spearman_rho'
|
|
431
|
+
else 'Standardized mean difference'
|
|
432
|
+
)
|
|
433
|
+
if alpha is not None:
|
|
434
|
+
padj = adjusted.to_numpy(dtype=float)
|
|
435
|
+
for row, col in np.argwhere(np.isfinite(padj) & (padj <= alpha)):
|
|
436
|
+
ax.text(col, row, '*', ha='center', va='center', color='black')
|
|
437
|
+
fig.tight_layout()
|
|
438
|
+
return fig, ax
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _coerce_treatment_inputs(A, pi_hat, treatment_names=None):
|
|
442
|
+
if isinstance(A, pd.DataFrame):
|
|
443
|
+
if treatment_names is None:
|
|
444
|
+
treatment_names = list(A.columns)
|
|
445
|
+
A = A.values
|
|
446
|
+
else:
|
|
447
|
+
A = np.asarray(A)
|
|
448
|
+
if A.ndim == 1:
|
|
449
|
+
A = A[:, None]
|
|
450
|
+
|
|
451
|
+
pi_hat = np.asarray(pi_hat, dtype=float)
|
|
452
|
+
if pi_hat.ndim == 1:
|
|
453
|
+
pi_hat = pi_hat[:, None]
|
|
454
|
+
if A.shape != pi_hat.shape:
|
|
455
|
+
raise ValueError('A and pi_hat must have the same shape')
|
|
456
|
+
if not np.all(np.isin(A, (0, 1))):
|
|
457
|
+
raise ValueError('A must contain only binary treatment indicators')
|
|
458
|
+
if np.any(~np.isfinite(pi_hat)) or np.any((pi_hat < 0) | (pi_hat > 1)):
|
|
459
|
+
raise ValueError('pi_hat must contain finite probabilities in [0, 1]')
|
|
460
|
+
|
|
461
|
+
if treatment_names is None:
|
|
462
|
+
treatment_names = list(range(A.shape[1]))
|
|
463
|
+
if len(treatment_names) != A.shape[1]:
|
|
464
|
+
raise ValueError('treatment_names must match the number of treatments')
|
|
465
|
+
return A, pi_hat, list(treatment_names)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _effective_sample_size(weights):
|
|
469
|
+
weights = np.asarray(weights, dtype=float)
|
|
470
|
+
denom = np.sum(weights ** 2)
|
|
471
|
+
return float(np.sum(weights) ** 2 / denom) if denom > 0 else np.nan
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def summarize_propensity_scores(
|
|
475
|
+
A, pi_hat, treatment_names=None, overlap_bounds=(0.05, 0.95),
|
|
476
|
+
clip_bounds=(0.01, 0.99), bins=40,
|
|
477
|
+
):
|
|
478
|
+
"""Summarize overlap and inverse-weight stability for each treatment.
|
|
479
|
+
|
|
480
|
+
Other perturbations are excluded from a treatment's diagnostic comparison;
|
|
481
|
+
each row compares that treatment with shared all-zero controls.
|
|
482
|
+
|
|
483
|
+
Parameters
|
|
484
|
+
----------
|
|
485
|
+
A, pi_hat, treatment_names, overlap_bounds, bins
|
|
486
|
+
Treatments, scores, labels, the reference overlap window, and the
|
|
487
|
+
histogram resolution used for ``overlap_ratio``.
|
|
488
|
+
clip_bounds : tuple(float, float) or None, optional
|
|
489
|
+
The bounds the caller **already applied** to ``pi_hat``, defaulting to
|
|
490
|
+
the ``LFC(ps_clip=(0.01, 0.99))`` default. ``clipped_fraction`` counts
|
|
491
|
+
scores sitting on those bounds. Pass ``None`` for raw, unclipped scores;
|
|
492
|
+
``clipped_fraction`` is then ``NaN`` rather than a misleading ``0.0``,
|
|
493
|
+
since clipping cannot be inferred from the values alone.
|
|
494
|
+
|
|
495
|
+
.. versionchanged:: 0.0.9
|
|
496
|
+
``clip_bounds=None`` reports ``NaN`` instead of ``0.0``.
|
|
497
|
+
"""
|
|
498
|
+
A, pi_hat, treatment_names = _coerce_treatment_inputs(
|
|
499
|
+
A, pi_hat, treatment_names)
|
|
500
|
+
lo, hi = overlap_bounds
|
|
501
|
+
if not 0 <= lo < hi <= 1:
|
|
502
|
+
raise ValueError('overlap_bounds must satisfy 0 <= lower < upper <= 1')
|
|
503
|
+
if bins < 2:
|
|
504
|
+
raise ValueError('bins must be at least 2')
|
|
505
|
+
|
|
506
|
+
ctrl = np.sum(A, axis=1) == 0
|
|
507
|
+
if not np.any(ctrl):
|
|
508
|
+
raise ValueError('At least one all-zero control row is required')
|
|
509
|
+
rows = []
|
|
510
|
+
for j, name in enumerate(treatment_names):
|
|
511
|
+
case = A[:, j] == 1
|
|
512
|
+
if not np.any(case):
|
|
513
|
+
raise ValueError(f'Treatment {name} has no treated rows')
|
|
514
|
+
eligible = ctrl | case
|
|
515
|
+
y = A[eligible, j].astype(int)
|
|
516
|
+
p = pi_hat[eligible, j]
|
|
517
|
+
p_ctrl, p_case = p[y == 0], p[y == 1]
|
|
518
|
+
|
|
519
|
+
h_ctrl, edges = np.histogram(p_ctrl, bins=bins, range=(0, 1))
|
|
520
|
+
h_case, _ = np.histogram(p_case, bins=edges)
|
|
521
|
+
h_ctrl = h_ctrl / h_ctrl.sum() if h_ctrl.sum() else h_ctrl
|
|
522
|
+
h_case = h_case / h_case.sum() if h_case.sum() else h_case
|
|
523
|
+
overlap_ratio = float(np.minimum(h_ctrl, h_case).sum())
|
|
524
|
+
|
|
525
|
+
eps = np.finfo(float).eps
|
|
526
|
+
ess_ctrl = _effective_sample_size(1 / np.clip(1 - p_ctrl, eps, None))
|
|
527
|
+
ess_case = _effective_sample_size(1 / np.clip(p_case, eps, None))
|
|
528
|
+
if clip_bounds is None:
|
|
529
|
+
clipped_fraction = np.nan
|
|
530
|
+
else:
|
|
531
|
+
clipped = (
|
|
532
|
+
np.isclose(p, clip_bounds[0]) | np.isclose(p, clip_bounds[1])
|
|
533
|
+
)
|
|
534
|
+
clipped_fraction = float(clipped.mean())
|
|
535
|
+
|
|
536
|
+
rows.append({
|
|
537
|
+
'treatment': name,
|
|
538
|
+
'n_control': int((y == 0).sum()),
|
|
539
|
+
'n_treated': int((y == 1).sum()),
|
|
540
|
+
'prevalence': float(y.mean()),
|
|
541
|
+
'overlap_ratio': overlap_ratio,
|
|
542
|
+
'auc': float(roc_auc_score(y, p)),
|
|
543
|
+
'brier_score': float(brier_score_loss(y, p)),
|
|
544
|
+
'outside_overlap_fraction': float(np.mean((p < lo) | (p > hi))),
|
|
545
|
+
'clipped_fraction': clipped_fraction,
|
|
546
|
+
'ess_control': ess_ctrl,
|
|
547
|
+
'ess_treated': ess_case,
|
|
548
|
+
'ess_control_fraction': ess_ctrl / max(len(p_ctrl), 1),
|
|
549
|
+
'ess_treated_fraction': ess_case / max(len(p_case), 1),
|
|
550
|
+
'score_q01': float(np.quantile(p, 0.01)),
|
|
551
|
+
'score_median': float(np.median(p)),
|
|
552
|
+
'score_q99': float(np.quantile(p, 0.99)),
|
|
553
|
+
})
|
|
554
|
+
return pd.DataFrame(rows)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def plot_propensity_scores(
|
|
558
|
+
A, pi_hat, treatments=None, treatment_names=None, overlap_bounds=(0.05, 0.95),
|
|
559
|
+
bins=40, max_panels=4, axes=None, clip_bounds=(0.01, 0.99),
|
|
560
|
+
):
|
|
561
|
+
"""Plot propensity distributions for treatment and control cells.
|
|
562
|
+
|
|
563
|
+
``clip_bounds`` is forwarded to :func:`summarize_propensity_scores` for the
|
|
564
|
+
returned summary; pass ``None`` when ``pi_hat`` holds raw, unclipped scores.
|
|
565
|
+
|
|
566
|
+
.. versionchanged:: 0.0.9
|
|
567
|
+
``clip_bounds`` is no longer fixed at its default.
|
|
568
|
+
"""
|
|
569
|
+
A, pi_hat, treatment_names = _coerce_treatment_inputs(
|
|
570
|
+
A, pi_hat, treatment_names)
|
|
571
|
+
summary = summarize_propensity_scores(
|
|
572
|
+
A, pi_hat, treatment_names=treatment_names,
|
|
573
|
+
overlap_bounds=overlap_bounds, clip_bounds=clip_bounds, bins=bins,
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
if treatments is None:
|
|
577
|
+
indices = list(range(min(A.shape[1], max_panels)))
|
|
578
|
+
else:
|
|
579
|
+
indices = []
|
|
580
|
+
for treatment in treatments:
|
|
581
|
+
if treatment in treatment_names:
|
|
582
|
+
indices.append(treatment_names.index(treatment))
|
|
583
|
+
else:
|
|
584
|
+
index = int(treatment)
|
|
585
|
+
if index < 0 or index >= A.shape[1]:
|
|
586
|
+
raise ValueError(f'Unknown treatment: {treatment}')
|
|
587
|
+
indices.append(index)
|
|
588
|
+
if not indices:
|
|
589
|
+
raise ValueError('At least one treatment must be selected')
|
|
590
|
+
|
|
591
|
+
if axes is None:
|
|
592
|
+
ncols = min(2, len(indices))
|
|
593
|
+
nrows = math.ceil(len(indices) / ncols)
|
|
594
|
+
fig, axes = plt.subplots(
|
|
595
|
+
nrows, ncols, figsize=(5 * ncols, 3.5 * nrows), squeeze=False,
|
|
596
|
+
)
|
|
597
|
+
axes_flat = axes.ravel()
|
|
598
|
+
else:
|
|
599
|
+
axes_flat = np.asarray(axes, dtype=object).ravel()
|
|
600
|
+
if len(axes_flat) < len(indices):
|
|
601
|
+
raise ValueError('Not enough axes for the selected treatments')
|
|
602
|
+
fig = axes_flat[0].figure
|
|
603
|
+
|
|
604
|
+
ctrl = np.sum(A, axis=1) == 0
|
|
605
|
+
for ax, j in zip(axes_flat, indices):
|
|
606
|
+
case = A[:, j] == 1
|
|
607
|
+
ax.hist(
|
|
608
|
+
pi_hat[ctrl, j], bins=bins, range=(0, 1), density=True,
|
|
609
|
+
histtype='step', linewidth=1.6, label='control', color='#4c78a8',
|
|
610
|
+
)
|
|
611
|
+
ax.hist(
|
|
612
|
+
pi_hat[case, j], bins=bins, range=(0, 1), density=True,
|
|
613
|
+
histtype='step', linewidth=1.6, label=str(treatment_names[j]),
|
|
614
|
+
color='#e45756',
|
|
615
|
+
)
|
|
616
|
+
ax.axvline(overlap_bounds[0], color='#777777', linestyle='--', linewidth=0.8)
|
|
617
|
+
ax.axvline(overlap_bounds[1], color='#777777', linestyle='--', linewidth=0.8)
|
|
618
|
+
overlap = summary.loc[j, 'overlap_ratio']
|
|
619
|
+
ess = summary.loc[j, 'ess_treated_fraction']
|
|
620
|
+
ax.set_title(f'{treatment_names[j]} | overlap={overlap:.2f}, ESS={ess:.2f}')
|
|
621
|
+
ax.set_xlim(0, 1)
|
|
622
|
+
ax.set_xlabel('Estimated propensity score')
|
|
623
|
+
ax.set_ylabel('Density')
|
|
624
|
+
ax.legend(frameon=False)
|
|
625
|
+
for ax in axes_flat[len(indices):]:
|
|
626
|
+
ax.set_visible(False)
|
|
627
|
+
fig.tight_layout()
|
|
628
|
+
return fig, axes, summary
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.0.8"
|
|
@@ -1,172 +0,0 @@
|
|
|
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
|
|
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
|
|
File without changes
|