Heterogeneous Treatment Effects in Regression Discontinuity Designs
Overview
Understanding heterogeneous causal effects based on pretreatment covariates is a crucial step in modern empirical work in data science. Building on the recent developments in Calonico et al (2025) doi:10.48550/arXiv.2503.13696, this package provides tools for estimation and inference of heterogeneous treatment effects in Regression Discontinuity (RD) Designs. The package includes two main commands: 'rdhte' to conduct estimation and robust bias-corrected inference for conditional RD treatment effects (given choice of bandwidth parameter); 'rdbwhte', which implements automatic bandwidth selection methods; and 'rdhte_lincom' to test linear combinations of parameters.
First systematic audit pass following the 0.1.0 pre-release. Fourteen
HIGH-severity and seven MED-severity bugs were fixed across the R and
Stata code, plus a new data = argument and a multi-script verification
harness. A 2026-05-01 Windows verification pass uncovered three further
HIGH-severity bugs (Stata weights not forwarded to bandwidth selection;
R rdhte_lincom silently applied multcomp's single-step multiplicity
adjustment; harness orchestrator regex/CSV-staging gaps) which are
included below.
A subsequent expert-review pass on the Stata code on 2026-05-01 PM
uncovered six further HIGH-severity issues, surfaced one previously
silent latent inference bug in the no-covs_hte path, and bumped the
Stata floor from 14 to 16 (frames). All are included in this version.
Show all changes in 0.2.0Hide the rest of the changes in 0.2.0
**Stata 16+ now required** (was Stata 14+). Driven by the migration
from preserve to frames for the in-program working sample.
New features
data =argument added tordhte()andrdbwhte(). When supplied,y,x,covs.hte,covs.eff,weights,cluster, andsubsetmay be given as bare variable names referring to columns ofdata.covs.hteadditionally accepts a one-sided formula (e.g."~ z1 + z2") whose variables are looked up indatafirst. Mirrors the rdrobust 4.0.0 pattern. Implemented via the internal.rdhte_resolve_data()NSE helper inR/helpers.R.
Bug fixes (R)
- **Undefined
Win no-covs.hte+covs.effbranch.** The internal regression formula referencedWeven though no heterogeneity variable was supplied.rdhte()now usesY ~ T*Xp + covs(andXqfor the q-fit). Previously crashed atlm(). - **Character / formula-string
covs.htepath.** Three separate bugs were fixed: a missing brace pair that leftfml.txtundefined for~-prefixed input; the formula's environment now usesparent.frame()so caller-frame variables resolve; and thedata.frame(covs.hte = model.matrix(...))step was rebuilt so the downstreamgrep("^covs.hte", ...)finds the correct columns (previously resulted in "subscript out of bounds" deeper inlm()). The illustration'scovs.hte = "w_left*w_strength"example was silently affected by these and now produces correct output. - **Bandwidth typo
is.null(h.r) & is.null(h.r)** (twice the same argument) is nowis.null(h.l) & is.null(h.r). - **Matrix-as-vector indexing
h.lev[l]** in three locations acrossrdhte()andrdbwhte(): the right-side effective-sample-size count was using the **left** bandwidth wheneverbwselect = "msetwo"or per-grouph.l/h.rwere supplied. Now usesh.lev[l, 1]andh.lev[l, 2]. - **
Nh.levalways 0 in continuous-covs.hteauto-bw branch** becauseh[1]/h[2]wasNULL. Now usesh.lev[1,1]/h.lev[1,2]. - **
out$hshape inconsistency** between branches (vector in no-covs.hte, matrix incovs.hte). All branches now return a consistentn.lev × 2matrix. - **Slot name typo
W.names. = W.names** (trailing dot) sosummary.rdhte()could not findx$W.namesfor the continuous case. Renamed toW.names. - **Bandwidth-vector validation** check
length(h.l) > 1 & length(h.r)always evaluatedTRUE(barelength(h.r)coerces toTRUEwhen positive). Nowlength(h.l) > 1 | length(h.r) > 1correctly errors on mismatched lengths. - **
do.call-unsafe NSE.**deparse(substitute(covs.hte))returned multi-line output whencovs.htewas passed as a value rather than a symbol (e.g. viado.call), causingpaste()recycling to inflateW.namesto length-126 and thenames(tau.hat.se) <- W.namesassignment to error. The deparse output is now collapsed to one line and length-capped (>100 chars falls back to the placeholder"covs.hte"). N.levis now consistentlyc(N_l, N_r)across all branches; the categorical-covs.htebranch previously storedtable(W.covs), which madesummary.rdhte()show wrong "Number of obs" for any group count other than two.subset =accepts both logical and integer indices (matches rdrobust 4.0.0); previously only logical worked.rdbwhte()gainedweights =andsubset =arguments to matchrdhte()'s feature surface, plus the same string-formulacovs.htehandling.weights =is now forwarded to all internalrdbwselect()calls (previously dropped silently in three locations).rdbwhte()no-covs.htebranch'scovs.cont <- FALSEflipped toTRUEso the print path matchesrdhte().rdbwhte()'s continuous-covs.htebranch now returnsNhas a1 x 2matrix (consistent with the factor case) rather than a length-2 vector.rdhte_lincom(): validatelevelis in[1, 100)so the common fraction-vs-percentage mistake (e.g.level = 0.95) errors clearly rather than silently producing a tiny CI.rdhte_lincom(): column renamedt_stat->z_stat. The values were already z-statistics (multcomp'sglhtuses a Gaussian approximation by default); the column label is now correct.rdhte_lincom(): removed deadsesvariable and a commented-outsecolumn.- **
rdhte_lincom()p-values were silently multiplicity-adjusted.**summary.glht()defaults totest = adjusted("single-step")whenever the user passes more than one hypothesis, so thep_valuecolumn in theindividualdata frame was an FWER-adjusted p-value rather than the per-hypothesis two-sided z-test the docstring promises (and that Stata'slincomreports). For >1 hypothesis the adjusted values were ~2x the raw values; the single-hypothesis case was already correct. Now passestest = adjusted(type = "none"). Point estimates and CI bounds are unchanged. checkup_rdhte_basic.Rwas emittingr$N[1]/r$N[2](full-sample left/right counts, post-na.omit) for theN_l/N_rcross-language diff fields, which mismatched Stata'se(tau_N)(effective N within bandwidth). Now emitsr$Nh[1,1]/r$Nh[1,2]so both sides compare the same quantity.- **
Nhshape inconsistency in the continuous-covs.htebranch.** Atrdhte.R:509the continuous (noW.lev) path returnedNhas a length-2 vector while every other branch returned a1 x 2(orn.lev x 2) matrix. Downstream code that indexedNh[i, j]uniformly -- including the Akhtari/Moreira/Trucco replication script -- broke on the continuous path. Now matches the rest of the branches withmatrix(c(Nh.lev.l, Nh.lev.r), 1, 2). Values are unchanged; positional access (Nh[1],Nh[2]) still works.
Bug fixes (Stata)
- **
rdhte y x, vce(hc1)errored atregress.** Stata'sregressonly acceptsvce(robust), notvce(hc1). The vce string is now translated tovce(robust)for the internal regress calls (display still shows "HC1"). - **
rdbwhte ... vce(cluster c)produced a different bandwidth from the auto-BW insiderdhte ... vce(cluster c)** because rdbwhte was forwarding the user's raw vce string (and forcingvce_select = "hc0"internally) while rdhte built a propervce_rdbwlocal. Both now use the samevce_rdbwconstruction. rdbwhteereturnede(cmd) = "rdhte"; now correctly"rdbwhte".- CI bounds at
rdhte.ado:712no longer hard-code 1.96; now useinvnormal(1 - (1 - level/100)/2)solevel()is honored. - Auto-
i.prefix on a 0/1 variable now warns when the variable is constant (all 0 or all 1) and the heterogeneity analysis collapses to an overall RD ATE. Mirrored acrossrdhte.adoandrdbwhte.ado. rdhte_lincom:lincomis now wrapped incaptureso a malformed hypothesis errors with a clear message instead of silently picking up staler()values from the previous estimation.rdhte_lincomaccepts a newlevel()option (default 95) -- the R version already had it; the Stata version was using the globalset levelonly.- **
weightswas not forwarded to bandwidth selection.**rdhte.ado's two internalrdbwselectcalls (the joint-bandwidth path and the per-level factor path) did not pass the user'sweights()option, sordhte ..., weights(w) bwselect(...)silently chose a different bandwidth from the R-side equivalent. Same shape of bug as thevce_rdbwcluster fix in this release. Now forwardsweights()to every internalrdbwselectinvocation. - **
rdbwhte.adowas missing theweights()option entirely.** The R-siderdbwhte()already acceptedweights =; the Statasyntaxline did not declareweights(string)at all, and the three internalrdbwselectcalls (joint-bandwidth, no-covs_hte, per-factor-level) did not forward it either. Both gaps fixed. checkup_rdhte_stress.dohad fiveif _rc == 0 { di "..."; local ++PASS }lines that triggered Stata'sr(198) program error: code follows on the same line as open braceunder batch mode. All five expanded to multi-line. The same script's NA-handling probe referencede(N_h_l) + e(N_h_r)(neither saved byrdhte.ado); now asserts only_rc == 0. Note:rdhte.ado'se(N)is the pre-NA-drop input row count (line 158), not the post-drop count R'ssum(r$N)reports -- a documented LOW-severity asymmetry left for a follow-up.
Verification harness
Eight new top-level scripts under the project root provide a
verification gate analogous to rdrobust's:
checkup_rdhte_lm.R-- 40 R-only sanity checks. Compares each rdhte result to a manual reconstruction fromcoef.full/vcov.full.checkup_rdhte_basic.{R,do}-- 14 specs of cross-language baseline parity (factor / continuous / cluster / weights / kernel / bwselect / p-q / manual h / bw.joint).checkup_rdhte_extended.{R,do}-- 9 specs combining 3+ non-default options each, with per-(label, level) emission.checkup_rdhte_bw.{R,do}-- 14 specs of standalone-rdbwhte parity, including the cluster path that the H10/H11/H12 fixes target directly.checkup_rdhte_lincom.{R,do}-- 7 hypotheses across 3 specs comparing R'smultcomp::glhtto Stata'slincom.checkup_rdhte_vs_rdrobust.R-- 11 R-only checks confirming the illustration's claim that rdhte reproduces rdrobust under matched settings (h fixed, rho=1, vce="hc3"). Matches to ~1e-15 on tau.checkup_rdhte_stress.R-- 11 R-only edge cases.checkup_rdhte_stress.do-- 9 Stata-portable stress probes.checkup_rdhte_strformula.R-- 6 R-only checks for the string-formulacovs.htepath with caller-frame variables.
run_rdhte_checks.py orchestrates all of the above end-to-end and
reports per-spec |R-Stata|. Marker on success: RUN_RDHTE_CHECKS_OK.
Two orchestrator-level bugs found and fixed during the 2026-05-01
Windows verification pass:
- The key=value parsing regex was
(\w+)=(-?[0-9.eE+\-]+), which did not tolerate the whitespace that Stata's%15.10fdisplayformat inserts between=and the number. Every Stata float field (tau_cl=...,h_l=..., etc.) silently parsed as nothing -- only the unpadded integer fields (N_l=240) got through. The orchestrator was therefore comparing only N_l/N_r, which under the old full-sample-vs-effective N semantic produced ~700-unit "diffs" that masked the real silent-OK pattern. Fixed by adding\s*after=. - Each Stata batch is run from a
C:\Temp\rdhte_stata_*scratch dir, but the.dofiles reference_rdhte_data.csvby relative path.run_STATA()now stages_rdhte_*.csvfrom the project root into the scratch dir before invoking Stata.
Stata expert audit (2026-05-01 PM)
A second pass on the Stata code, severities HIGH / MEDIUM / LOW.
All HIGH fixed; most MEDIUM applied; LOW partially done.
HIGH
- **Latent missing-
e(V)bug in the no-covs_htepath (surfaced by H4).**rdhte y x(no heterogeneity) had been silently returning ane(V)matrix populated with.s -- the no-covs branch never wrotetau_V[1,1]. Thecap ereturn post b Vmasked the resultingr(504) matrix has missing values, leaving downstreamtest/lincomcalls with broken inference. Now correct (matrix tau_V[1,1] = se^2); thecapis gone. - **rdrobust version-check rewritten.** The previous code read line 5 of
c(sysdir_plus)/r/rdrobust.adowithfile openand tokenized to extract the version. Replaced withfindfile rdrobust.ado(works for any adopath location: PERSONAL, SITE, PLUS) plus a regex scan of the first*!...vN.M.Kline. Robust to future rdrobust file-layout changes. - **
vce()parser validation restored.**vce(robust foo),vce(hc1 var),vce(cluster a b), etc., previously had their validation commented out (and silently mis-parsed). Now error withr(198)and a clear message; the legal 2-token forms are exactlyvce(cluster|hc2|hc3 var). - **Tempname migration of all working matrices.** All 14 internal matrices in
rdhte.ado(tau_h,tau_hat,tau_bc,tau_se,tau_V,tau_t,tau_pv,tau_N,tau_h_l/h_r,tau_ci_l/r,CV, plus theb/Vereturn pair) and 2 inrdbwhte.ado(tau_h,tau_N) are nowtempname'd. They no longer leak into the caller's namespace and cannot clobber a user matrix of the same name. - **
rdhte_countvarsno longer double-counts.** The if-elseif chain wasn't mutually exclusive: any pure factor#factor term (e.g.i.W#i.Z) incremented BOTHn_interaction_factorANDn_interaction_continuous. Restructured so each term routes to exactly one bucket. Affected the downstreamquadflag and the BW-routing decision for higher-order factor interactions. - **
rdhte_fvexpanddeclaredrclass.** Previously the program had no class declaration and callers relied on the innerfvexpandcall'sr()values surviving through program return -- accidental, undocumented, and brittle to future Stata. Nowrclass, capturesr(varlist)/r(fvops)insidenobreakbefore any reset, and explicitly returns them.
MEDIUM
- **
preserve->frames(M1).**rdhte.adoandrdbwhte.adonow do all work in an isolated temporary frame:frame put * if touse, into(workframe); cwf workframe; ...; cwf orig_frame; frame drop workframe. Avoids the O(N) preserve disk round-trip and only thetouse=1subset is copied (typically a fraction of the user's dataset). The user's data and frame state are never mutated, even on error. Drives the Stata 16+ floor bump (frames were introduced in Stata 16). - **
e(N_h_l)ande(N_h_r)added (M2).** Per-side bandwidth-effective sample sizes (summed across factor levels). Mirrors rdrobust's convention. The deeper post-NA-drop semantic fore(N)across covs_hte / weights / cluster missingness is documented as a deferred follow-up. - **
qui countconsolidation (M3).** Threequi countpasses collapsed to two (left + right; total via addition). One fewer O(N) pass per call. - **
version 14.0->16.0(M8).** Required by the frames refactor. Bumped across all 5 ados;rdhte_countvars.adoalso gained the missingversiondirective (it had none before).
LOW
- **Dead / commented-out code removed (L1).** ~10 commented blocks across
rdhte.ado(check-it-exists scaffolding for value labels, deadtau_Vcovariance attempts, redundantlocal k = iinside nestedforvalues, etc.) andrdbwhte.ado(orphan*/, value-label scaffolding). - **
e(outcomevar)removed (L2).** Was advertised in the help file but the 2026-04-27 harmonization audit had already removed it; line 855 had silently reintroduced it. Users gete(depvar)per Stata convention. - **
rdhte.sthlpStored results section refreshed (L5).** Removede(outcomevar); addede(N_h_l)ande(N_h_r);e(N)description now correctly says "sample size after listwise deletion on (y, x)" instead of "original number of observations". - **Continuous-W discretization warning (L6).** When the user passes
covs_hte(W)(noi.prefix) andWhas fewer than 10 unique values,rdhtenow emits anote:recommendingi.Wfor subgroup-CATE estimation rather than the (probably unintended) slope-coefficient interpretation.
MEDIUM items deferred (not in this release)
- **M4** (
length(subinstr(...))hash-counting refactor): arithmetic is correct, just opaque. - **M6** (display-formatting magic numbers): pure cleanup, high regression risk on output formatting
The NEWS section is longer than shown here.
From NEWS.md in the release
Install
Health
- OK13 OK2026-08-05
- NOTE12 OK, 1 NOTE2026-08-01
- OK13 OK2026-06-09
- NOTE12 OK, 1 NOTE2026-06-08
- OK14 OK2026-03-10
Documentation
- Help pages
- 17 (10 internal)includes a package overview page
- Examples that run
- 67%4 run in a plain check · 2 never run
- Exported functions with examples
- 100%
- Documented parameters
- 100%
- Return-value docs
- 100%
- Help pages citing references
- 24%
Downloads
Dependencies
Code & Tests
Datasets
People & History
Show 3 peopleHide 3 people
Pick two releases to compare their code metrics.
- 0.2.0Latest
- RR 4.6.0 released · 2026-04-24
- 0.1.0
- RR 4.5.0 released · 2025-04-11
- 0.0.22025-04-04 · diff ↗
- 0.0.12025-03-25
- RR 4.4.0 released · 2024-04-24
Package metadata
- First published
- 2025-03-25
- Total releases
- 4 / 1 yrs
- License
- GPL-3 OSI
- Minimum R
- ≥ 3.5
- Bundled data
- 216 KB / 1 file
- Download size
- 316 KB
Cite
Cite this package
Run in R for the authors' preferred citation:
citation("rdhte")This package has no citation file, so citation() returns this entry built from DESCRIPTION.
Cite the R Observatory
For a number measured here: a download total, a coverage figure, an archival date.
From data release v2026-10-22, which the citation names so these numbers can be found later. More on citing and the projects behind them.