-
Notifications
You must be signed in to change notification settings - Fork 33
Expand file tree
/
Copy pathTMLE.py
More file actions
1343 lines (1134 loc) · 65.6 KB
/
Copy pathTMLE.py
File metadata and controls
1343 lines (1134 loc) · 65.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import copy
import warnings
import patsy
import numpy as np
import statsmodels.api as sm
import statsmodels.formula.api as smf
import matplotlib.pyplot as plt
from scipy.stats import logistic, norm
import pandas as pd
from zepid.causal.utils import propensity_score, stochastic_check_conditional
from zepid.causal.doublyrobust.utils import tmle_unit_bounds, tmle_unit_unbound
from zepid.calc import probability_to_odds, odds_to_probability, probability_bounds
from zepid.causal.utils import (exposure_machine_learner, outcome_machine_learner, stochastic_outcome_machine_learner,
stochastic_outcome_predict, missing_machine_learner, plot_kde, plot_love,
standardized_mean_differences, positivity, plot_kde_accuracy, outcome_accuracy,
check_input_data)
class TMLE:
r"""Implementation of target maximum likelihood estimator. This implementation calculates TMLE for a
time-fixed exposure and a single time-point outcome. By default standard parametric regression models are used to
calculate the estimate of interest. The TMLE estimator allows users to instead use machine learning algorithms
from sklearn and PyGAM.
Note
----
Valid confidence intervals are only attainable with certain machine learning algorithms. These algorithms must be
Donsker class for valid confidence intervals. GAM and LASSO are examples of alogorithms that are Donsker class
Note
----
TMLE is a doubly-robust substitution estimator. TMLE obtains the target estimate in a single step. The
single-step TMLE is described further by van der Laan. For further details, see the listed references.
Continuous outcomes must be bounded between 0 and 1. TMLE does this automatically for the user. Additionally,
the average treatment effect is estimate is back converted to the original scale of Y. When scaling Y as Y*,
some values may take the value of 0 or 1, which breaks a logit(Y*) transformation. To avoid this issue, Y* is
bounded by the `continuous_bound` argument. The default is 0.0005, the same as R's tmle
The following is a general outline of the estimation process for TMLE
1. Initial estimates for Y are predicted from a regression model. Expected values for each individual are
generated under the scenarios of all treated vs all untreated
.. math::
E(Y|A, L)
2. Predicted probabilities are generated from a regression model
.. math::
\pi_1 = \Pr(A=1|L)
3. The 'clever covariate' is calculated by
.. math::
H_a(A=a,L) = \frac{I(A=1)}{\pi_1} - \frac{I(A=0)}{\pi_0}
for each individual. Afterwards, the predicted Y is set as an offset in the following logit model and used to
predict values under each treatment strategy after fitted
.. math::
\text{logit}(E(Y|A,L)) = \text{logit}(Y_a) + \sigma H_a
4. The targeted Psi is estimated, representing the causal effect of all treated vs. all untreated
Confidence intervals are constructed using influence curves.
Parameters
----------
df : DataFrame
Pandas dataframe containing the variables of interest
exposure : str
Column label for the exposure of interest
outcome : str
Column label for the outcome of interest
alpha : float, optional
Alpha for confidence interval level. Default is 0.05
continuous_bound : float, optional
Optional argument to control the bounding feature for continuous outcomes. The bounding process may result
in values of 0,1 which are undefined for logit(x). This parameter adds or substracts from the scenarios of
0,1 respectively. Default value is 0.0005
target_gwt: bool, optional
- target_gwt = True: use the "clever covariet" by weighting. This seems to be the default version in R and yields more stable result.
- target_gwt = False, the "clever covariet" will be include as covariate in the model. This is the older version in R. (We have seem some unstable result under this setting.)
Examples
--------
Setting up environment
>>> from zepid import load_sample_data, spline
>>> from zepid.causal.doublyrobust import TMLE
>>> df = load_sample_data(False).dropna()
>>> df[['cd4_rs1', 'cd4_rs2']] = spline(df, 'cd40', n_knots=3, term=2, restricted=True)
Estimating TMLE using logistic regression
>>> tmle = TMLE(df, exposure='art', outcome='dead')
>>> # Specifying exposure/treatment model
>>> tmle.exposure_model('male + age0 + cd40 + cd4_rs1 + cd4_rs2 + dvl0')
>>> # Specifying outcome model
>>> tmle.outcome_model('art + male + age0 + cd40 + cd4_rs1 + cd4_rs2 + dvl0')
>>> # TMLE estimation procedure
>>> tmle.fit()
>>> # Printing main results
>>> tmle.summary()
>>> # Extracting risk difference and confidence intervals, respectively
>>> tmle.risk_difference
>>> tmle.risk_difference_ci
Estimating TMLE with machine learning algorithm from sklearn
>>> from sklearn.linear_model import LogisticRegression
>>> log1 = LogisticRegression(penalty='l1', random_state=201)
>>> tmle = TMLE(df, 'art', 'dead')
>>> # custom_model allows specification of machine learning algorithms
>>> tmle.exposure_model('male + age0 + cd40 + cd4_rs1 + cd4_rs2 + dvl0', custom_model=log1)
>>> tmle.outcome_model('male + age0 + cd40 + cd4_rs1 + cd4_rs2 + dvl0', custom_model=log1)
>>> tmle.fit()
Demonstration of estimating g-model with symmetric bounds
>>> tmle.exposure_model('male + age0 + cd40 + cd4_rs1 + cd4_rs2 + dvl0', bound=0.05)
Demonstration of estimating g-model with asymmetric bounds
>>> tmle.exposure_model('male + age0 + cd40 + cd4_rs1 + cd4_rs2 + dvl0', bound=[0.05, 0.9])
References
----------
Schuler MS, and Sherri R. "Targeted maximum likelihood estimation for causal inference in
observational studies." American journal of epidemiology 185.1 (2017): 65-73.
Van der Laan, MJ, and Sherri R. Targeted learning: causal inference for observational and experimental
data. Springer Science & Business Media, 2011.
Van Der Laan, MJ, Rubin D. "Targeted maximum likelihood learning." The International Journal of
Biostatistics 2.1 (2006).
Gruber S, van der Laan, MJ. (2011). tmle: An R package for targeted maximum likelihood estimation.
"""
def __init__(self, df, exposure, outcome, alpha=0.05, continuous_bound=0.0005, target_gwt = True):
self.exposure = exposure
self.outcome = outcome
self._missing_indicator = '__missing_indicator__'
self.df, self._miss_flag, self._continuous_outcome = check_input_data(data=df,
exposure=exposure,
outcome=outcome,
estimator="TMLE",
drop_censoring=False,
drop_missing=True,
binary_exposure_only=True)
# Detailed steps follow "Targeted Learning" chapter 4, figure 4.2 by van der Laan, Rose
if self._continuous_outcome:
self._continuous_min = np.min(self.df[outcome])
self._continuous_max = np.max(self.df[outcome])
self._cb = continuous_bound
self.df[outcome] = tmle_unit_bounds(y=self.df[outcome], mini=self._continuous_min,
maxi=self._continuous_max, bound=self._cb)
else:
self._cb = 0.0
self._out_model = None
self._exp_model = None
self._miss_model = None
self._out_model_custom = False
self._exp_model_custom = False
self._miss_model_custom = False
self._fit_exposure_model = False
self._fit_outcome_model = False
self._fit_missing_model = False
self.alpha = alpha
self.target_gwt = target_gwt
self.QA0W = None
self.QA1W = None
self.QAW = None
self.g1W = None
self.g0W = None
self.m1W = None
self.m0W = None
self._epsilon = None
self.risk_difference = None
self.risk_difference_ci = None
self.risk_difference_se = None
self.risk_ratio = None
self.risk_ratio_ci = None
self.risk_ratio_se = None
self.odds_ratio = None
self.odds_ratio_ci = None
self.odds_ratio_se = None
self.average_treatment_effect = None
self.average_treatment_effect_ci = None
self.average_treatment_effect_se = None
def exposure_model(self, model, custom_model=None, bound=False, print_results=True):
"""Estimation of Pr(A=1|L), which is termed as g(A=1|L) in the literature
Parameters
----------
model : str
Independent variables to predict the exposure. Example) 'var1 + var2 + var3'
custom_model : optional
Input for a custom model that is used in place of the logit model (default). The model must have the
"fit()" and "predict()" attributes. SciKit-Learn style models are supported as custom models. In the
background, TMLE will fit the custom model and generate the predicted probablities
bound : float, list, optional
Value between 0,1 to truncate predicted probabilities. Helps to avoid near positivity violations.
Specifying this argument can improve finite sample performance for random positivity violations. However,
truncating weights leads to additional confounding. Default is False, meaning no truncation of
predicted probabilities occurs. Providing a single float assumes symmetric trunctation, where values below
or above the threshold are set to the threshold value. Alternatively a list of floats can be provided for
asymmetric trunctation, with the first value being the lower bound and the second being the upper bound
print_results : bool, optional
Whether to print the fitted model results. Default is True (prints results)
"""
self._exp_model = self.exposure + ' ~ ' + model
self.__mweight = model
# Step 3) Estimation of g-model (exposure model)
if custom_model is None:
fitmodel = propensity_score(self.df, self._exp_model, print_results=print_results)
self.g1W = fitmodel.predict(self.df)
# User-specified prediction model
else:
self._exp_model_custom = True
data = patsy.dmatrix(model + ' - 1', self.df)
self.g1W = exposure_machine_learner(xdata=np.asarray(data),
ydata=np.asarray(self.df[self.exposure]),
ml_model=copy.deepcopy(custom_model),
print_results=print_results)
self.g0W = 1 - self.g1W
if bound: # Bounding predicted probabilities if requested
self.g1W = probability_bounds(self.g1W, bounds=bound)
self.g0W = probability_bounds(self.g0W, bounds=bound)
self._fit_exposure_model = True
def missing_model(self, model, custom_model=None, bound=False, print_results=True):
"""Estimation of Pr(M=1|A,L), which is the missing data mechanism for the outcome. The corresponding observation
probabilities are used to update the clever covariates for estimation of Qn.
The initial estimate of Q is still based on complete observations only
Parameters
----------
model : str
Independent variables to predict the exposure. Example) 'var1 + var2 + var3'. The treatment must be
included for the missing data model
custom_model : optional
Input for a custom model that is used in place of the logit model (default). The model must have the
"fit()" and "predict()" attributes. Both sklearn and supylearner are supported as custom models. In the
background, TMLE will fit the custom model and generate the predicted probablities
bound: float, list, optional
Value between 0,1 to truncate predicted probabilities. Helps to avoid near positivity violations.
Specifying this argument can improve finite sample performance for random positivity violations. However,
truncating weights leads to additional confounding. Default is False, meaning no truncation of
predicted probabilities occurs. Providing a single float assumes symmetric trunctation, where values below
or above the threshold are set to the threshold value. Alternatively a list of floats can be provided for
asymmetric trunctation, with the first value being the lower bound and the second being the upper bound
print_results : bool, optional
Whether to print the fitted model results. Default is True (prints results)
"""
# Error if no missing outcome data
if not self._miss_flag:
raise ValueError("No missing outcome data is present in the data set")
# Warning if exposure is not included in the missingness of outcome model
if self.exposure not in model:
warnings.warn("For the specified missing outcome model, the exposure variable should be included in the "
"model", UserWarning)
self._miss_model = self._missing_indicator + ' ~ ' + model
# Step 3b) Prediction for M if missing outcome data exists
if custom_model is None: # Logistic Regression model for predictions
fitmodel = propensity_score(self.df, self._miss_model, print_results=print_results)
dfx = self.df.copy()
dfx[self.exposure] = 1
self.m1W = fitmodel.predict(dfx)
dfx = self.df.copy()
dfx[self.exposure] = 0
self.m0W = fitmodel.predict(dfx)
# User-specified model
else:
self._miss_model_custom = True
data = patsy.dmatrix(model + ' - 1', self.df)
dfx = self.df.copy()
dfx[self.exposure] = 1
adata = patsy.dmatrix(model + ' - 1', dfx)
dfx = self.df.copy()
dfx[self.exposure] = 0
ndata = patsy.dmatrix(model + ' - 1', dfx)
self.m1W, self.m0W = missing_machine_learner(xdata=np.array(data),
mdata=self.df[self._missing_indicator],
all_a=adata, none_a=ndata,
ml_model=copy.deepcopy(custom_model),
print_results=print_results)
if bound: # Bounding predicted probabilities if requested
self.m1W = probability_bounds(self.m1W, bounds=bound)
self.m0W = probability_bounds(self.m0W, bounds=bound)
self._fit_missing_model = True
def outcome_model(self, model, custom_model=None, bound=False, print_results=True,
continuous_distribution='gaussian'):
"""Estimation of E(Y|A,L,M=1), which is also written sometimes as Q(A,W,M=1) or Pr(Y=1|A,W,M=1). Estimation
of this model is based on complete observations of Y only
Parameters
----------
model : str
Independent variables to predict the exposure. Example) 'var1 + var2 + var3'
custom_model : optional
Input for a custom model that is used in place of the logit model (default). The model must have the
"fit()" and "predict()" attributes. Both sklearn and supylearner are supported as custom models. In the
background, TMLE will fit the custom model and generate the predicted values
bound : bool, optional
This argument should ONLY be used if the outcome is continuous. Value between 0,1 to truncate the bounded
predicted outcomes. Default is `False`, meaning no truncation of predicted outcomes occurs (unless a
predicted outcome is outside the bounded continuous outcome). Providing a single float assumes symmetric
trunctation. A list of floats can be provided for asymmetric trunctation.
print_results : bool, optional
Whether to print the fitted model results. Default is True (prints results)
continuous_distribution : str, optional
Distribution to use for continuous outcomes. Options are 'gaussian' for normal distributions and 'poisson'
for Poisson distributions
"""
if self.exposure not in model:
warnings.warn("It looks like '" + self.exposure + "' is not included in the outcome model.")
self._out_model = self.outcome + ' ~ ' + model
if self._miss_flag:
cc = self.df.copy().dropna()
else:
cc = self.df.copy()
# Step 1) Prediction for Q (estimation of Q-model)
if custom_model is None: # Logistic Regression model for predictions
self._continuous_type = continuous_distribution
if self._continuous_outcome:
if (continuous_distribution == 'gaussian') or (continuous_distribution == 'normal'):
f = sm.families.family.Gaussian()
elif continuous_distribution == 'poisson':
f = sm.families.family.Poisson()
else:
raise ValueError("Only 'gaussian' and 'poisson' distributions are supported")
log = smf.glm(self._out_model, cc, family=f).fit()
else:
f = sm.families.family.Binomial()
log = smf.glm(self._out_model, cc, family=f).fit()
if print_results:
print('==============================================================================')
print('Outcome Model')
print(log.summary())
print('==============================================================================')
# Step 2) Estimation under the scenarios
dfx = self.df.copy()
dfx[self.exposure] = 1
self.QA1W = log.predict(dfx)
dfx = self.df.copy()
dfx[self.exposure] = 0
self.QA0W = log.predict(dfx)
# User-specified model
else:
self._out_model_custom = True
data = patsy.dmatrix(model + ' - 1', cc)
dfx = self.df.copy()
dfx[self.exposure] = 1
adata = patsy.dmatrix(model + ' - 1', dfx)
dfx = self.df.copy()
dfx[self.exposure] = 0
ndata = patsy.dmatrix(model + ' - 1', dfx)
self.QA1W, self.QA0W = outcome_machine_learner(xdata=np.asarray(data),
ydata=np.asarray(cc[self.outcome]),
all_a=adata, none_a=ndata,
ml_model=copy.deepcopy(custom_model),
continuous=self._continuous_outcome,
print_results=print_results)
if not bound: # Bounding predicted probabilities if requested
bound = self._cb
# This bounding step prevents continuous outcomes from being outside the range
self.QA1W = probability_bounds(self.QA1W, bounds=bound)
self.QA0W = probability_bounds(self.QA0W, bounds=bound)
self.QAW = self.QA1W * self.df[self.exposure] + self.QA0W * (1 - self.df[self.exposure])
self._fit_outcome_model = True
def fit(self):
"""Calculate the effect measures from the predicted exposure probabilities and predicted outcome values using
the TMLE procedure. Confidence intervals are calculated using influence curves.
Note
----
Exposure and outcome models must be specified prior to `fit()`
Returns
-------
TMLE gains `risk_difference`, `risk_ratio`, and `odds_ratio` for binary outcomes and
`average _treatment_effect` for continuous outcomes
"""
if (self._fit_exposure_model is False) or (self._fit_outcome_model is False):
raise ValueError('The exposure and outcome models must be specified before the psi estimate can '
'be generated')
if self._miss_flag and not self._fit_missing_model:
warnings.warn("No missing data model has been specified. All missing outcome data is assumed to be "
"missing completely at random. To relax this assumption to outcome data is missing at random"
"please use the `missing_model()` function", UserWarning)
# Step 4) Calculating clever covariate (HAW)
if self._miss_flag and self._fit_missing_model:
self.g1W_total = self.g1W * self.m1W
self.g0W_total = self.g0W * self.m0W
else:
self.g1W_total = self.g1W
self.g0W_total = self.g0W
H1W = self.df[self.exposure] / self.g1W_total
self.A = self.df[self.exposure]
if self.target_gwt:
# 4a) if target gwt = TRUE, clever covariate goes to the weighting
wt = self.A/self.g1W_total + (1-self.A)/self.g0W_total
H1W = self.A
H0W = self.A - 1
## for A = 1, H1W, H0W = (1, 0), HAW = 1
## for A = 0, H1W, H0W = (0, -1), HAW = -1
else:
## 4b) if target gwt = FALSE, use cleaver covariate as adjustment (original Zepid)
wt = pd.Series([1] * len(self.A))
H1W = self.A / self.g1W_total
H0W = -(1 - self.A) / self.g0W_total
## for A = 1, HAW = 1/g1w
## for A = 0, HAW = -1/g0W
# Step 5) Estimating TMLE
f = sm.families.family.Binomial()
y = self.df[self.outcome]
log = sm.GLM(
endog = self.y,
exog = np.column_stack((H1W, H0W)),
offset = np.log(probability_to_odds(self.QAW)),
family=f,
missing='drop',
freq_weights = wt).fit()
self._epsilon = log.params
Qstar = log.predict(np.column_stack((H1W, H0W)),
offset=np.log(probability_to_odds(self.QAW)))
if self.target_gwt:
Qstar1 = logistic.cdf(np.log(probability_to_odds(self.QA1W)) + self._epsilon[0])
Qstar0 = logistic.cdf(np.log(probability_to_odds(self.QA0W)) - self._epsilon[1])
else:
Qstar1 = logistic.cdf(np.log(probability_to_odds(self.QA1W)) + self._epsilon[0] / self.g1W_total)
Qstar0 = logistic.cdf(np.log(probability_to_odds(self.QA0W)) - self._epsilon[1] / self.g0W_total)
# Step 6) Calculating Psi
if self.alpha == 0.05: # Without this, won't match R exactly. R relies on 1.96, while I use SciPy
zalpha = 1.96
else:
zalpha = norm.ppf(1 - self.alpha / 2, loc=0, scale=1)
## use the original HAW for IC calculation
H1W = self.A / self.g1W_total
H0W = -(1 - self.A) / self.g0W_total
HAW = H1W + H0W
# p-values are not implemented (doing my part to enforce CL over p-values)
delta = np.where(self.df[self._missing_indicator] == 1, 1, 0)
if self._continuous_outcome:
# Calculating Average Treatment Effect
Qstar = tmle_unit_unbound(Qstar, mini=self._continuous_min, maxi=self._continuous_max)
Qstar1 = tmle_unit_unbound(Qstar1, mini=self._continuous_min, maxi=self._continuous_max)
Qstar0 = tmle_unit_unbound(Qstar0, mini=self._continuous_min, maxi=self._continuous_max)
self.average_treatment_effect = np.nanmean(Qstar1 - Qstar0)
# Influence Curve for CL
y_unbound = tmle_unit_unbound(self.df[self.outcome], mini=self._continuous_min, maxi=self._continuous_max)
ic = np.where(delta == 1,
HAW * (y_unbound - Qstar) + (Qstar1 - Qstar0) - self.average_treatment_effect,
Qstar1 - Qstar0 - self.average_treatment_effect)
seIC = np.sqrt(np.nanvar(ic, ddof=1) / self.df.shape[0])
self.average_treatment_effect_se = seIC
self.average_treatment_effect_ci = [self.average_treatment_effect - zalpha * seIC,
self.average_treatment_effect + zalpha * seIC]
else:
# Calculating Risk Difference
self.risk_difference = np.nanmean(Qstar1 - Qstar0)
# Influence Curve for CL
ic = np.where(delta == 1,
HAW * (self.df[self.outcome] - Qstar) + (Qstar1 - Qstar0) - self.risk_difference,
(Qstar1 - Qstar0) - self.risk_difference)
seIC = np.sqrt(np.nanvar(ic, ddof=1) / self.df.shape[0])
self.risk_difference_se = seIC
self.risk_difference_ci = [self.risk_difference - zalpha * seIC,
self.risk_difference + zalpha * seIC]
# Calculating Risk Ratio
self.risk_ratio = np.nanmean(Qstar1) / np.nanmean(Qstar0)
# Influence Curve for CL
ic = np.where(delta == 1,
(1 / np.mean(Qstar1) * (H1W * (self.df[self.outcome] - Qstar) + Qstar1 - np.mean(Qstar1)) -
(1/np.mean(Qstar0)) * (-1 * H0W * (self.df[self.outcome] - Qstar) + Qstar0 - np.mean(Qstar0))),
(Qstar1 - np.mean(Qstar1)) + Qstar0 - np.mean(Qstar0))
seIC = np.sqrt(np.nanvar(ic, ddof=1) / self.df.shape[0])
self.risk_ratio_se = seIC
self.risk_ratio_ci = [np.exp(np.log(self.risk_ratio) - zalpha * seIC),
np.exp(np.log(self.risk_ratio) + zalpha * seIC)]
# Calculating Odds Ratio
self.odds_ratio = (np.nanmean(Qstar1) / (1 - np.nanmean(Qstar1)
)) / (np.nanmean(Qstar0) / (1 - np.nanmean(Qstar0)))
# Influence Curve for CL
ic = np.where(delta == 1,
((1 / (np.nanmean(Qstar1)*(1 - np.nanmean(Qstar1))) *
(H1W * (self.df[self.outcome] - Qstar) + Qstar1)) -
(1 / (np.nanmean(Qstar0)*(1 - np.nanmean(Qstar0))) *
(-1 * H0W * (self.df[self.outcome] - Qstar) + Qstar0))),
((1 / (np.nanmean(Qstar1) * (1 - np.nanmean(Qstar1))) * Qstar1 -
(1 / (np.nanmean(Qstar0) * (1 - np.nanmean(Qstar0))) * Qstar0))))
seIC = np.sqrt(np.nanvar(ic, ddof=1) / self.df.shape[0])
self.odds_ratio_se = seIC
self.odds_ratio_ci = [np.exp(np.log(self.odds_ratio) - zalpha * seIC),
np.exp(np.log(self.odds_ratio) + zalpha * seIC)]
def summary(self, decimal=3):
"""Prints summary of the estimated average causal effects
Parameters
----------
decimal : int, optional
Number of decimal places to display. Default is 3
"""
if (self._fit_exposure_model is False) or (self._fit_exposure_model is False):
raise ValueError('The exposure and outcome models must be specified before the psi estimate can '
'be generated')
print('======================================================================')
print(' Targeted Maximum Likelihood Estimator ')
print('======================================================================')
fmt = 'Treatment: {:<15} No. Observations: {:<20}'
print(fmt.format(self.exposure, self.df.shape[0]))
fmt = 'Outcome: {:<15} No. Missing Outcome: {:<20}'
print(fmt.format(self.outcome, np.sum(self.df[self.outcome].isnull())))
fmt = 'g-Model: {:<15} Missing Model: {:<20}'
if self._exp_model_custom:
e = 'User-specified'
else:
e = 'Logistic'
if self._miss_model_custom and self._miss_model is not None:
m = 'User-specified'
elif self._miss_model is None:
m = 'None'
else:
m = 'Logistic'
print(fmt.format(e, m))
fmt = 'Q-Model: {:<15}'
if self._out_model_custom:
y = 'User-specified'
elif self._continuous_outcome:
y = self._continuous_type
else:
y = 'Logistic'
print(fmt.format(y))
print('======================================================================')
if self._continuous_outcome:
print('Average Treatment Effect: ', round(float(self.average_treatment_effect), decimal))
print(str(round(100 * (1 - self.alpha), 1)) + '% two-sided CI: (' +
str(round(self.average_treatment_effect_ci[0], decimal)), ',',
str(round(self.average_treatment_effect_ci[1], decimal)) + ')')
else:
print('Risk Difference: ', round(float(self.risk_difference), decimal))
print(str(round(100 * (1 - self.alpha), 1)) + '% two-sided CI: (' +
str(round(self.risk_difference_ci[0], decimal)), ',',
str(round(self.risk_difference_ci[1], decimal)) + ')')
print('----------------------------------------------------------------------')
print('Risk Ratio: ', round(float(self.risk_ratio), decimal))
print(str(round(100 * (1 - self.alpha), 1)) + '% two-sided CI: (' +
str(round(self.risk_ratio_ci[0], decimal)), ',',
str(round(self.risk_ratio_ci[1], decimal)) + ')')
print('----------------------------------------------------------------------')
print('Odds Ratio: ', round(float(self.odds_ratio), decimal))
print(str(round(100 * (1 - self.alpha), 1)) + '% two-sided CI: (' +
str(round(self.odds_ratio_ci[0], decimal)), ',',
str(round(self.odds_ratio_ci[1], decimal)) + ')')
print('======================================================================')
def run_diagnostics(self, decimal=3):
"""Run all currently implemented diagnostics for the exposure and outcome models. Each
`run_diagnostics` provides results for all implemented diagnostics for ease of the user. For publication
quality presentations, I recommend calling each diagnostic function individually and utilizing the optional
parameters
Note
----
The plot presented cannot be edited. To edit the plots, call `plot_kde` or `plot_love` directly. Those
functions return an axes object
Parameters
----------
decimal : int, optional
Number of decimal places to display. Default is 3
Returns
-------
None
"""
if not self._fit_outcome_model or not self._fit_exposure_model:
raise ValueError("The exposure_model and outcome_model function must be ran before any diagnostics")
if self._fit_missing_model:
ps = self.g1W * np.where(self.df[self.exposure] == 1, self.m1W, self.m0W)
else:
ps = self.g1W
# Weight diagnostics
print('\tExposure Model Diagnostics')
self.positivity(decimal=decimal)
print('======================================================================')
print(' Standardized Mean Differences')
print('======================================================================')
print(self.standardized_mean_differences().set_index(keys='labels'))
print('======================================================================\n')
# Outcome accuracy diagnostics
print('\tOutcome Model Diagnostics')
v = self.QAW - self.df[self.outcome]
outcome_accuracy(true=self.df[self.outcome], predicted=self.QAW, decimal=decimal)
df = self.df.copy()
df['_ipw_'] = np.where(df[self.exposure] == 1, 1 / ps, 1 / (1 - ps))
df['_g1_'] = ps
plt.figure(figsize=[8, 6])
plt.subplot(221)
plot_love(df=df, treatment=self.exposure, weight='_ipw_', formula=self.__mweight)
plt.title("Love Plot")
plt.subplot(223)
plot_kde(df=df, treatment=self.exposure, probability='_g1_')
plt.title("Kernel Density of Propensity Scores")
plt.subplot(222)
plot_kde_accuracy(values=v.dropna(), color='green')
plt.title("Kernel Density of Accuracy")
plt.tight_layout()
plt.show()
def positivity(self, decimal=3):
"""Use this to assess whether positivity is a valid assumption for the exposure model / calculated IPTW. If
there are extreme outliers, this may indicate problems with the calculated weights. To reduce extreme weights,
the `bound` argument can be specified in `exposure_model()`
Parameters
--------------
decimal : int, optional
Number of decimal places to display. Default is three
Returns
--------------
None
Prints the positivity results to the console but does not return any objects
"""
if self._fit_missing_model:
ps = self.g1W * np.where(self.df[self.exposure] == 1, self.m1W, self.m0W)
else:
ps = self.g1W
df = self.df.copy()
df['_ipw_'] = np.where(df[self.exposure] == 1, 1 / ps, 1 / (1 - ps))
pos = positivity(df=df, weights='_ipw_')
print('======================================================================')
print(' Weight Positivity Diagnostics')
print('======================================================================')
print('If the mean of the weights is far from either the min or max, this may\n '
'indicate the model is incorrect or positivity is violated')
print('Average weight should be 2')
print('----------------------------------------------------------------------')
print('Mean weight: ', round(pos[0], decimal))
print('Standard Deviation: ', round(pos[1], decimal))
print('Minimum weight: ', round(pos[2], decimal))
print('Maximum weight: ', round(pos[3], decimal))
print('======================================================================\n')
def standardized_mean_differences(self):
"""Calculates the standardized mean differences for all variables based on the inverse probability weights.
Returns
-------
DataFrame
Returns pandas DataFrame of calculated standardized mean differences. Columns are labels (variables labels),
smd_u (unweighted standardized difference), and smd_w (weighted standardized difference)
"""
if self._fit_missing_model:
ps = self.g1W * np.where(self.df[self.exposure] == 1, self.m1W, self.m0W)
else:
ps = self.g1W
df = self.df.copy()
df['_ipw_'] = np.where(df[self.exposure] == 1, 1 / ps, 1 / (1 - ps))
s = standardized_mean_differences(df=df, treatment=self.exposure,
weight='_ipw_', formula=self.__mweight)
return s
def plot_kde(self, to_plot, bw_method='scott', fill=True,
color='g', color_e='b', color_u='r'):
"""Generates density plots that can be used to check predictions qualitatively. Density plots can be generated
for assess either positivity violations of the exposure model or the accuracy in predicting the outcome for
the outcome model. The kernel density used is SciPy's Gaussian kernel. Either Scott's Rule or
Silverman's Rule can be implemented.
Parameters
------------
to_plot : str, optional
The plot to generate. Specifying 'exposure' returns only the density plot for treatment probabilities,
and 'outcome' returns only the density plot for the outcome accuracy
bw_method : str, optional
Method used to estimate the bandwidth. Following SciPy, either 'scott' or 'silverman' are valid options
fill : bool, optional
Whether to color the area under the density curves. Default is true
color : str, optional
Color of the line/area for predicted outcomes minus observed outcomes. Default is Green
color_e : str, optional
Color of the line/area for the treated group. Default is Blue
color_u : str, optional
Color of the line/area for the treated group. Default is Red
Returns
---------------
matplotlib axes
"""
if not self._fit_outcome_model or not self._fit_exposure_model:
raise ValueError("The exposure_model and outcome_model function must be ran before any diagnostics")
if to_plot == 'exposure':
if self._fit_missing_model:
ps = self.g1W * np.where(self.df[self.exposure] == 1, self.m1W, self.m0W)
else:
ps = self.g1W
df = self.df.copy()
df['_g1_'] = ps
ax = plot_kde(df=df, treatment=self.exposure, probability='_g1_',
bw_method=bw_method, fill=fill, color_e=color_e, color_u=color_u)
ax.set_title("Kernel Density of Propensity Scores")
elif to_plot == 'outcome':
v = self.QAW - self.df[self.outcome]
ax = plot_kde_accuracy(values=v.dropna(), bw_method=bw_method, fill=fill, color=color)
ax.set_title("Kernel Density of Accuracy")
else:
raise ValueError("Please use one of the following options for `to_plot`; 'treatment', 'outcome'")
return ax
def plot_love(self, color_unweighted='r', color_weighted='b', shape_unweighted='o', shape_weighted='o'):
"""Generates a Love-plot to detail covariate balance based on the IPTW weights. Further details on the usage of
this plot are available in Austin PC & Stuart EA 2015 https://onlinelibrary.wiley.com/doi/full/10.1002/sim.6607
The Love plot generates a dashed line at standardized mean difference of 0.10. Ideally, weighted SMD are below
this level. Below 0.20 may also be sufficient. Variables above this level may be unbalanced despite the
weighting procedure. Different functional forms (or approaches like machine learning) may be worth considering
Parameters
----------
color_unweighted : str, optional
Color for the unweighted standardized mean differences. Default is red
color_weighted : str, optional
Color for the weighted standardized mean differences. Default is blue
shape_unweighted : str, optional
Shape of points for the unweighted standardized mean differences. Default is circles
shape_weighted:
Shape of points for the weighted standardized mean differences. Default is circles
Returns
-------
axes
Matplotlib axes of the Love plot
"""
if not self._fit_outcome_model or not self._fit_exposure_model:
raise ValueError("The exposure_model and outcome_model function must be ran before any diagnostics")
if self._fit_missing_model:
ps = self.g1W * np.where(self.df[self.exposure] == 1, self.m1W, self.m0W)
else:
ps = self.g1W
df = self.df.copy()
df['_g1_'] = ps
df['_ipw_'] = np.where(df[self.exposure] == 1, 1 / df['_g1_'], 1 / (1 - df['_g1_']))
ax = plot_love(df=df, treatment=self.exposure, weight='_ipw_', formula=self.__mweight,
color_unweighted=color_unweighted, color_weighted=color_weighted,
shape_unweighted=shape_unweighted, shape_weighted=shape_weighted)
return ax
class StochasticTMLE:
r"""Implementation of target maximum likelihood estimator for stochastic treatment plans. This implementation
calculates TMLE for a time-fixed exposure and a single time-point outcome under a stochastic treatment plan of
interest. By default, standard parametric regression models are used to calculate the estimate of interest. The
StochasticTMLE estimator allows users to instead use machine learning algorithms from sklearn and PyGAM.
Note
----
Valid confidence intervals are only attainable with certain machine learning algorithms. These algorithms must be
Donsker class for valid confidence intervals. GAM and LASSO are examples of alogorithms that are Donsker class
Parameters
----------
df : DataFrame
Pandas dataframe containing the variables of interest
exposure : str
Column label for the exposure of interest
outcome : str
Column label for the outcome of interest
alpha : float, optional
Alpha for confidence interval level. Default is 0.05
continuous_bound : float, optional
Optional argument to control the bounding feature for continuous outcomes. The bounding process may result
in values of 0,1 which are undefined for logit(x). This parameter adds or substracts from the scenarios of
0,1 respectively. Default value is 0.0005
verbose : bool, optional
Optional argument for verbose estimation. With verbose estimation, the model fits for each result are printed
to the console. It is highly recommended to turn this parameter to True when conducting model diagnostics
Note
----
TMLE is a doubly-robust substitution estimator. TMLE obtains the target estimate in a single step. The
single-step TMLE is described further by van der Laan. For further details, see the listed references.
Continuous outcomes must be bounded between 0 and 1. TMLE does this automatically for the user. Additionally,
the average treatment effect is estimate is back converted to the original scale of Y. When scaling Y as Y*,
some values may take the value of 0 or 1, which breaks a logit(Y*) transformation. To avoid this issue, Y* is
bounded by the `continuous_bound` argument. The default is 0.0005, the same as R's tmle
Following is a general narrative of the estimation procedure for TMLE with stochastic treatments
1. Initial estimators for g-model (IPTW) and Q-model (g-formula) are fit. By default these estimators are based
on parametric regression models. Additionally, machine learning algorithms can be used to estimate the g-model and
Q-model.
2. The auxiliary covariate is calculated (i.e. IPTW).
.. math::
H = \frac{p}{\widehat{\Pr}(A=a)}
where `p` is the probability of treatment `a` under the stochastic intervention of interest.
3. Targeting step occurs through estimation of `e` via a logistic regression model. Briefly a weighted logistic
regression model (weighted by the auxiliary covariates) with the dependent variable as the observed outcome and
an offset term of the Q-model predictions under the observed treatment (A).
.. math::
\text{logit}(Y) = \text{logit}(Q(A, W)) + \epsilon
4. Stochastic interventions are evaluated through Monte Carlo integration for binary treatments. The different
treatment plans are randomly applied and evaluated through the Q-model and then the targeting step via
.. math::
E[\text{logit}(Q(A=a, W)) + \hat{\epsilon}]
This process is repeated a large number of times and the point estimate is the average of those individual treatment
plans.
Examples
--------
Setting up environment
>>> from zepid import load_sample_data, spline
>>> from zepid.causal.doublyrobust import StochasticTMLE
>>> df = load_sample_data(False).dropna()
>>> df[['cd4_rs1', 'cd4_rs2']] = spline(df, 'cd40', n_knots=3, term=2, restricted=True)
Estimating TMLE for 0.2 being treated with ART
>>> tmle = StochasticTMLE(df, exposure='art', outcome='dead')
>>> tmle.exposure_model('male + age0 + cd40 + cd4_rs1 + cd4_rs2 + dvl0')
>>> tmle.outcome_model('art + male + age0 + cd40 + cd4_rs1 + cd4_rs2 + dvl0')
>>> tmle.fit(p=0.2)
>>> tmle.summary()
Estimating TMLE for conditional plan
>>> tmle = StochasticTMLE(df, exposure='art', outcome='dead')
>>> tmle.exposure_model('male + age0 + cd40 + cd4_rs1 + cd4_rs2 + dvl0')
>>> tmle.outcome_model('art + male + age0 + cd40 + cd4_rs1 + cd4_rs2 + dvl0')
>>> tmle.fit(p=[0.6, 0.4], conditional=["df['male']==1", "df['male']==0"])
>>> tmle.summary()
Estimating TMLE with machine learning algorithm from sklearn
>>> from sklearn.linear_model import LogisticRegression
>>> log1 = LogisticRegression(penalty='l1', random_state=201)
>>> tmle = StochasticTMLE(df, 'art', 'dead')
>>> tmle.exposure_model('male + age0 + cd40 + cd4_rs1 + cd4_rs2 + dvl0', custom_model=log1)
>>> tmle.outcome_model('male + age0 + cd40 + cd4_rs1 + cd4_rs2 + dvl0', custom_model=log1)
>>> tmle.fit(p=0.75)
References
----------
Muñoz ID, and Van Der Laan MJ. Population intervention causal effects based on stochastic interventions.
Biometrics 68.2 (2012): 541-549.
van der Laan MJ, and Sherri R. Targeted learning in data science: causal inference for complex longitudinal
studies. Springer Science & Business Media, 2011.
"""
def __init__(self, df, exposure, outcome, alpha=0.05, continuous_bound=0.0005, verbose=False):
self.exposure = exposure
self.outcome = outcome
self._missing_indicator = '__missing_indicator__'
self.df, self._miss_flag, self._continuous_outcome = check_input_data(data=df,
exposure=exposure,
outcome=outcome,
estimator="StochasticTMLE",
drop_censoring=True,
drop_missing=True,
binary_exposure_only=True)
# Manage outcomes
if self._continuous_outcome:
self._continuous_min = np.min(self.df[outcome])
self._continuous_max = np.max(self.df[outcome])
self._cb = continuous_bound
self.df[outcome] = tmle_unit_bounds(y=self.df[outcome], mini=self._continuous_min,
maxi=self._continuous_max, bound=self._cb)
else:
self._cb = 0.0
# Output attributes
self.epsilon = None
self.marginals_vector = None
self.marginal_outcome = None
self.alpha = alpha
self.marginal_se = None
self.marginal_ci = None
self.conditional_se = None
self.conditional_ci = None
# Storage for items I need later
self._outcome_model = None
self._q_model = None
self._Qinit_ = None
self._treatment_model = None
self._g_model = None
self._resamples_ = None
self._specified_bound_ = None
self._denominator_ = None
self._verbose_ = verbose
self._out_model_custom = False
self._exp_model_custom = False
self._continuous_type = None
# Custom model / machine learner storage
self._g_custom_ = None
self._q_custom_ = None
def exposure_model(self, model, custom_model=None, bound=False):
"""Estimation of Pr(A=1|L), which is termed as g(A=1|L) in the literature. This value is used as the denominator
for the inverse probability weights.
Parameters
----------
model : str
Independent variables to predict the exposure. Example) 'var1 + var2 + var3'
custom_model : optional
Input for a custom model that is used in place of the logit model (default). The model must have the
"fit()" and "predict()" attributes. Both sklearn and supylearner are supported as custom models. In the
background, TMLE will fit the custom model and generate the predicted probablities
bound : float, list, optional