Chapter 2

Code Call-out 2.1: Regression, Comparison of Means and Covariates

In this code call-out we will explore some basic elements of regression as a means to estimate treatment effects. First we will, very simply, confirm the equivalance between a regression and difference of means estimator for the pooint estimate of binary treatment receipt on outcomes. And secondly, we will explore effect heterogeneity by a single covariate. We will do this with data from Bari et al. (2024), who experimentally assess the impact of access to large business assets in microenteprises in Pakistan, where a randomly treated sample was offered a larger microfinance route to purchase such large assets.

Equivalence between regression and comparison of means

To understand the equivalence between regression analysis and the comparison of means in a binary regression set-up, we will first open the data from Bari et al. (2024) and run a basic regression such as that documented in Table 4 of their paper:

import pandas as pd
import statsmodels.api as sm

data = pd.read_stata("data/Bari_et_al_2024.dta")
data_wave1 = data[data["wave"] == 1]
X = sm.add_constant(data_wave1["A"])
y = data_wave1["biz_ta"]
res = sm.OLS(y, X).fit(cov_type="HC2")
print(res.summary())
coef_treatment = res.params["A"]
print(coef_treatment)
                            OLS Regression Results                            
==============================================================================
Dep. Variable:                 biz_ta   R-squared:                       0.019
Model:                            OLS   Adj. R-squared:                  0.018
Method:                 Least Squares   F-statistic:                     14.27
Date:                Mon, 15 Jun 2026   Prob (F-statistic):           0.000171
Time:                        03:45:46   Log-Likelihood:                -6550.0
No. Observations:                 739   AIC:                         1.310e+04
Df Residuals:                     737   BIC:                         1.311e+04
Df Model:                           1                                         
Covariance Type:                  HC2                                         
==============================================================================
                 coef    std err          z      P>|z|      [0.025      0.975]
------------------------------------------------------------------------------
const       1149.0111    110.060     10.440      0.000     933.298    1364.725
A            507.0368    134.211      3.778      0.000     243.989     770.085
==============================================================================
Omnibus:                      303.091   Durbin-Watson:                   1.867
Prob(Omnibus):                  0.000   Jarque-Bera (JB):             1030.966
Skew:                           2.012   Prob(JB):                    1.34e-224
Kurtosis:                       7.158   Cond. No.                         3.21
==============================================================================

Notes:
[1] Standard Errors are heteroscedasticity robust (HC2)
507.03681309809275

Here biz_ta is an outcome measuring total business assets, and this is measured at wave 1 (ie the wave in data immediately after treatment was first provided).

To confirm that this is–by definition–simply equivalent to a difference in means we can consider the quantities which would be observed if we substituted \(A=1\) and \(A=0\) into the above equation. In this case we can see that the regression consists of a constant term: \(E[Y_i|Treat_i=0]\), and a second term which capture the difference between units receiving treatment and those not receiving treatment: \(E[Y_i|Treat_i=1]-E[Y_i|Treat_i=0]\). This latter is just the treatment effect of interest. Below we confirm that the coefficient on \(A\) above is equivalent to the difference in means.

mean_treatment = data_wave1.loc[data_wave1['A'] == 1, 'biz_ta'].mean()
mean_control = data_wave1.loc[data_wave1['A'] == 0, 'biz_ta'].mean()
diff_means = mean_treatment - mean_control

print(f"Coefficient for treatment in regression: {coef_treatment}")
print(f"Difference in means (Treatment - Control): {diff_means}")
print("The coefficient from the regression should be equal to the difference in means to demonstrate equivalence.")
Coefficient for treatment in regression: 507.03681309809275
Difference in means (Treatment - Control): 507.036865234375
The coefficient from the regression should be equal to the difference in means to demonstrate equivalence.

Here we see that, as expected, our mean for the control group is equivalent to the constant in the regression, and the difference in means is equivalent to the coefficient on the treatment indicator in the regression.

Heterogeneity by a baseline covariate \(X\)

Let’s now consider the incorporation of a covariate into this regression and confirm that we can also quite simply break down (linear) heterogeneity, as discussed in (2.10) of the book. Here we will consider a variable which measures the management capacity of the small businesses in the sample. We will consider this management capacity at baseline, which below we generate, standardise so that it is mean zero, and then interact with treatment:

import numpy as np

data['temp'] = np.where(data['wave'] == 0, data['mpall'], np.nan)
data['manage'] = data.groupby('id')['temp'].transform(lambda x: x.mean(skipna=True))
data['manage'] = data['manage'] - data['manage'].mean(skipna=True)
data['Amanage'] = data['A'] * data['manage']
print(data['Amanage'].describe())
count    4365.000000
mean        0.012202
std         0.608976
min        -1.076952
25%        -0.442583
50%         0.000000
75%         0.143815
max         2.305716
Name: Amanage, dtype: float64

We can now consider whether there are differential treatment effects by a firm’s management capacity. To do so, we estimate the interacted regression below which allows for (a) a differential intercept for treated and untreated groups by the inclusion of the treatment dummy and a constant, and (b) a differential slope by management capacity, by including both a control for management capacity, as well as an interaction with treatment:

import statsmodels.formula.api as smf

model = smf.ols("biz_ta ~ A + manage + Amanage", data=data[data["wave"] == 1]).fit(cov_type="HC2")
print(model.summary())
                            OLS Regression Results                            
==============================================================================
Dep. Variable:                 biz_ta   R-squared:                       0.035
Model:                            OLS   Adj. R-squared:                  0.031
Method:                 Least Squares   F-statistic:                     10.87
Date:                Mon, 15 Jun 2026   Prob (F-statistic):           5.45e-07
Time:                        03:45:46   Log-Likelihood:                -6543.8
No. Observations:                 739   AIC:                         1.310e+04
Df Residuals:                     735   BIC:                         1.311e+04
Df Model:                           3                                         
Covariance Type:                  HC2                                         
==============================================================================
                 coef    std err          z      P>|z|      [0.025      0.975]
------------------------------------------------------------------------------
Intercept   1167.5899    110.752     10.542      0.000     950.519    1384.661
A            485.2146    134.655      3.603      0.000     221.295     749.134
manage       442.0014    164.480      2.687      0.007     119.626     764.377
Amanage     -229.2233    194.024     -1.181      0.237    -609.503     151.056
==============================================================================
Omnibus:                      304.670   Durbin-Watson:                   1.887
Prob(Omnibus):                  0.000   Jarque-Bera (JB):             1056.553
Skew:                           2.012   Prob(JB):                    3.74e-230
Kurtosis:                       7.256   Cond. No.                         4.61
==============================================================================

Notes:
[1] Standard Errors are heteroscedasticity robust (HC2)

Above we eastimate this regression, and observe that while in general business with higher management capacity at baseline have much higher business assets, there is not (at least in wave 1) a statistically significantly different return to management among treated and untreated individuals. There is, however, a quite large negative effect on the interaction, weakly suggestive of smaller treatment effects among firms with higher management capacity. We can visualise this as in Figure 2.1 of the book. There are many ways we can do this, and you may wish to explore alternative ways of setting this up by considering marginal effects, or by confirming that the four quantities estimated in the regression above correspond to the two intercepts and two slopes of the lines plotted below which can be similarly plotted “by hand” based on these values. However, below we can see that it is sufficient to plot the predicted effect among treated and control units across the range of the manage variable. We do this in the code block below, first generating this predicted effect from the regression, and then plotting resulting curves for treated and control units.

import pandas as pd
import matplotlib.pyplot as plt

df_pred = data.loc[data['wave'] == 1].copy()
df_pred['yhat'] = model.predict(df_pred)


fig, ax = plt.subplots()
for grp, grp_df in df_pred.groupby('A'):
    grp_sorted = grp_df.sort_values('manage')
    ls = '-' if grp == 1 else '--'
    col = 'red' if grp == 1 else 'blue'
    lbl = 'Treated' if grp == 1 else 'Untreated'
    ax.plot(grp_sorted['manage'], grp_sorted['yhat'],
            linestyle=ls, color=col, label=lbl, linewidth=1)

ax.set_xlabel('manage')
ax.set_ylabel('Linear prediction')
ax.legend(loc='lower center', ncol=2)
plt.show()

While this is just a simple illustration, it points to the flexibility of regression for capturing treatment effects when treatment is randomly assigned, and assumptions of unconditional unconfoundedness are reasonable.

Code Call-out 2.2: Randomization Inference

Randomization inference is perhaps best-illustrated with practical examples. A particularly illuminating approach to understand how randomisation inference works is visualization through tabular permutation. In this code call-out we will first consider a made-up example based on 3 treated units and 3 control units, before working with a larger number of control and treated units in data from an experiment implemented by Banerjee, Duflo, and Sharma (2021), which we discuss below.

An Exact p-value

It is perhaps useful to see a simple example. Consider the case of 6 units, with 3 observations randomly assigned treatment. Imagine that after having been exposed to treatment, the observed outcomes were, in the treatment group: \((34,27,29)\) and in the control group: \((14,18,24)\). A simple comparison of means estimator suggests that the treatment effect is 11.33. To calculate a p-value, we can permute all the possible combinations, and ask what proportion of these are greater than or equal to this treatment effect. If we consider random orderings of 6 units, this suggests that there are \(6!\) possible combinations, but in reality, as we are randomly choosing 3 units from these 6 to assign a permuted treatment status, the actual value of different combinations is \(6\choose 3\) \(=\frac{6!}{3!*(6-3)!}=20\). We document each of these possible permutations, as well as their permuted treatment effect in the Table below. In this case, we can see that only 1 of the 20 different permutations is greater than or equal to 11.33 (the original treatment assignment). Suggesting an exact p-value of \(1/20=0.05\) if a one-sided test is considered. If, however, we wish to consider a two-sided p-value, there are two values as extreme as 11.33, which is permutation 1, and permutation 20 below, suggesting a two-sided p-value of \(2/10=0.10\).

A Simple Illustration of Randomization Inference
Permutation T1 T2 T3 C1 C2 C3 Estimate
Original (1) 34 27 29 14 18 24 11.33
2 34 27 14 29 18 24 1.33
3 34 27 18 14 29 24 4
4 34 27 24 14 18 29 8
5 34 14 29 27 18 24 2.67
6 34 18 29 14 27 24 5.33
7 34 24 29 14 18 27 9.33
8 14 27 29 34 18 24 -2
9 18 27 29 14 34 24 0.67
10 24 27 29 14 18 34 4.67
11 34 14 18 27 29 24 -4.67
12 34 14 24 27 18 29 -0.67
13 34 18 24 14 27 29 2
14 14 27 18 34 29 24 -9.33
15 14 27 24 34 18 29 -5.33
16 18 27 24 14 34 29 -2.67
17 14 18 29 34 27 24 -8
18 14 24 29 34 18 27 -4
19 18 24 29 14 34 27 -1.33
20 14 18 24 34 27 29 -11.33

While this is so simple that we can set it up by hand, it is also useful to see how we can compute this in Python. First, we will load our “data”:

import pandas as pd

data = pd.DataFrame({
    'Y': [34, 27, 29, 14, 18, 24],
    'W': [1, 1, 1, 0, 0, 0]
})

and then calculate a comparison of means estimator of the ATT:

mean_Y1 = data.loc[data['W'] == 1, 'Y'].mean()
mean_Y0 = data.loc[data['W'] == 0, 'Y'].mean()
tau_hat = mean_Y1 - mean_Y0
print("Treatment effect is:", tau_hat)
Treatment effect is: 11.333333333333332

Now let’s generate our p-value by permuting all possible treatment combinations. While there are many ways we could consider doing this, a simple way is to use tools explicitly for permutation (such as Python’s itertools.combinations) which will provide us each of the 20 possible combinations corresponding to this 6 choose 3 setting. Below we begin by preparing a new frame of data in which to store our resulting treatment effects:

import numpy as np

permutations = pd.DataFrame({
    'permutation': np.arange(1, 21),
    'effect': np.nan
})
print(permutations)
    permutation  effect
0             1     NaN
1             2     NaN
2             3     NaN
3             4     NaN
4             5     NaN
5             6     NaN
6             7     NaN
7             8     NaN
8             9     NaN
9            10     NaN
10           11     NaN
11           12     NaN
12           13     NaN
13           14     NaN
14           15     NaN
15           16     NaN
16           17     NaN
17           18     NaN
18           19     NaN
19           20     NaN

This is simply a frame containing 20 lines where permutation increases from 1 to 20, with an empty variable effect that we can fill in with our effects. Now, let’s begin permuting our effect, where we will generate a variable Wperm which indicates treatment assignment for each permutation. Rather than iterating manually through nested loops as in Stata, Python’s itertools.combinations function generates all possible combinations of 3 treated units drawn from 6 directly. We loop over each combination, assign treatment to the selected units, estimate the permuted treatment effect via regression, and store it in our permutations frame. In each iteration we also print out which units are receiving treatment so that we can convince ourselves that we are indeed seeing the 20 treatment combinations mentioned in the table above.

import itertools

# Generate all 20 permutations and compute effects
effects = []
for combo in itertools.combinations(range(6), 3):
    print(f"Treated units are: {list(combo)}")
    Wperm = [0] * 6
    for idx in combo:
        Wperm[idx] = 1
    df_temp = pd.DataFrame({'Y': data['Y'], 'Wperm': Wperm})
    model = smf.ols("Y ~ Wperm", data=df_temp).fit()
    effects.append(model.params['Wperm'])

permutations = pd.DataFrame({
    'permutation': range(1, len(effects) + 1),
    'effect': effects
})
Treated units are: [0, 1, 2]
Treated units are: [0, 1, 3]
Treated units are: [0, 1, 4]
Treated units are: [0, 1, 5]
Treated units are: [0, 2, 3]
Treated units are: [0, 2, 4]
Treated units are: [0, 2, 5]
Treated units are: [0, 3, 4]
Treated units are: [0, 3, 5]
Treated units are: [0, 4, 5]
Treated units are: [1, 2, 3]
Treated units are: [1, 2, 4]
Treated units are: [1, 2, 5]
Treated units are: [1, 3, 4]
Treated units are: [1, 3, 5]
Treated units are: [1, 4, 5]
Treated units are: [2, 3, 4]
Treated units are: [2, 3, 5]
Treated units are: [2, 4, 5]
Treated units are: [3, 4, 5]

In the code above, within each loop we have estimated our permuted treatment effect, and placed it in the permutations frame. We can now open up this frame, and compare these permutation-based effects to the original effect (which is the first effect stored in our data), and calculate the one-sided and two-sided p-values. We do this below.

import matplotlib.pyplot as plt

# Compute effect1 and p-values
effect1 = permutations['effect'].iloc[0]
p1side = (permutations['effect'] >= effect1).mean()
p2side = (permutations['effect'].abs() >= abs(effect1)).mean()

print("One sided p-value:", p1side)
print("Two sided p-value:", p2side)

# Plot histogram
fig, ax = plt.subplots()
ax.hist(permutations['effect'], bins=10, facecolor='lightblue', edgecolor='black')
ax.axvline(x=effect1, color='red', linewidth=1)
ax.set_title("Permutation Distribution of Treatment Effects")
ax.set_xlabel("Effect")
ax.set_ylabel("Count")
plt.show()
One sided p-value: 0.05
Two sided p-value: 0.05

Here we can confirm that we find the p-values we calculated by hand above, and view these permutations graphically in the resulting histogram above.

A Real Example

It turns out, that to translate this to a case with real data, we do not need to add many additional elements. In this section, we work with data from a randomized control trial that examines asset transfers to poor households in India, as discussed in the paper by Banerjee, Duflo, and Sharma (2021). Here we will consider the impact of receipt of asset transfers on two outcomes: a financial index ind_fin_el1 and an asset index asset_ind_tot_el1.

Before we explore randomisation inference, let’s open these data and estimate a simple regression, considering the effect of treatment receipt on the asset index:

data = pd.read_csv("data/Banerjee_et_al_2021.csv")
model = smf.ols("asset_ind_tot_el1 ~ treatment", data=data).fit()
robust_res = model.get_robustcov_results(cov_type="HC1")
print(robust_res.summary())
assetEffect = model.params["treatment"]
print(assetEffect)
                            OLS Regression Results                            
==============================================================================
Dep. Variable:      asset_ind_tot_el1   R-squared:                       0.017
Model:                            OLS   Adj. R-squared:                  0.016
Method:                 Least Squares   F-statistic:                     11.94
Date:                Mon, 15 Jun 2026   Prob (F-statistic):           0.000582
Time:                        03:45:47   Log-Likelihood:                -1257.3
No. Observations:                 682   AIC:                             2519.
Df Residuals:                     680   BIC:                             2528.
Df Model:                           1                                         
Covariance Type:                  HC1                                         
==============================================================================
                 coef    std err          t      P>|t|      [0.025      0.975]
------------------------------------------------------------------------------
Intercept     -0.1961      0.076     -2.591      0.010      -0.345      -0.047
treatment      0.4078      0.118      3.456      0.001       0.176       0.639
==============================================================================
Omnibus:                      209.123   Durbin-Watson:                   1.995
Prob(Omnibus):                  0.000   Jarque-Bera (JB):              558.197
Skew:                           1.552   Prob(JB):                    6.15e-122
Kurtosis:                       6.164   Cond. No.                         2.58
==============================================================================

Notes:
[1] Standard Errors are heteroscedasticity robust (HC1)
0.4077528208202768

From the summary we can note that \(\widehat{\tau}_{ATE} = 0.408\) and its standard error equals 0.118. This suggests a p-value of around 0.0005. We have saved the original estimated effect in a local (assetEffect) which we will use below.

We can similarly consider the financial index:

model_fin = smf.ols("ind_fin_el1 ~ treatment", data=data).fit()
robust_fin = model_fin.get_robustcov_results(cov_type="HC1")
print(robust_fin.summary())
finEffect = model_fin.params["treatment"]
print(finEffect)
                            OLS Regression Results                            
==============================================================================
Dep. Variable:            ind_fin_el1   R-squared:                       0.000
Model:                            OLS   Adj. R-squared:                 -0.001
Method:                 Least Squares   F-statistic:                   0.05335
Date:                Mon, 15 Jun 2026   Prob (F-statistic):              0.817
Time:                        03:45:47   Log-Likelihood:                -729.69
No. Observations:                 815   AIC:                             1463.
Df Residuals:                     813   BIC:                             1473.
Df Model:                           1                                         
Covariance Type:                  HC1                                         
==============================================================================
                 coef    std err          t      P>|t|      [0.025      0.975]
------------------------------------------------------------------------------
Intercept      0.1354      0.030      4.576      0.000       0.077       0.194
treatment     -0.0096      0.042     -0.231      0.817      -0.091       0.072
==============================================================================
Omnibus:                      613.330   Durbin-Watson:                   1.913
Prob(Omnibus):                  0.000   Jarque-Bera (JB):             9492.123
Skew:                           3.359   Prob(JB):                         0.00
Kurtosis:                      18.310   Cond. No.                         2.69
==============================================================================

Notes:
[1] Standard Errors are heteroscedasticity robust (HC1)
-0.009589322745445043

where we observe a point estimate quite close to zero, and p-value of 0.817.

Let’s now consider how we conduct permutation inference in this particular case where there are too many permutations of treatment to feasibly consider them all. Here, rather than considering all possible permutations, we will consider randomly assigned permutations of treatment which respect the original treatment assignment structure. Namely, if we inspect these data, we can see that there are 991 units, 525 of whom receive treatment. We can thus conduct a single permutation by randomly re-assigning treatment to 525 units, and control to the remaning units. We do this below, estimating the correspnding effect.

print(data['treatment'].value_counts())

np.random.seed(121316)
data['epsilon'] = np.random.randn(len(data))
data = data.sort_values('epsilon').reset_index(drop=True)
data['Wperm'] = [1]*525 + [0]*(len(data) - 525)


model_perm = smf.ols('asset_ind_tot_el1 ~ Wperm', data=data).fit()
robust_perm = model_perm.get_robustcov_results(cov_type='HC1')
print(robust_perm.summary())

data.drop(columns=['Wperm', 'epsilon'], inplace=True)
treatment
1    525
0    466
Name: count, dtype: int64
                            OLS Regression Results                            
==============================================================================
Dep. Variable:      asset_ind_tot_el1   R-squared:                       0.001
Model:                            OLS   Adj. R-squared:                 -0.001
Method:                 Least Squares   F-statistic:                    0.3872
Date:                Mon, 15 Jun 2026   Prob (F-statistic):              0.534
Time:                        03:45:47   Log-Likelihood:                -1263.1
No. Observations:                 682   AIC:                             2530.
Df Residuals:                     680   BIC:                             2539.
Df Model:                           1                                         
Covariance Type:                  HC1                                         
==============================================================================
                 coef    std err          t      P>|t|      [0.025      0.975]
------------------------------------------------------------------------------
Intercept     -0.0376      0.082     -0.458      0.647      -0.199       0.124
Wperm          0.0735      0.118      0.622      0.534      -0.158       0.305
==============================================================================
Omnibus:                      211.184   Durbin-Watson:                   1.926
Prob(Omnibus):                  0.000   Jarque-Bera (JB):              568.819
Skew:                           1.564   Prob(JB):                    3.04e-124
Kurtosis:                       6.200   Cond. No.                         2.65
==============================================================================

Notes:
[1] Standard Errors are heteroscedasticity robust (HC1)

Here we see that, perhaps unsurprisingly, our randomly assigned treatment results in a small estimated “effect”, of 0.082, which is substantially smaller than the original treatment effect itself.

To estimate a permutation-based p-value all we need to do is repeat this procedure a large number of times (below 10,000 times), and count the proportion of permuted effects which are larger than our estimated treatment effect. We set this up below, incrementing pval by one any time the absolute value of the permuted effect exceeds the absolute value of our observed treatment effect:

Npermut = 10000
pval = 0

np.random.seed(121316)
n = len(data)

for i in range(Npermut):
    epsilon = np.random.randn(n)
    idx = np.argsort(epsilon)
    Wperm = np.zeros(n, dtype=int)
    # 525 = number of treated units in this dataset
    Wperm[idx[:525]] = 1

    # Generate temporary dataframe of outcome and permuted treatment
    df_temp = pd.DataFrame({'asset_ind_tot_el1': data['asset_ind_tot_el1'], 'Wperm': Wperm})
    model_i = smf.ols("asset_ind_tot_el1 ~ Wperm", data=df_temp).fit()
    
    if abs(model_i.params["Wperm"]) > abs(assetEffect):
        pval += 1

print(pval)
print(pval / Npermut)
8
0.0008

Here we see that among all of our 10,000 placebos, only 8 effects were larger than the effect we actually observed in our data, suggesting a p-value of 8/10000, or 0.0008.

We can generalise this idea below (for this specific data set-up), and below we do this defining a program which we call randomisation_inference. This program takes a single argument, which is the outcome we wish to consider, and packages the procedure we have explored above, finally printing the p-value and a number of other pieces of information. While we could further optimise this program, for example to automatically infer how to permute the treatment assignment and to return resulting p-values to users, the below is sufficient for our interests here of estimating a randomisation-based p-value.

def randomisation_inference(varname, data, Npermut=10000):
    np.random.seed(121316)
    # Observed effect
    model_obs = smf.ols(f"{varname} ~ treatment", data=data).fit()
    obs_effect = model_obs.params['treatment']
    
    # Permutation test
    pval = 0
    n = data.shape[0]
    for _ in range(Npermut):
        epsilon = np.random.randn(n)
        idx = np.argsort(epsilon)
        Wperm = np.zeros(n, dtype=int)
        Wperm[idx[:525]] = 1  # same number of treated as original
        
        # Generate temporary dataframe of outcome and permuted treatment
        df_temp = pd.DataFrame({'asset_ind_tot_el1': data['asset_ind_tot_el1'], 'ind_fin_el1': data['ind_fin_el1'],'Wperm': Wperm})
        model_perm = smf.ols(f"{varname} ~ Wperm", data=df_temp).fit()
        
        if abs(model_perm.params['Wperm']) > abs(obs_effect):
            pval += 1
    
    p_value = pval / Npermut
    print(f"The observed effect of {varname} is {obs_effect} and its p-value is {p_value}")

Finally, we can run this program, simply passing as an argument the two variables we have examined above. We do this below:

randomisation_inference("ind_fin_el1", data)
randomisation_inference("asset_ind_tot_el1", data)
The observed effect of ind_fin_el1 is -0.009589322745445189 and its p-value is 0.8161
The observed effect of asset_ind_tot_el1 is 0.4077528208202771 and its p-value is 0.0008

If we compare these p-values to regression-based estimates, we will see that, perhaps unsurpringly, they are very similar.

Code Call-out 2.3: Bootstrap

For an introduction to the bootstrap, refer to Section 2.4.4 of the book. Here we will examine a computational implementation of a bootstrap standard error and confidence intervals, using data from Chong et al. (2016). First we will consider the main regression \[Y_i = \mu + \tau_{ATE}W_i + \varepsilon_i.\] Here we will work with the data Chong_et_al_2016, and examine the total cognitive score (wii_total) as our outcome measure \(Y_i\). We can load the data and examine selected variables on the first few lines of this dataset, before estimating the treatment effect via regression below.

import pandas as pd
import numpy as np
import statsmodels.formula.api as smf

data = pd.read_stata("data/Chong_et_al_2016.dta")
print(data[['student_id', 'hhid', 'treat']].head(5))

data_clean = data.dropna(subset=['wii_total', 'treat'])
model = smf.ols("wii_total ~ treat", data=data_clean).fit()
print(model.summary())
  student_id  hhid  treat
0     001001     1      0
1     001002     1      0
2     001003     1      0
3     002001     2      0
4     002002     2      1
                            OLS Regression Results                            
==============================================================================
Dep. Variable:              wii_total   R-squared:                       0.025
Model:                            OLS   Adj. R-squared:                  0.020
Method:                 Least Squares   F-statistic:                     5.174
Date:                Mon, 15 Jun 2026   Prob (F-statistic):             0.0240
Time:                        03:47:14   Log-Likelihood:                -1365.7
No. Observations:                 208   AIC:                             2735.
Df Residuals:                     206   BIC:                             2742.
Df Model:                           1                                         
Covariance Type:            nonrobust                                         
==============================================================================
                 coef    std err          t      P>|t|      [0.025      0.975]
------------------------------------------------------------------------------
Intercept    455.6642     14.759     30.874      0.000     426.566     484.762
treat         57.4625     25.262      2.275      0.024       7.658     107.267
==============================================================================
Omnibus:                        4.050   Durbin-Watson:                   1.512
Prob(Omnibus):                  0.132   Jarque-Bera (JB):                3.153
Skew:                          -0.179   Prob(JB):                        0.207
Kurtosis:                       2.514   Cond. No.                         2.41
==============================================================================

Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.

From the summary we can note that \(\widehat{\tau}_{ATE} = 57.46\) and its standard error equals 25.26. This suggests a p-value of 0.024, and 95% confidence intervals of [7.66;107.267]. In this case the standard errors are computed via the standard OLS variance estimator, assuming homoscedastic errors, although heteroscedasticity-robust standard errors can be requested quite simply using the get_robustcov_results function from statsmodels. This is shown below, resulting in slight variations to the reported standard error (and correspondingly, p-value and resulting confidence intervals).

model = smf.ols("wii_total ~ treat", data=data_clean).fit()
robust_res = model.get_robustcov_results(cov_type="HC2")
print(robust_res.summary())
                            OLS Regression Results                            
==============================================================================
Dep. Variable:              wii_total   R-squared:                       0.025
Model:                            OLS   Adj. R-squared:                  0.020
Method:                 Least Squares   F-statistic:                     5.642
Date:                Mon, 15 Jun 2026   Prob (F-statistic):             0.0185
Time:                        03:47:14   Log-Likelihood:                -1365.7
No. Observations:                 208   AIC:                             2735.
Df Residuals:                     206   BIC:                             2742.
Df Model:                           1                                         
Covariance Type:                  HC2                                         
==============================================================================
                 coef    std err          t      P>|t|      [0.025      0.975]
------------------------------------------------------------------------------
Intercept    455.6642     15.397     29.595      0.000     425.309     486.020
treat         57.4625     24.193      2.375      0.018       9.765     105.160
==============================================================================
Omnibus:                        4.050   Durbin-Watson:                   1.512
Prob(Omnibus):                  0.132   Jarque-Bera (JB):                3.153
Skew:                          -0.179   Prob(JB):                        0.207
Kurtosis:                       2.514   Cond. No.                         2.41
==============================================================================

Notes:
[1] Standard Errors are heteroscedasticity robust (HC2)

An alternative to these closed-form methods for variance estimation is to use bootstrap resampling methods. There are multiple ways a bootstrap can be implemented, but the simplest here will be to simply conduct a paired bootstrap, which is also robust to heteroscedasticity. The pairs bootstrap consists of, first, sampling with replacement the ordered pairs \(\left\{(y_i,w_i)\right\}_{i=1}^{N} = \left\{(y_1,w_1),\ldots,(y_N,w_N)\right\}\) from original data, obtaining a “new” dataset of \(N\) resampled pairs \(\left\{(y_i^*,w_i^*)\right\}_{i=1}^{N} = \left\{(y_1^*,w_1^*),\ldots,(y_N^*,w_N^*)\right\}\). The new dataset thus simply consists of (potentially repeated) randomly selected rows of the original data. Let’s see what this looks like with a single bootstrap replicate. First, we will generate a vector of size \(N\), drawn with replacement. We do this below, having a look at the first 10 rows of the vector.

data_bootstrap = data_clean.sample(n=len(data_clean), replace=True, random_state=121316)
print(data_bootstrap[['student_id', 'hhid', 'treat']].head(5))
    student_id  hhid  treat
189     183002   183      0
77      071002    71      0
91      084001    84      0
180     174001   174      0
98      089001    89      0

With this bootstrap sample we estimate again the coefficient of interest with standard OLS procedure:

model_bootstrap = smf.ols("wii_total ~ treat", data=data_bootstrap).fit()
print(model_bootstrap.summary())
                            OLS Regression Results                            
==============================================================================
Dep. Variable:              wii_total   R-squared:                       0.043
Model:                            OLS   Adj. R-squared:                  0.038
Method:                 Least Squares   F-statistic:                     9.243
Date:                Mon, 15 Jun 2026   Prob (F-statistic):            0.00267
Time:                        03:47:14   Log-Likelihood:                -1350.4
No. Observations:                 208   AIC:                             2705.
Df Residuals:                     206   BIC:                             2711.
Df Model:                           1                                         
Covariance Type:            nonrobust                                         
==============================================================================
                 coef    std err          t      P>|t|      [0.025      0.975]
------------------------------------------------------------------------------
Intercept    454.4189     13.190     34.452      0.000     428.414     480.424
treat         74.6644     24.559      3.040      0.003      26.246     123.083
==============================================================================
Omnibus:                        5.828   Durbin-Watson:                   1.864
Prob(Omnibus):                  0.054   Jarque-Bera (JB):                3.299
Skew:                          -0.053   Prob(JB):                        0.192
Kurtosis:                       2.392   Cond. No.                         2.43
==============================================================================

Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.

With this sample, we have obtained an estimate of \(\widehat{\tau}_{ATE}^* =23.41\); a different value to the original value, in line with variation in the sample used for estimation. If we want some idea of how much estimates vary as the sample changes (ie the sampling variation of our estimate), the next step simply consists of repeating this bootstrap process \(B\) times, resulting in \(B\) estimates of \(\widehat{\tau}_{ATE,(b)}^*\) with \(b\in\{1,\ldots,B\}\). Let’s set \(B=1,000\), and do this:

np.random.seed(121316)
taus = np.empty(1000)
for i in range(1000):
    data_bootstrap = data_clean.sample(n=len(data_clean), replace=True)
    model = smf.ols("wii_total ~ treat", data=data_bootstrap).fit()
    taus[i] = model.params["treat"]

You may wish to step through each of the lines in this loop to ensure that you can see what is going on, but the end product of this code is a array called taus which has been filled in with \(B\) estimates of \(\tau\), \(\left\{\widehat{\tau}^{(b)}\right\}_{b=1}^{B}\). The next step is to convert this column matrix into a variable in our dataset

taus_df = pd.DataFrame({'taus': taus})

We can have a look at the entire empirical distribution of these estimates as follows:

import matplotlib.pyplot as plt
from scipy.stats import gaussian_kde

p25 = np.quantile(taus_df['taus'], 0.025)
p975 = np.quantile(taus_df['taus'], 0.975)

# Kernel density estimate
data_vals = taus_df['taus'].values
kde = gaussian_kde(data_vals)
x = np.linspace(data_vals.min(), data_vals.max(), 200)
y = kde(x)

# Plot
fig, ax = plt.subplots()
ax.fill_between(x, y, alpha=0.5, color='lightblue')
ax.axvline(p25, linestyle='dashed', color='red')
ax.axvline(p975, linestyle='dashed', color='red')
ax.axvline(57.46253, linestyle='solid', color='grey', linewidth=1)
ax.set_title(r'$\tau$ Empirical Distribution')
ax.set_xlabel(r'$\tau$')
ax.set_ylabel('Density')
ax.xaxis.set_major_formatter(plt.FuncFormatter(lambda val, pos: f"{val:.2f}"))

plt.show()

As we can see, this empirical distribution is close to centred on the original estimate (in the limit, they will be exactly the same, which is something you may wish to confirm by using a larger value for \(B\)), while also giving us some idea of the variation of the estimate over alternative resamples. We additionally plot the empirical 2.5th and 97.5th quantiles of the distribution, which provides a 95% confidence interval for \(\widehat\tau\). Clearly, and in line with the regression results observed above, we can reject the null that \(\tau=0\) with some certainty. Using these values \(\left\{\widehat{\tau}^{(b)}\right\}_{b=1}^{B}\), finally we display the estimate’s standard error as the standard deviation of the bootstrap estimates, and the confidence intervals can be generated from empirical quantiles. We illustrate this below:

var_estimate = taus_df['taus'].var()
sd_estimate = taus_df['taus'].std()
ci = taus_df['taus'].quantile([0.025, 0.975])
print(f"The variance estimate is: {var_estimate:.4f} as such, the standard error estimate is: {sd_estimate:.4f}")
print(f"The empirical 95% confidence interval is: [{ci.iloc[0]:.4f}, {ci.iloc[1]:.4f}].")
The variance estimate is: 500.2855 as such, the standard error estimate is: 22.3671
The empirical 95% confidence interval is: [16.4375, 105.4291].

We can see that the standard error, and hence 95% confidence interval, is very closely alligned to that from regression documented above, given both are valid under broadly similar assumptions.

References

Banerjee, Abhijit, Esther Duflo, and Garima Sharma. 2021. Long-Term Effects of the Targeting the Ultra Poor Program.” American Economic Review: Insights 3 (4): 471–86. https://doi.org/10.1257/aeri.20200667.
Bari, Faisal, Kashif Malik, Muhammad Meki, and Simon Quinn. 2024. “Asset-Based Microfinance for Microenterprises: Evidence from Pakistan.” American Economic Review 114 (2): 534–74. https://doi.org/10.1257/aer.20210169.
Chong, Alberto, Isabelle Cohen, Erica Field, Eduardo Nakasone, and Maximo Torero. 2016. “Iron Deficiency and Schooling Attainment in Peru.” American Economic Journal: Applied Economics 8 (4): 222–55. https://doi.org/10.1257/app.20140494.