The hardware and bandwidth for this mirror is donated by dogado GmbH, the Webhosting and Full Service-Cloud Provider. Check out our Wordpress Tutorial.
If you wish to report a bug, or if you are interested in having us mirror your free-software or open-source project, please feel free to contact us at mirror[@]dogado.de.

rdhte: Empirical Illustration

Overview

rdhte estimates heterogeneous treatment effects in sharp Regression Discontinuity (RD) designs along one or more pretreatment covariates. The package exposes three main commands:

This vignette illustrates each feature on a real-data extract from Granzier, Pons, and Tricaud (2023, American Economic Journal: Applied), bundled with the package as rdhte_dataset.

The data and the design

Granzier, Pons, and Tricaud study coordination and bandwagon effects in French two-round elections, where candidates must clear a qualifying-vote threshold in the first round to advance to the runoff. The institutional rule creates a sharp regression-discontinuity design on every candidate’s first-round margin against that threshold: candidates just above the cutoff advance, those just below do not, and small differences in first-round support produce a discrete change in runoff participation. The authors use the design to ask whether being just-qualified causally affects subsequent voter and candidate behavior, and document substantive heterogeneity across ideology and candidate-strength dimensions.

The bundled extract has 39,534 candidate-race observations with the following variables:

These covariates support every covariate-incorporation pattern rdhte exposes: binary cells, multi-level (unordered and ordered) factor cells, factor-by-factor interactions, continuous slopes, and binary x continuous interactions.

library(rdhte)
library(rdrobust)
library(broom)
data(rdhte_dataset)
attach(rdhte_dataset)

Single binary variable

When covs.hte is binary (or a factor with two levels), rdhte reports one CATE per cell.

summary(rd_left <- rdhte(y = y, x = x, covs.hte = factor(w_left),
                          cluster = cluster_var))
#> Sharp RD Heterogeneous Treatment Effects: Subgroups., Cluster-Adjusted
#> 
#> Number of Obs.                39534
#> BW type                       mserd
#> Kernel                   Triangular
#> VCE method                      CR1
#> 
#> Number of Obs.                19723        19811
#> Order est. (p)                    1            1
#> Order bias  (q)                   2            2
#> 
#> ==================================================================================================================
#>                      Point        Robust Inference
#> factor(w_left)    Estimate           z    Pr(>|z|)      [ 95% C.I. ]             Nh-       Nh+        h-        h+
#> ------------------------------------------------------------------------------------------------------------------
#>              0       0.021       1.523       0.128  [-0.003 , 0.027]            4352      4422     0.077     0.077
#>              1       0.089       6.821       0.000   [0.062 , 0.112]            5036      4840     0.117     0.117
#> ==================================================================================================================

How large is the advantage for left-of-center candidates? Test the difference with rdhte_lincom:

rdhte_lincom(model = rd_left,
             linfct = c("`factor(w_left)1` - `factor(w_left)0` = 0"))
#> $individual
#>                              hypothesis estimate z_stat p_value conf.low
#> 1 `factor(w_left)1` - `factor(w_left)0`    0.068  5.056       0    0.046
#>   conf.high
#> 1     0.105
#> 
#> $joint
#>   statistic X1L p_value
#> 1    25.563   1       0

Forcing a common bandwidth across cells (bw.joint = TRUE) can distort the cell-specific inference – here it makes the right-of-center effect (incorrectly) statistically significant:

summary(rdhte(y = y, x = x, covs.hte = w_left, cluster = cluster_var,
              bw.joint = TRUE))
#> Sharp RD Heterogeneous Treatment Effects: Subgroups., Cluster-Adjusted
#> 
#> Number of Obs.                39534
#> BW type                       mserd
#> Kernel                   Triangular
#> VCE method                      CR1
#> 
#> Number of Obs.                19723        19811
#> Order est. (p)                    1            1
#> Order bias  (q)                   2            2
#> 
#> ================================================================================================================
#>                    Point        Robust Inference
#>       w_left    Estimate           z    Pr(>|z|)      [ 95% C.I. ]             Nh-       Nh+        h-        h+
#> ----------------------------------------------------------------------------------------------------------------
#>            0       0.023       2.272       0.023   [0.002 , 0.029]            5208      5364     0.097     0.097
#>            1       0.088       6.437       0.000   [0.062 , 0.116]            4325      4183     0.097     0.097
#> ================================================================================================================

A 0/1 numeric variable is auto-promoted to a factor; coefficient names then carry the variable name as a prefix:

summary(rd_left2 <- rdhte(y = y, x = x, covs.hte = w_left,
                           cluster = cluster_var))
#> Sharp RD Heterogeneous Treatment Effects: Subgroups., Cluster-Adjusted
#> 
#> Number of Obs.                39534
#> BW type                       mserd
#> Kernel                   Triangular
#> VCE method                      CR1
#> 
#> Number of Obs.                19723        19811
#> Order est. (p)                    1            1
#> Order bias  (q)                   2            2
#> 
#> ================================================================================================================
#>                    Point        Robust Inference
#>       w_left    Estimate           z    Pr(>|z|)      [ 95% C.I. ]             Nh-       Nh+        h-        h+
#> ----------------------------------------------------------------------------------------------------------------
#>            0       0.021       1.523       0.128  [-0.003 , 0.027]            4352      4422     0.077     0.077
#>            1       0.089       6.821       0.000   [0.062 , 0.112]            5036      4840     0.117     0.117
#> ================================================================================================================
rdhte_lincom(model = rd_left2,
             linfct = c("`w_left1` - `w_left0` = 0"))
#> $individual
#>          hypothesis estimate z_stat p_value conf.low conf.high
#> 1 w_left1 - w_left0    0.068  5.056       0    0.046     0.105
#> 
#> $joint
#>   statistic X1L p_value
#> 1    25.563   1       0

Single categorical variable – unordered

A multi-level factor produces one CATE per level. The reference category is the factor’s first level.

summary(rd_ideology <- rdhte(y = y, x = x,
                              covs.hte = factor(w_ideology),
                              cluster = cluster_var))
#> Sharp RD Heterogeneous Treatment Effects: Subgroups., Cluster-Adjusted
#> 
#> Number of Obs.                39534
#> BW type                       mserd
#> Kernel                   Triangular
#> VCE method                      CR1
#> 
#> Number of Obs.                19723        19811
#> Order est. (p)                    1            1
#> Order bias  (q)                   2            2
#> 
#> ======================================================================================================================
#>                          Point        Robust Inference
#> factor(w_ideology)    Estimate           z    Pr(>|z|)      [ 95% C.I. ]             Nh-       Nh+        h-        h+
#> ----------------------------------------------------------------------------------------------------------------------
#>                  1       0.089       6.819       0.000   [0.062 , 0.112]            5036      4840     0.117     0.117
#>                  2      -0.024      -0.853       0.393  [-0.195 , 0.077]             181       156     0.056     0.056
#>                  3       0.026       1.897       0.058  [-0.001 , 0.032]            3816      4202     0.086     0.086
#>                  4       0.007       0.884       0.377  [-0.012 , 0.032]             713       384     0.090     0.090
#> ======================================================================================================================

Joint test that the three non-reference ideology cells are all indistinguishable from zero:

rdhte_lincom(model = rd_ideology,
             linfct = c("`factor(w_ideology)2` = 0",
                        "`factor(w_ideology)3` = 0",
                        "`factor(w_ideology)4` = 0"))
#> $individual
#>            hypothesis estimate z_stat p_value conf.low conf.high
#> 1 factor(w_ideology)2   -0.024 -0.853   0.393   -0.195     0.077
#> 2 factor(w_ideology)3    0.026  1.897   0.058   -0.001     0.032
#> 3 factor(w_ideology)4    0.007  0.884   0.377   -0.012     0.032
#> 
#> $joint
#>   statistic X3L p_value
#> 1     5.116   3   0.163

Single categorical variable – ordered

For an ordered categorical variable, wrapping in factor() keeps the per-level CATE interpretation:

summary(rdhte(y = y, x = x, covs.hte = factor(w_strength_qrt),
              cluster = cluster_var))
#> Sharp RD Heterogeneous Treatment Effects: Subgroups., Cluster-Adjusted
#> 
#> Number of Obs.                39534
#> BW type                       mserd
#> Kernel                   Triangular
#> VCE method                      CR1
#> 
#> Number of Obs.                19723        19811
#> Order est. (p)                    1            1
#> Order bias  (q)                   2            2
#> 
#> ==========================================================================================================================
#>                              Point        Robust Inference
#> factor(w_strength_qrt)    Estimate           z    Pr(>|z|)      [ 95% C.I. ]             Nh-       Nh+        h-        h+
#> --------------------------------------------------------------------------------------------------------------------------
#>                      0       0.016       1.803       0.071  [-0.001 , 0.032]            2583      2283     0.094     0.094
#>                      1       0.039       2.219       0.026   [0.003 , 0.055]            2227      2300     0.085     0.085
#>                      2       0.054       3.490       0.000   [0.025 , 0.090]            2040      2329     0.105     0.105
#>                      3       0.094       6.652       0.000   [0.069 , 0.127]            3436      3486     0.143     0.143
#> ==========================================================================================================================

The per-quartile CATE increases monotonically: candidates with higher average strength have larger treatment effects.

A bare numeric variable (no factor()) is treated as continuous – useful when the ordering is meaningful but you want the linear-in-W parametrization:

summary(rdhte(y = y, x = x, covs.hte = w_strength_qrt,
              cluster = cluster_var))
#> Sharp RD Heterogeneous Treatment Effects: Continuous., Cluster-Adjusted
#> 
#> Number of Obs.                39534
#> BW type                       mserd
#> Kernel                   Triangular
#> VCE method                      CR1
#> 
#> Number of Obs.                19723        19811
#> Eff. Number of Obs.            9533         9547
#> Order est. (p)                    1            1
#> Order bias  (q)                   2            2
#> BW est. (h)                   0.097        0.097
#> 
#> ==========================================================================
#>                      Point        Robust Inference
#>                   Estimate           z    Pr(>|z|)      [ 95% C.I. ]      
#> --------------------------------------------------------------------------
#>              T       0.014       1.389       0.165  [-0.005 , 0.029]      
#> T:w_strength_qrt       0.026       3.997       0.000   [0.013 , 0.037]      
#> ==========================================================================

Two binary variables – interaction

Interactions of two factors produce one CATE per joint cell. The ordering of cells follows R’s interaction() convention.

summary(rdhte(y = y, x = x,
              covs.hte = factor(w_left):factor(w_strong),
              cluster = cluster_var))
#> Sharp RD Heterogeneous Treatment Effects: Subgroups., Cluster-Adjusted
#> 
#> Number of Obs.                39534
#> BW type                       mserd
#> Kernel                   Triangular
#> VCE method                      CR1
#> 
#> Number of Obs.                19723        19811
#> Order est. (p)                    1            1
#> Order bias  (q)                   2            2
#> 
#> ===================================================================================================================================
#>                                       Point        Robust Inference
#> factor(w_left):factor(w_strong)    Estimate           z    Pr(>|z|)      [ 95% C.I. ]             Nh-       Nh+        h-        h+
#> -----------------------------------------------------------------------------------------------------------------------------------
#>                             0:1       0.003       0.004       0.997  [-0.016 , 0.016]            2348      2257     0.070     0.070
#>                             0:2       0.044       3.014       0.003   [0.014 , 0.064]            2316      2643     0.104     0.104
#>                             1:1       0.061       3.838       0.000   [0.029 , 0.090]            2066      1960     0.097     0.097
#>                             1:2       0.114       6.462       0.000   [0.082 , 0.153]            3107      3055     0.143     0.143
#> ===================================================================================================================================

The same model can be expressed by pre-building the joint cell variable:

interactions <- 1*(w_left==0)*(w_strong==1) +
                2*(w_left==0)*(w_strong==2) +
                3*(w_left==1)*(w_strong==1) +
                4*(w_left==1)*(w_strong==2)
summary(rdhte(y = y, x = x, covs.hte = factor(interactions),
              cluster = cluster_var))
#> Sharp RD Heterogeneous Treatment Effects: Subgroups., Cluster-Adjusted
#> 
#> Number of Obs.                39534
#> BW type                       mserd
#> Kernel                   Triangular
#> VCE method                      CR1
#> 
#> Number of Obs.                19723        19811
#> Order est. (p)                    1            1
#> Order bias  (q)                   2            2
#> 
#> ========================================================================================================================
#>                            Point        Robust Inference
#> factor(interactions)    Estimate           z    Pr(>|z|)      [ 95% C.I. ]             Nh-       Nh+        h-        h+
#> ------------------------------------------------------------------------------------------------------------------------
#>                    1       0.003       0.004       0.997  [-0.016 , 0.016]            2348      2257     0.070     0.070
#>                    2       0.044       3.014       0.003   [0.014 , 0.064]            2316      2643     0.104     0.104
#>                    3       0.061       3.838       0.000   [0.029 , 0.090]            2066      1960     0.097     0.097
#>                    4       0.114       6.462       0.000   [0.082 , 0.153]            3107      3055     0.143     0.143
#> ========================================================================================================================

Single continuous variable

When covs.hte is continuous, rdhte switches to a linear-in-W parametrization of the CATE function:

\[ \tau(w) = \beta_T + \beta_{T:W}\,w. \]

summary(rd_continuous <- rdhte(y = y, x = x, covs.hte = w_strength,
                                kernel = "uni",
                                cluster = cluster_var))
#> Sharp RD Heterogeneous Treatment Effects: Continuous., Cluster-Adjusted
#> 
#> Number of Obs.                39534
#> BW type                       mserd
#> Kernel                      Uniform
#> VCE method                      CR1
#> 
#> Number of Obs.                19723        19811
#> Eff. Number of Obs.            9514         9529
#> Order est. (p)                    1            1
#> Order bias  (q)                   2            2
#> BW est. (h)                   0.096        0.096
#> 
#> ========================================================================
#>                    Point        Robust Inference
#>                 Estimate           z    Pr(>|z|)      [ 95% C.I. ]      
#> ------------------------------------------------------------------------
#>            T      -0.055      -2.507       0.012  [-0.129 , -0.016]     
#> T:w_strength       0.262       3.927       0.000   [0.151 , 0.451]      
#> ========================================================================

To aid interpretation, the slope coefficient is precisely a partial-linear regression slope. With the uniform kernel and a fixed bandwidth, the rdhte point estimates match plain lm() on the same in-bandwidth subset:

trt <- (x > 0)
new.data <- data.frame(y, x, w_strength, trt)
using.lm <- coef(lm(y ~ trt * x * w_strength,
                    data = new.data[abs(new.data$x) < rd_continuous$h[1], ]))
rd_continuous$Estimate[1] - using.lm["trtTRUE"]
#> T 
#> 0
rd_continuous$Estimate[2] - using.lm["trtTRUE:w_strength"]
#> T:w_strength 
#>            0

(Inference, however, requires the robust bias correction in rdhte and cannot be obtained from the lm() fit alone.)

Interaction effect: binary x continuous

A fully interacted specification gives a separate intercept and slope for each level of the categorical covariate.

summary(rd_interaction <- rdhte(y = y, x = x,
                                 covs.hte = "w_left*w_strength",
                                 cluster = cluster_var))
#> Sharp RD Heterogeneous Treatment Effects: Continuous., Cluster-Adjusted
#> 
#> Number of Obs.                39534
#> BW type                       mserd
#> Kernel                   Triangular
#> VCE method                      CR1
#> 
#> Number of Obs.                19723        19811
#> Eff. Number of Obs.            9533         9547
#> Order est. (p)                    1            1
#> Order bias  (q)                   2            2
#> BW est. (h)                   0.097        0.097
#> 
#> ===============================================================================
#>                           Point        Robust Inference
#>                        Estimate           z    Pr(>|z|)      [ 95% C.I. ]      
#> -------------------------------------------------------------------------------
#>                   T      -0.033      -1.364       0.173  [-0.094 , 0.017]      
#>            T:w_left      -0.059      -0.275       0.783  [-0.306 , 0.230]      
#>        T:w_strength       0.141       1.779       0.075  [-0.014 , 0.295]      
#> T:w_left:w_strength       0.274       0.742       0.458  [-0.395 , 0.876]      
#> ===============================================================================

Wrapping the binary in factor() is also allowed but shifts the baseline category and therefore the reported intercepts:

summary(rdhte(y = y, x = x, covs.hte = "factor(w_left)*w_strength",
              cluster = cluster_var))
#> Sharp RD Heterogeneous Treatment Effects: Continuous., Cluster-Adjusted
#> 
#> Number of Obs.                39534
#> BW type                       mserd
#> Kernel                   Triangular
#> VCE method                      CR1
#> 
#> Number of Obs.                19723        19811
#> Eff. Number of Obs.            9533         9547
#> Order est. (p)                    1            1
#> Order bias  (q)                   2            2
#> BW est. (h)                   0.097        0.097
#> 
#> =======================================================================================
#>                                   Point        Robust Inference
#>                                Estimate           z    Pr(>|z|)      [ 95% C.I. ]      
#> ---------------------------------------------------------------------------------------
#>                           T      -0.092      -0.571       0.568  [-0.338 , 0.186]      
#>           T:factor(w_left)0       0.059       0.275       0.783  [-0.230 , 0.306]      
#>                T:w_strength       0.141       1.779       0.075  [-0.014 , 0.295]      
#> T:factor(w_left)1:w_strength       0.274       0.742       0.458  [-0.395 , 0.876]      
#> =======================================================================================

Each cell-specific coefficient may look insignificant individually while the joint test still rejects:

rdhte_lincom(model = rd_interaction,
             linfct = c("`T` = 0",
                        "`T:w_left` = 0",
                        "`T:w_strength` = 0",
                        "`T:w_left:w_strength` = 0"))
#> $individual
#>            hypothesis estimate z_stat p_value conf.low conf.high
#> 1                   T   -0.033 -1.364   0.173   -0.094     0.017
#> 2            T:w_left   -0.059 -0.275   0.783   -0.306     0.230
#> 3        T:w_strength    0.141  1.779   0.075   -0.014     0.295
#> 4 T:w_left:w_strength    0.274  0.742   0.458   -0.395     0.876
#> 
#> $joint
#>   statistic X4L p_value
#> 1    47.392   4       0

With the bandwidth fixed, the fully interacted model matches results from category-specific estimation. Use rdhte_lincom to read off the per-cell intercept and slope:

summary(rd_interaction <- rdhte(y = y, x = x,
                                 covs.hte = "w_left*w_strength",
                                 h = 0.1, cluster = cluster_var))
#> Sharp RD Heterogeneous Treatment Effects: Continuous., Cluster-Adjusted
#> 
#> Number of Obs.                39534
#> BW type                      Manual
#> Kernel                   Triangular
#> VCE method                      CR1
#> 
#> Number of Obs.                19723        19811
#> Eff. Number of Obs.            9829         9843
#> Order est. (p)                    1            1
#> Order bias  (q)                   2            2
#> BW est. (h)                   0.100        0.100
#> 
#> ===============================================================================
#>                           Point        Robust Inference
#>                        Estimate           z    Pr(>|z|)      [ 95% C.I. ]      
#> -------------------------------------------------------------------------------
#>                   T      -0.031      -1.434       0.152  [-0.095 , 0.015]      
#>            T:w_left      -0.061      -0.266       0.791  [-0.303 , 0.231]      
#>        T:w_strength       0.138       1.875       0.061  [-0.007 , 0.297]      
#> T:w_left:w_strength       0.281       0.727       0.467  [-0.397 , 0.866]      
#> ===============================================================================
summary(rdhte(y = y[w_left == 0], x = x[w_left == 0],
              covs.hte = w_strength[w_left == 0], h = 0.1,
              cluster = cluster_var[w_left == 0]))
#> Sharp RD Heterogeneous Treatment Effects: Continuous., Cluster-Adjusted
#> 
#> Number of Obs.                21938
#> BW type                      Manual
#> Kernel                   Triangular
#> VCE method                      CR1
#> 
#> Number of Obs.                10287        11651
#> Eff. Number of Obs.            5371         5530
#> Order est. (p)                    1            1
#> Order bias  (q)                   2            2
#> BW est. (h)                   0.100        0.100
#> 
#> ===================================================================================
#>                               Point        Robust Inference
#>                            Estimate           z    Pr(>|z|)      [ 95% C.I. ]      
#> -----------------------------------------------------------------------------------
#>                       T      -0.031      -1.434       0.152  [-0.095 , 0.015]      
#> T:w_strength[w_left == 0]       0.138       1.875       0.061  [-0.007 , 0.297]      
#> ===================================================================================
summary(rdhte(y = y[w_left == 1], x = x[w_left == 1],
              covs.hte = w_strength[w_left == 1], h = 0.1,
              cluster = cluster_var[w_left == 1]))
#> Sharp RD Heterogeneous Treatment Effects: Continuous., Cluster-Adjusted
#> 
#> Number of Obs.                17596
#> BW type                      Manual
#> Kernel                   Triangular
#> VCE method                      CR1
#> 
#> Number of Obs.                 9436         8160
#> Eff. Number of Obs.            4458         4313
#> Order est. (p)                    1            1
#> Order bias  (q)                   2            2
#> BW est. (h)                   0.100        0.100
#> 
#> ===================================================================================
#>                               Point        Robust Inference
#>                            Estimate           z    Pr(>|z|)      [ 95% C.I. ]      
#> -----------------------------------------------------------------------------------
#>                       T      -0.093      -0.571       0.568  [-0.337 , 0.185]      
#> T:w_strength[w_left == 1]       0.419       1.213       0.225  [-0.233 , 0.992]      
#> ===================================================================================
rdhte_lincom(model = rd_interaction,
             linfct = c("`T` + `T:w_left` = 0",
                        "`T:w_strength` + `T:w_left:w_strength` = 0"))
#> $individual
#>                               hypothesis estimate z_stat p_value conf.low
#> 1                         T + `T:w_left`   -0.093 -0.571   0.568   -0.337
#> 2 `T:w_strength` + `T:w_left:w_strength`    0.419  1.213   0.225   -0.233
#>   conf.high
#> 1     0.185
#> 2     0.992
#> 
#> $joint
#>   statistic X2L p_value
#> 1     42.13   2       0

Standalone bandwidth selection (rdbwhte)

rdbwhte runs the same data-driven bandwidth selectors as rdhte but returns the bandwidths without estimating the CATEs. Useful when you want to inspect or fix h and then explore alternative variance estimators or compare specifications at a common bandwidth.

bw <- rdbwhte(y = y, x = x, covs.hte = factor(w_ideology),
              cluster = cluster_var)
bw$h
#>            [,1]       [,2]
#> [1,] 0.11696532 0.11696532
#> [2,] 0.05645238 0.05645238
#> [3,] 0.08564972 0.08564972
#> [4,] 0.09037549 0.09037549

bw.joint = TRUE forces a single shared bandwidth across cells:

bw_joint <- rdbwhte(y = y, x = x, covs.hte = factor(w_ideology),
                    cluster = cluster_var, bw.joint = TRUE)
bw_joint$h
#>            [,1]       [,2]
#> [1,] 0.09650206 0.09650206
#> [2,] 0.09650206 0.09650206
#> [3,] 0.09650206 0.09650206
#> [4,] 0.09650206 0.09650206

Efficiency-improving covariates (covs.eff)

covs.eff adds covariates to the local-polynomial regression to shrink standard errors without changing the identification of the CATE. They enter additively (and as covs.eff x W interactions in the heterogeneity-aware paths) but never with the treatment indicator or the running-variable polynomial. Useful when you have strong pretreatment predictors of the outcome.

m_no_eff <- rdhte(y = y, x = x, covs.hte = factor(w_ideology),
                   cluster = cluster_var)
m_eff    <- rdhte(y = y, x = x, covs.hte = factor(w_ideology),
                   covs.eff = w_strength, cluster = cluster_var)
data.frame(cell       = m_no_eff$W.lev,
           se_no_eff  = round(m_no_eff$se.rb, 4),
           se_eff     = round(m_eff$se.rb,    4),
           pct_change = round(100 * (m_eff$se.rb - m_no_eff$se.rb)
                              / m_no_eff$se.rb, 1))
#>                     cell se_no_eff se_eff pct_change
#> factor(w_ideology)1    1    0.0128 0.0129        0.6
#> factor(w_ideology)2    2    0.0691 0.0689       -0.3
#> factor(w_ideology)3    3    0.0083 0.0084        0.6
#> factor(w_ideology)4    4    0.0113 0.0117        3.0

Plotting

plot() is a post-estimation method for categorical covs.hte: one point per cell at the conventional point estimate, with the robust bias-corrected CI as an error bar.

plot(rd_ideology)

rdhte forest plot, ideology buckets

sort = TRUE reorders cells along the x-axis by their point estimate – often the more useful default for ranking-style narratives:

plot(rd_ideology, sort = TRUE)

sorted forest plot

The returned object is a ggplot, so additional layers compose naturally. For example, a custom title with horizontal orientation:

p <- plot(rd_ideology, sort = TRUE,
          title = "Heterogeneity by ideology bucket",
          ylab  = "Sharp RD ITT")

custom-themed forest plot

if (requireNamespace("ggplot2", quietly = TRUE)) {
  p + ggplot2::theme_minimal(base_size = 11) +
      ggplot2::coord_flip()
}

custom-themed forest plot

Building publication-ready tables

rdhte exposes the per-cell results through tidy() and a one-row summary through glance() (both broom-compatible). Combined with knitr::kable() / xtable::xtable() the same numbers can be turned into Markdown, HTML, or LaTeX tables for papers.

(A) per-cell table from a single call

tidy() returns one row per cell with the conventional point estimate, robust BC standard error, z / p-value, BC confidence interval, and per-side h and Nh. Renaming or formatting columns before kable() gives a clean publication panel:

tab_A <- tidy(rd_ideology)
tab_A
#>   term     estimate  std.error  statistic      p.value      conf.low  conf.high
#> 1    1  0.088652653 0.01279375  6.8186555 9.189647e-12  0.0621608620 0.11231142
#> 2    2 -0.024026038 0.06914491 -0.8533574 3.934611e-01 -0.1945268631 0.07651622
#> 3    3  0.025632358 0.00831174  1.8974042 5.777461e-02 -0.0005199808 0.03206144
#> 4    4  0.007008009 0.01133009  0.8840432 3.766729e-01 -0.0121902781 0.03222286
#>   estimate.bc     h.left    h.right n.eff.left n.eff.right
#> 1  0.08723614 0.11696532 0.11696532       5036        4840
#> 2 -0.05900532 0.05645238 0.05645238        181         156
#> 3  0.01577073 0.08564972 0.08564972       3816        4202
#> 4  0.01001629 0.09037549 0.09037549        713         384
knitr::kable(
  tab_A[, c("term", "estimate", "std.error", "conf.low", "conf.high",
            "n.eff.left", "n.eff.right", "h.left", "h.right")],
  digits = c(NA, 3, 3, 3, 3, 0, 0, 3, 3),
  col.names = c("Cell", "Estimate", "SE", "CI lo", "CI hi",
                "Nh-", "Nh+", "h-", "h+"),
  caption = "Per-cell RD effects by ideology bucket"
)
Per-cell RD effects by ideology bucket
Cell Estimate SE CI lo CI hi Nh- Nh+ h- h+
1 0.089 0.013 0.062 0.112 5036 4840 0.117 0.117
2 -0.024 0.069 -0.195 0.077 181 156 0.056 0.056
3 0.026 0.008 -0.001 0.032 3816 4202 0.086 0.086
4 0.007 0.011 -0.012 0.032 713 384 0.090 0.090

glance() complements tidy() with a one-row description of the fit (N, polynomial orders, kernel, VCE, bandwidth selector):

glance(rd_ideology)
#>       n n.left n.right cutoff p q     kernel vce vce_select bwselect level
#> 1 39534  19723   19811      0 1 2 Triangular CR1        cr1    mserd    95
#>   n.terms covs.continuous
#> 1       4           FALSE

(B) cell x specification comparison

Fix the covs.hte argument and vary vce across columns to see how the choice of variance estimator moves the per-cell standard errors.

specs <- list(HC0 = list(vce = "hc0"),
              HC2 = list(vce = "hc2"),
              HC3 = list(vce = "hc3"),
              CR1 = list(vce = "cr1", cluster = cluster_var))

fit_one <- function(args) {
  args <- c(list(y = y, x = x, covs.hte = factor(w_ideology)), args)
  do.call(rdhte, args)
}

cells  <- sort(unique(w_ideology))
mat_pt <- sapply(specs, function(s) tidy(fit_one(s))$estimate)
mat_se <- sapply(specs, function(s) tidy(fit_one(s))$std.error)
rownames(mat_pt) <- rownames(mat_se) <- as.character(cells)

mat_pt
#>            HC0          HC2          HC3          CR1
#> 1  0.088623908  0.088625020  0.088625979  0.088652653
#> 2 -0.024247190 -0.023878639 -0.023577737 -0.024026038
#> 3  0.025784637  0.025791260  0.025797429  0.025632358
#> 4  0.007022683  0.007006824  0.006991369  0.007008009
mat_se
#>           HC0        HC2         HC3        CR1
#> 1 0.011736372 0.01174108 0.011746082 0.01279375
#> 2 0.069234017 0.07084168 0.072492654 0.06914491
#> 3 0.008274419 0.00827868 0.008283159 0.00831174
#> 4 0.011338034 0.01136898 0.011400526 0.01133009

A common publication layout interleaves the point estimate and its SE in parentheses below it:

n_cells <- nrow(mat_pt)
out     <- data.frame(matrix(NA_character_, nrow = 2 * n_cells,
                             ncol = ncol(mat_pt)))
for (i in seq_len(n_cells)) {
  out[2 * i - 1, ] <- sprintf("%.3f", mat_pt[i, ])
  out[2 * i,     ] <- sprintf("(%.3f)", mat_se[i, ])
}
out <- cbind(Cell = rep(rownames(mat_pt), each = 2), out)
out$Cell[seq(2, nrow(out), by = 2)] <- ""
colnames(out)[-1] <- colnames(mat_pt)
knitr::kable(out, caption = "Per-cell estimates by variance option (SE in parentheses)")
Per-cell estimates by variance option (SE in parentheses)
Cell HC0 HC2 HC3 CR1
1 0.089 0.089 0.089 0.089
(0.012) (0.012) (0.012) (0.013)
2 -0.024 -0.024 -0.024 -0.024
(0.069) (0.071) (0.072) (0.069)
3 0.026 0.026 0.026 0.026
(0.008) (0.008) (0.008) (0.008)
4 0.007 0.007 0.007 0.007
(0.011) (0.011) (0.011) (0.011)

(C) LaTeX export

xtable or kableExtra can render the same data frame as LaTeX. The snippet below produces a paper-ready table; uncomment the writeLines() call to write to a file:

tex <- xtable::xtable(
  tab_A[, c("term", "estimate", "std.error", "conf.low", "conf.high")],
  digits  = c(0, 0, 3, 3, 3, 3),
  caption = "Per-cell RD effects by ideology bucket",
  label   = "tab:rdhte-ideology"
)
print(tex, include.rownames = FALSE, booktabs = TRUE,
      caption.placement = "top")
#> % latex table generated in R 4.6.0 by xtable 1.8-8 package
#> % Tue May 26 09:50:57 2026
#> \begin{table}[ht]
#> \centering
#> \caption{Per-cell RD effects by ideology bucket} 
#> \label{tab:rdhte-ideology}
#> \begin{tabular}{lrrrr}
#>   \toprule
#> term & estimate & std.error & conf.low & conf.high \\ 
#>   \midrule
#> 1 & 0.089 & 0.013 & 0.062 & 0.112 \\ 
#>   2 & -0.024 & 0.069 & -0.195 & 0.077 \\ 
#>   3 & 0.026 & 0.008 & -0.001 & 0.032 \\ 
#>   4 & 0.007 & 0.011 & -0.012 & 0.032 \\ 
#>    \bottomrule
#> \end{tabular}
#> \end{table}
# writeLines(capture.output(print(tex, include.rownames = FALSE,
#                                booktabs = TRUE,
#                                caption.placement = "top")),
#            con = "rdhte_table_A.tex")

Replicating rdrobust

Average effects from rdhte() and rdrobust() will not match under default settings, because the two packages use different defaults for rho and vce:

summary(rdhte(y = y, x = x))
#> Sharp RD Average Treatment Effect.
#> 
#> Number of Obs.                39534
#> BW type                       mserd
#> Kernel                   Triangular
#> VCE method                      HC3
#> 
#> Number of Obs.                19723        19811
#> Eff. Number of Obs.            9536         9550
#> Order est. (p)                    1            1
#> Order bias  (q)                   2            2
#> BW est. (h)                   0.097        0.097
#> 
#> ========================================================================
#>                    Point        Robust Inference
#>                 Estimate           z    Pr(>|z|)      [ 95% C.I. ]      
#> ------------------------------------------------------------------------
#>            T       0.051       7.071       0.000   [0.035 , 0.062]      
#> ========================================================================
summary(rdrobust(y = y, x = x))
#> Call: rdrobust
#> 
#> Sharp RD estimates using local polynomial regression.
#> 
#> Number of Obs.                39534
#> BW type                       mserd
#> Kernel                   Triangular
#> VCE method                       NN
#> 
#>                                Left        Right
#> Number of Obs.                19723        19811
#> Eff. Number of Obs.            9546         9560
#> Order est. (p)                    1            1
#> Order bias (q)                    2            2
#> BW est. (h)                   0.097        0.097
#> BW bias (b)                   0.159        0.159
#> rho (h/b)                     0.608        0.608
#> Unique Obs.                   19660        19746
#> 
#> =====================================================================
#>                    Point    Robust Inference
#>                 Estimate         z     P>|z|      [ 95% C.I. ]       
#> ---------------------------------------------------------------------
#>      RD Effect     0.051     8.894     0.000     [0.039 , 0.062]     
#> =====================================================================

To replicate exactly with rdrobust, set rho = 1, choose a matching vce, and enforce the same bandwidth:

summary(rdhte(y = y, x = x, h = 0.1, vce = "hc3"))
#> Sharp RD Average Treatment Effect.
#> 
#> Number of Obs.                39534
#> BW type                      Manual
#> Kernel                   Triangular
#> VCE method                      HC3
#> 
#> Number of Obs.                19723        19811
#> Eff. Number of Obs.            9829         9843
#> Order est. (p)                    1            1
#> Order bias  (q)                   2            2
#> BW est. (h)                   0.100        0.100
#> 
#> ========================================================================
#>                    Point        Robust Inference
#>                 Estimate           z    Pr(>|z|)      [ 95% C.I. ]      
#> ------------------------------------------------------------------------
#>            T       0.051       7.194       0.000   [0.035 , 0.062]      
#> ========================================================================
summary(rdrobust(y = y, x = x, h = 0.1, rho = 1, vce = "hc3"))
#> Call: rdrobust
#> 
#> Sharp RD estimates using local polynomial regression.
#> 
#> Number of Obs.                39534
#> BW type                      Manual
#> Kernel                   Triangular
#> VCE method                      HC3
#> 
#>                                Left        Right
#> Number of Obs.                19723        19811
#> Eff. Number of Obs.            9829         9843
#> Order est. (p)                    1            1
#> Order bias (q)                    2            2
#> BW est. (h)                   0.100        0.100
#> BW bias (b)                   0.100        0.100
#> rho (h/b)                     1.000        1.000
#> Unique Obs.                   19723        19811
#> 
#> =====================================================================
#>                    Point    Robust Inference
#>                 Estimate         z     P>|z|      [ 95% C.I. ]       
#> ---------------------------------------------------------------------
#>      RD Effect     0.051     7.194     0.000     [0.035 , 0.062]     
#> =====================================================================

The same trick reproduces the per-cell rdrobust estimates from a single rdhte call with per-cell bandwidths:

summary(rdhte(y = y, x = x, covs.hte = w_left,
              h = c(0.078, 0.116), vce = "hc3"))
#> Sharp RD Heterogeneous Treatment Effects: Subgroups.
#> 
#> Number of Obs.                39534
#> BW type                      Manual
#> Kernel                   Triangular
#> VCE method                      HC3
#> 
#> Number of Obs.                19723        19811
#> Order est. (p)                    1            1
#> Order bias  (q)                   2            2
#> 
#> ================================================================================================================
#>                    Point        Robust Inference
#>       w_left    Estimate           z    Pr(>|z|)      [ 95% C.I. ]             Nh-       Nh+        h-        h+
#> ----------------------------------------------------------------------------------------------------------------
#>            0       0.021       1.522       0.128  [-0.003 , 0.027]            4388      4453     0.078     0.078
#>            1       0.089       7.435       0.000   [0.064 , 0.110]            5010      4799     0.116     0.116
#> ================================================================================================================
summary(rdrobust(y = y[w_left == 1], x = x[w_left == 1],
                 h = 0.116, rho = 1, vce = "hc3"))
#> Call: rdrobust
#> 
#> Sharp RD estimates using local polynomial regression.
#> 
#> Number of Obs.                17596
#> BW type                      Manual
#> Kernel                   Triangular
#> VCE method                      HC3
#> 
#>                                Left        Right
#> Number of Obs.                 9436         8160
#> Eff. Number of Obs.            5010         4799
#> Order est. (p)                    1            1
#> Order bias (q)                    2            2
#> BW est. (h)                   0.116        0.116
#> BW bias (b)                   0.116        0.116
#> rho (h/b)                     1.000        1.000
#> Unique Obs.                    9436         8160
#> 
#> =====================================================================
#>                    Point    Robust Inference
#>                 Estimate         z     P>|z|      [ 95% C.I. ]       
#> ---------------------------------------------------------------------
#>      RD Effect     0.089     7.435     0.000     [0.064 , 0.110]     
#> =====================================================================

See also

Reference

Calonico, S., Cattaneo, M.D., Farrell, M.H., Palomba, F., and Titiunik, R. (2025). Treatment Effect Heterogeneity in Regression Discontinuity Designs. arXiv:2503.13696.

Granzier, R., Pons, V., and Tricaud, C. (2023). Coordination and Bandwagon Effects: How Past Rankings Shape the Behavior of Voters and Candidates. American Economic Journal: Applied Economics 15(4): 177-217.

These binaries (installable software) and packages are in development.
They may not be fully stable and should be used with caution. We make no claims about them.
Health stats visible at Monitor.