These tutorials are licensed by [Bernard Koch](http://www.github.com/kochbj) under a [Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License](http://creativecommons.org/licenses/by-nc-sa/4.0/).

# Tutorial 1: Introduction to Deep Learning for Causal Inference on Observables

The following tutorials are a gentle introduction to building deep learning models for causal inference using the selection on observables identification strategy. In particular, these model are designed to estimate the  average treatment effect (ATE) and the conditional average treatment effect (CATE). The ATE is defined as:

$$ATE =\mathbb{E}[Y(1)-Y(0)]$$

where $Y(1)$ and $Y(0)$ are the potential outcomes had the unit received or not received the treatment, respectively. The CATE is defined as,

$$CATE =\mathbb{E}[Y(1)-Y(0)|X=x]$$

where $X$ is the set of selected, observable covariates, and $x \in X$.

Because selection on observables is a simple identification strategy, these estimators are simple neural networks. This tutorial is thus also a gentle introduction to writing models in TensorFlow, and getting started coding deep learning models.

**These tutorials are for you if:**

1. You want a quick and dirty introduction to DL + Selection on Observables literature with minimal math, or...

2. You want a gentle introduction to writing  and training custom models in Tensorflow 2 and...

3. You have a basic familiarity with causal inference and...

4. You have a basic familiarity with Python and object oriented programming.

## Why use deep learning for causal inference?

1. Appropriately built neural network models are among the **lowest bias** estimators in our statistical arsenal.

2. For similar reasons, the complex response surfaces learned by neural networks make them well-suited for estimating **heterogeneous treatment effects**.

4. Most excitingly, deep learning has the ability to allow us to control for confounding found in **complex data types like images, text, and networks**.

3. Although most of these models don't make theoretical guarantees, representation learning **might be more robust to empirical violations of overlap** than simpler adjustment strategies.

5. Although most of these models don't make theoretical guarantees, representation learning **might be more robust to bias induced by including instruments**  in your conditioning covariates.

One more point: even if we cannot formally satisfy causal inference assumptions, these architectures are still very useful for **creating interpretable ML models** where we can isolate the contributions of specific covariates to predicting the outcome.

## Notation
**Causal identification**

- Observed covariates/features: $X$

- Potential outcomes: $Y(0)$ and $Y(1)$

- Treatment: $T$

- Average Treatment Effect: $ATE =\mathbb{E}[Y(1)-Y(0)]$

- Conditional Average Treatment Effect: $CATE =\mathbb{E}[Y(1)-Y(0)|X=x]$

**Deep learning estimation**

- Predicted outcomes: $\hat{Y}(0)$ and $\hat{Y}(1)$

- Outcome modeling functions: $\hat{Y}(T)=h(X,T)$

- Representation functions: $\Phi(X)$ (producing representations $\phi$)

- Loss functions: $\mathcal{L}(true,predicted)$, with the mean squared error abbreviated $MSE$ and binary cross-entropy as $BCE$

- Estimated CATE: $\hat{CATE}=(1-2t)(\hat{{y}}(t)-\hat{y}(1-t))$

- Estimated ATE: $\hat{ATE}=\frac{1}{n}\sum_{i=1}^n\hat{CATE_i}$

## Standard assumptions for causal identification under selection on observables
Standard assumptions for model-based causal inference apply here (from [Johansson et al., 2020](https://arxiv.org/pdf/2001.07426.pdf)):
1. **Ignorability/Exchangability**.The potential outcomes $Y(0)$, $Y(1)$ and the treatment $T$ are conditionally independent given $X$,
$$Y(0),Y(1)\perp \!\!\! \perp T|X $$
Ignorability specifies that there are no *unmeasured confounders* that affect both treatment and outcome outside of those in the observed covariates/features $X$.

2. **Consistency/Stable Unit Treatment Value Assumption (SUTVA)**. Consistency specifies that when a unit recieves treatment, we observe the potential outcome. Moreover, the response of any unit does not vary with the treatment assignment to other units (i.e., no network effects), and the form/level of treatment is homogeneous and consistent across units,
$$T=t \rightarrow Y=Y(T)$$

3. **Overlap** In any context $x \in X$, any treatment $t\in \{0,1\}$ has a non-zero probability of being observed in the data,

$$\forall x \in X, t\in\{0,1\}:p(T=t|X=x)>0$$

Note that the overlap assumption does not require that the empirical data are necessarily balanced, but that the two treatment distributions have common support.

## Representation learning as a balancing strategy

A core concept in deep learning is the idea that artificial neural networks have the capacity to project a set of complex features $X$ into a useful vector space. When data are transformed into this space, we call the resulting tensor a **representation** ([Goodfellow, et al. 2016](https://www.deeplearningbook.org/contents/representation.html)) (you might also see the term "embedding"). For social scientists most comfortable with linear models, we can think about the parameters in each feed-forward layer of a deep neural network as capturing every possible interaction between the values produced by the previous layer. Tasking the network to minimize error on a relevant downstream task encourages it to adjust these interaction parameters to learn useful representations. We can also think about these representation layers as automatically extracting useful  latent covariates/features.

The key intuition in this literature is that we want to train neural networks to learn a representation function $\Phi(X)$ where the data are deconfounded/balanced in the representation space. In other words, the distributions of the representations $\Phi(X|T=0)$ and $\Phi(X|T=1)$ are similar.

From Shen and Johansson talk 2018

Note that $\Phi$ must, in theory, be an invertible function for the  ignorability and overlap assumptions to hold. By invertible we mean that there is an inverse function such that $\Phi^{-1}(\Phi(X))=X$.

# TARNet (Baseline model)

To encourage balanced representations, [Johansson et al., 2017](https://arxiv.org/pdf/1605.03661.pdf) propose a simple two-headed neural network called Treatment Agnostic Regression Network (TARNet). Each head models a separate outcome. One head learns the function $\hat{Y}(1)=h(\Phi(X),1)$, and the other head learns the function $\hat{Y}(0)=h(\Phi(X),0)$. Both heads backpropagate their gradients to shared representation layers that learn $\Phi(X)$. Again, the hope is that these representation layers will learn to balance the data because they are used to predict both outcomes.

TARNet architecture originally introduced in Johansson et al, 2017. Dashed lines indicate plug-in estimation of CATE (empirical risk) as described above. Green indicates raw data. Figure from Johansson et al, 2020.

Other than this architectural change, this is a standard regression DNN where we minimize the factual MSE for each head:

$$\mathcal{L}(Y,h(\Phi(X),T))=MSE(Y,h(\Phi(X),T))=\frac{1}{n}\sum_{i=1}^n [h(\Phi(x_i),t_i)-y_i(t_i)]^2$$

 The complete objective for the network is to minimize the parameters of $h$ and $\Phi$ for all $n$ units in the training sample such that,

\begin{equation}
\min_{h,\Phi}\frac{1}{n}\sum_{i=1}^n \mathcal{L}(y_i(t_i),h(\Phi(x_i),t_i)) + \lambda \mathcal{R}(h)\end{equation}

where $\mathcal{R}(h)$ is a model complexity term (e.g., for $L_2$ regularization) and $\lambda$ is a hyperparameter chosen by the user.

##Estimating Causal Effects
After training, we will get two predictions: $\hat{y}(t)$ and $\hat{y}(1-t)$. We can estimate the $CATE$ in the testing sample (or full sample since this is inference) as,
$$\hat{CATE}=(1-2t)(\hat{y}(t)-\hat{y}(1-t))$$
and the average treatment effect as,
$$\hat{ATE}=\frac{1}{n}\sum_{i=1}^n\hat{CATE_i}$$

## Coding up TARNet

Okay, let's build the model! The rest of this tutorial basically annotates Claudia Shi's beautiful [implementation](https://github.com/claudiashi57/dragonnet) of TARNet from her [DragonNet paper](https://arxiv.org/pdf/1906.02120.pdf) (featured in a subsequent tutorial). Tensorflow provides a lot of flexibility for building custom models. As I go along I will try and point out alternative ways we could have done things. If you've never used Keras or Tensorflow 2, I'd recommend going over some standard tutorials first to get your bearings, but otherwise this is about as simple as it gets.

First let's get our packages...

```
import tensorflow as tf
import numpy as np #numpy is the numerical computing package in python
import datetime #we'll use dates to label our logs
print(tf.__version__)
```

The next block specifies a function to build the model using Tensorflow 2's functional API. The functional API is one of three API's in Tensorflow (see this [post](https://medium.com/tensorflow/what-are-symbolic-and-imperative-apis-in-tensorflow-2-0-dfccecb01021) for pros and cons). An example implementation of TARNet in the imperative object-oriented API is in the spoiler below (closer to how it would be implemented in PyTorch).

Imperative API Implementation

 The same model above might look something like this in the imperative API:
```python
class TarNet(tf.keras.Model):
    def __init__(self,
                 input_dim,
                 name='tarnet',
                 regularization=.01,
                 **kwargs):
        super(TarNet, self).__init__(name=name, **kwargs)
        self.encoder1=Dense(units=200, activation='elu', kernel_initializer='RandomNormal')
        self.encoder2=Dense(units=200, activation='elu', kernel_initializer='RandomNormal')
        self.encoder3=Dense(units=200, activation='elu', kernel_initializer='RandomNormal')

        self.regressor1_y0 = Dense(units=100, activation='elu', kernel_regularizer=tf.keras.regularizers.l2(regularization))
        self.regressor2_y0 = Dense(units=100, activation='elu', kernel_regularizer=tf.keras.regularizers.l2(regularization))
        self.regressorO_y0 = Dense(units=1, activation=None, kernel_regularizer=tf.keras.regularizers.l2(regularization))

        self.regressor1_y1 = Dense(units=100, activation='elu', kernel_regularizer=tf.keras.regularizers.l2(regularization))
        self.regressor2_y1 = Dense(units=100, activation='elu', kernel_regularizer=tf.keras.regularizers.l2(regularization))
        self.regressorO_y1 = Dense(units=1, activation=None, kernel_regularizer=tf.keras.regularizers.l2(regularization))

    def call(self,inputs):
        x=self.encoder1(inputs)
        x=self.encoder2(x)
        phi=self.encoder3(x)

        out_y0=self.regressor1_y0(phi)
        out_y0=self.regressor2_y0(out_y0)
        y0=self.regressorO_y0(out_y0)

        out_y1=self.regressor1_y1(phi)
        out_y1=self.regressor2_y1(out_y1)
        y1=self.regressorO_y1(out_y1)

        concat=tf.concat([y0,y1,propensity],axis=1)
        return concat
```

The model itself is pretty simple. We use two representation layers with 200 neurons each, and two output layers for each head with 100 neurons each. Because treatment information is implicit in in the two heads, we only need one input: $X$. There are again a couple ways to have multiple outputs in the Functional API, but here we concatenate the two outputs into a list of vectors. We apply regularization to the output heads.

```
from tensorflow.keras.layers import Input
from tensorflow.keras.layers import Dense
from tensorflow.keras.layers import Concatenate
from tensorflow.keras import regularizers
from tensorflow.keras import Model

def make_tarnet(input_dim, reg_l2):
    '''
    The first argument is the column dimension of our data.
    It needs to be specified because the functional API creates a static computational graph
    The second argument is the strength of regularization we'll apply to the output layers
    '''
    x = Input(shape=(input_dim,), name='input')

    # REPRESENTATION
    #in TF2/Keras it is idiomatic to instantiate a layer and pass its inputs on the same line unless the layer will be reused
    #Note that we apply no regularization to the representation layers
    phi = Dense(units=200, activation='elu', kernel_initializer='RandomNormal',name='phi_1')(x)
    phi = Dense(units=200, activation='elu', kernel_initializer='RandomNormal',name='phi_2')(phi)
    phi = Dense(units=200, activation='elu', kernel_initializer='RandomNormal',name='phi_3')(phi)

    # HYPOTHESIS
    y0_hidden = Dense(units=100, activation='elu', kernel_regularizer=regularizers.l2(reg_l2),name='y0_hidden_1')(phi)
    y1_hidden = Dense(units=100, activation='elu', kernel_regularizer=regularizers.l2(reg_l2),name='y1_hidden_1')(phi)

    # second layer
    y0_hidden = Dense(units=100, activation='elu', kernel_regularizer=regularizers.l2(reg_l2),name='y0_hidden_2')(y0_hidden)
    y1_hidden = Dense(units=100, activation='elu', kernel_regularizer=regularizers.l2(reg_l2),name='y1_hidden_2')(y1_hidden)

    # third
    y0_predictions = Dense(units=1, activation=None, kernel_regularizer=regularizers.l2(reg_l2), name='y0_predictions')(y0_hidden)
    y1_predictions = Dense(units=1, activation=None, kernel_regularizer=regularizers.l2(reg_l2), name='y1_predictions')(y1_hidden)

    #a convenience "layer" that concatenates arrays as columns in a matrix
    concat_pred = Concatenate(1)([y0_predictions, y1_predictions])
    #the declarations above have specified the computational graph of our network, now we instantiate it
    model = Model(inputs=x, outputs=concat_pred)

    return model

```

The `summary` method can be used to confirm that the architecture is specified correctly.

One of the advantages of the functional API is that you can also visualize static computational graphs (very similar to the cartoon representation above).

```
tarnet_model=make_tarnet(25,.01)

print(tarnet_model.summary())
tf.keras.utils.plot_model(tarnet_model, show_shapes=True, show_layer_names=True, to_file='tarnet.png')

from IPython.display import Image # this just Jupyter notebook stuff
Image(retina=True, filename='tarnet.png')
```

## Specifying the loss function
There are again at least four different ways to specify loss functions in Tensorflow2: if you have a standard loss there are built-in options, you can specify them as custom functions, custom objects, or build them into custom layers of your network. Here we've written a function.

 Note that we compute $\mathcal{L}(Y(0),h(\Phi(X),0))$ and $\mathcal{L}(Y(1),h(\Phi(X),1))$ separately and just add them to get the whole loss. Tensorflow will apply the gradients appropriately to the different outcome and representation layers.

```
# every loss function in TF2 takes 2 arguments, a vector of true values and a vector predictions
def regression_loss(concat_true, concat_pred):
    #computes a standard MSE loss for TARNet
    y_true = concat_true[:, 0] #get individual vectors
    t_true = concat_true[:, 1]

    y0_pred = concat_pred[:, 0]
    y1_pred = concat_pred[:, 1]

    #Each head outputs a prediction for both potential outcomes
    #We use t_true as a switch to only calculate the factual loss
    loss0 = tf.reduce_sum((1. - t_true) * tf.square(y_true - y0_pred))
    loss1 = tf.reduce_sum(t_true * tf.square(y_true - y1_pred))
    #note Shi uses tf.reduce_sum for her losses instead of tf.reduce_mean.
    #They should be equivalent but it's possible that having larger gradients accelerates convergence.
    #You can always try changing it!
    return loss0 + loss1
```

## Data

The IHDP dataset used in this example is a naturalistic simulation introduced in [Hill, 2011](https://www.tandfonline.com/doi/abs/10.1198/jcgs.2010.08162?casa_token=b8-rfzagECIAAAAA:QeP7C4lKN6nZ7MkDjJHFrEberXopD9M5qPBMeBqbk84mI_8qGxj01ctgt4jdZtORpu9aZvpVRe07PA) to evaluate estimation of heterogeneous treatment effects ($CATE$). The  25 covariates/features for the 747 units (139 treated) in the dataset were taken from an experiment, but Hill simulated the outcomes to create known counterfactuals. The data are available from Fredrik Johansson's website. IHDP is the de facto benchmark in this literature.

Additional details from Hill, 2011

[Hill] used experimental data from the Infant Health and Development Program (IHDP), a randomized experiment that began in 1985, targeted low-birth-weight, premature infants, and provided the treatment group with both intensive high-quality child care and home visits from a trained provider.... [The response surface] is nonlinear and not parallel across treatment conditions, with $Y(0)∼\mathcal{N}(exp((X+W)\beta_B),1)$ and $Y(1)∼\mathcal{N}(X\beta_B−\omega^s_B,1)$, where $W$ is an offset matrix of the same dimension as $X$ with every value equal to 0.5, $\beta_B$ is a vector of regression coefficients (0, 0.1, 0.2, 0.3, 0.4) randomly sampled with probabilities (0.6, 0.1, 0.1, 0.1,0.1). For the sth simulation, $\omega^s_B$ was chosen in the overlap setting, where we estimate the effect of the treatment on the treated, such that theconditional average treatment effect for the treated equals 4.

`y` is the simulated outcome that may represent $Y(0)$ or $Y(1)$ depending on `t`. Note that we rescale it here to improve convergence. `mu_0` and `mu_1` are "noiseless" potential outcomes where Hill simply used the mean of the normal distribution described in the spoiler.

There are 100 stochastic simulations in this data. For this example we will just use the eighth one.

```
from sklearn.preprocessing import StandardScaler
!wget -nc http://www.fredjo.com/files/ihdp_npci_1-100.train.npz
!wget -nc http://www.fredjo.com/files/ihdp_npci_1-100.test.npz

def load_IHDP_data(training_data,testing_data,i=7):
    with open(training_data,'rb') as trf, open(testing_data,'rb') as tef:
        train_data=np.load(trf); test_data=np.load(tef)
        y=np.concatenate(   (train_data['yf'][:,i],   test_data['yf'][:,i])).astype('float32') #most GPUs only compute 32-bit floats
        t=np.concatenate(   (train_data['t'][:,i],    test_data['t'][:,i])).astype('float32')
        x=np.concatenate(   (train_data['x'][:,:,i],  test_data['x'][:,:,i]),axis=0).astype('float32')
        mu_0=np.concatenate((train_data['mu0'][:,i],  test_data['mu0'][:,i])).astype('float32')
        mu_1=np.concatenate((train_data['mu1'][:,i],  test_data['mu1'][:,i])).astype('float32')

        data={'x':x,'t':t,'y':y,'t':t,'mu_0':mu_0,'mu_1':mu_1}
        data['t']=data['t'].reshape(-1,1) #we're just padding one dimensional vectors with an additional dimension
        data['y']=data['y'].reshape(-1,1)

        #rescaling y between 0 and 1 often makes training of DL regressors easier
        data['y_scaler'] = StandardScaler().fit(data['y'])
        data['ys'] = data['y_scaler'].transform(data['y'])

    return data

data=load_IHDP_data(training_data='./ihdp_npci_1-100.train.npz',testing_data='./ihdp_npci_1-100.test.npz')
```

# Training and Fitting the Model

A brief spoiler about training neural networks if you've never done so before.

When you use other types of machine learning models, optimization of the model parameters is typically done for you under the hood and you simply wait for training to finish. In contrast, neural networks have so many parameters that optimization becomes an art.

Rather than training on the whole training dataset at once, neural networks are trained on mini-batches of dozens to a few hundred examples. This is a compromise between applying error gradients from a single example (computationally expensive) and using the whole training dataset (expensive in terms of memory; may not work as well for losses that are not perfectly convex). The error gradient is applied to the network parameters after each mini-batch. A complete iteration through all mini-batches in the training set is called an **epoch.**

After each epoch we run prediction on the entire validation set. While there are a number of regularization techniques used in DL to prevent overfitting (norms, dropout, batch normalization), the most important is **early stopping.** To prevent overfitting, we wish to stop training after several consecutive epochs where the validation loss has failed to improve. The number of epochs to wait after early stopping is often called a *patience* hyperparameter.

The proportion of the gradient the optimizer backpropagates to the parameters is called the **learning rate.** A learning rate that is too small takes a long time to train. A learning rate that is too large will overshoot optima. Learning rate schedulers are used to adaptively slow the learning rate as you get closer to an optimum.

---

Shi uses the builtin Keras `.fit` infrastructure for training the model which makes things super easy. There are a lot of hyperparameter choices here, but I won't dwell on them because hyperparameter selection will be covered in the next tutorial.

 In this example we use stochastic gradient descent to optimize the model with an initial learning rate of 1E-4 and momentum of .9. You can also try other optimizers (e.g., ADAM). **While you should experiment with different learning rates, I recommend having a conservative (smaller) learning rate because we really want our estimator to be unbiased.**

 To avoid overfitting, we stop training deep learning models when the validation loss stops improving. In Tensorflow the `EarlyStopping` callback automatically stops training after a number of epochs with no improvement on the validation loss (`patience` parameter). The `ReduceLROnPlateau` adaptively lowers the learning rate of the optimizer as we approach validation loss plateaus so that the optimizer does not overshoot the current optimum.

We use a mini-batch size of 64. Other papers have recommmended batch sizes up to 200 with this dataset. **The batch size is an important consideration for these causal inference architectures because you really want to make sure each mini-batch has both treatment and control examples for the representation layers.** This is obviously less of a problem for datasets with high proportions of treated units.

```
from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau, TerminateOnNaN
from tensorflow.keras.optimizers import SGD

val_split=0.2
batch_size=64
verbose=1
i = 0
tf.random.set_seed(i)
np.random.seed(i)
yt = np.concatenate([data['ys'], data['t']], 1) #we'll use both y and t to compute the loss

sgd_callbacks = [
        TerminateOnNaN(),
        EarlyStopping(monitor='val_loss', patience=40, min_delta=0.),
        #40 is Shi's recommendation for this dataset, but you should tune for your data
        ReduceLROnPlateau(monitor='loss', factor=0.5, patience=5, verbose=verbose, mode='auto',
                          min_delta=0., cooldown=0, min_lr=0),
    ]
#optimzier hyperparameters
sgd_lr = 1e-5
momentum = 0.9
tarnet_model.compile(optimizer=SGD(lr=sgd_lr, momentum=momentum, nesterov=True),
                    loss=regression_loss,
                    metrics=regression_loss)

tarnet_model.fit(x=data['x'],y=yt,
                callbacks=sgd_callbacks,
                validation_split=val_split,
                epochs=300,
                batch_size=batch_size,
                verbose=verbose)
print("DONE!")
```

# Estimating the ATE/CATE

Now we can do inference on either the whole dataset or a heldout testing sample. For simplicity, we just use the whole dataset here.

We'll also plot our CATE estimates against the the true cates from the simulation.

```
import pandas as pd

concat_pred=tarnet_model.predict(data['x'])
#dont forget to rescale the outcome before estimation!
y0_pred = data['y_scaler'].inverse_transform(concat_pred[:, 0])
y1_pred = data['y_scaler'].inverse_transform(concat_pred[:, 1])
cate_pred=y1_pred-y0_pred
cate_true=data['mu_1']-data['mu_0'] #Hill's noiseless true values
ate_pred=tf.reduce_mean(cate_pred)
print("Estimated ATE (True is 4):", ate_pred.numpy(),'\n\n')

print("Individualized CATE Estimates: BLUE")
print(pd.Series(cate_pred).plot.kde(color='blue'))
print("Individualized CATE True: Green")
print(pd.Series(cate_true).plot.kde(color='green'))

print("\nError CATE Estimates: RED")
print(pd.Series(cate_pred-cate_true).plot.kde(color='red'))

```

While our estimates have a broader spread than the real values, we haven't done any hyperparameter optimization yet so we can definitely do better. That's the focus of the next tutorial.

Of course we can also break down these heterogeneous treatment effects to see if we can find any interesting patterns using, for example, Google's [Facet Dive](https://pair-code.github.io/facets/). This is just demonstrative since our covariates are meaningless in the simulation, but it's still cool. The Facet Dive is now built into TensorBoard.

```
#@title Explore Heterogeneity Using the Facet Dive

data['cate_pred']=cate_pred
facet_df=pd.DataFrame(data['x'])
facet_df['t']=data['t']
facet_df['y']=data['y']
facet_df['cate_pred']=data['cate_pred']

# Display the Dive visualization for the training data.
from IPython.core.display import display, HTML

jsonstr = facet_df.to_json(orient='records')
HTML_TEMPLATE = """

        <link rel="import" href="https://raw.githubusercontent.com/PAIR-code/facets/1.0.0/facets-dist/facets-jupyter.html">
        <facets-dive id="elem" height="600"></facets-dive>
        """
html = HTML_TEMPLATE.format(jsonstr=jsonstr)
display(HTML(html))
```

# Thats it!

- In this tutorial we introduce representation learning for causal inference.

- We learned how to write custom models using the functional API and custom losses in TF2.

- We built a TARNet model and tested it on the IHDP data.

# Up next...
- In the [second tutorial](https://colab.research.google.com/drive/1y9i8koqPqs8JSyVHkdZmjGEW6ntqPV73?usp=sharing) we will dig a bit deeper into model design. We'll learn how to assess convergence using Tensorboard, and tailor TarNet models to your dataset through hyperparameter optimization using [kerastuner](https://keras-team.github.io/keras-tuner/).

- In the [third tutorial](https://colab.research.google.com/drive/19JJNyGAvSJCY8xP8vkVUXFf3-uEdDuss?usp=sharing), we'll introduce some more sophisticated elaborations of TARNet built on semiparametric theory shown in [Shi et al., 2019](https://arxiv.org/pdf/1906.02120.pdf).

- In the [fourth tutorial](https://colab.research.google.com/drive/1d8kvEXk_j268rrYq8QC_hbkfhLmp742Y?usp=sharing), we demonstrate TARNet extensions using integral probability metrics from [Shalit et al., 2017](https://arxiv.org/abs/1606.03976), [Johansson et al. 2018](https://arxiv.org/abs/1903.03448), and [Johansson et al., 2020](https://arxiv.org/abs/2001.07426).