Skip to contents

compdid estimates difference-in-differences effects in repeated cross-section settings with compositional changes. The repository name is comp_did; the R package name is compdid because R package names cannot contain underscores. The main user-facing function is comp_did().

Package Status: Alpha Version

Version 0.1.0 is an alpha release of compdid. The core estimators, preprocessing tools, inference routines, application data, and replication checks are implemented and functional, but the package remains under active development. APIs, defaults, and diagnostics may change as the package matures.

Feedback and bug reports are welcome through the GitHub Issues page. The package site is configured at https://psantanna.com/comp_did/.

What It Provides

  • A friendly comp_did() front door with a familiar formula-style interface: yname, tname, dname, xformula, and data.
  • A validated preprocessing layer through make_did_dp() for mixed continuous, unordered discrete, and ordered discrete covariates.
  • Doubly robust non-stationary and stationary repeated cross-section DiD estimators.
  • A stationarity test comparing the two ATT estimators.
  • Local-polynomial nuisance estimation backed by compiled C++.
  • Cross-fitted DML nuisance estimation using either mlr3 learners or custom learner functions.
  • Unit, integration, optional-backend, and light stress tests.

Installation

From GitHub:

install.packages("remotes")
remotes::install_github("pedrohcgs/comp_did")

From the package source directory:

install.packages(".", repos = NULL, type = "source")

For development:

devtools::load_all(".")

Quick Start

library(compdid)

set.seed(123)
d <- simulate_comp_did(n = 1000)

out <- comp_did(
  y ~ x1 + x2 + x3 + x4 + x5 + x6,
  data = d,
  tname = "post",
  dname = "d",
  K = 2,
  seed = 123,
  stationary = TRUE,
  stationarity_test = TRUE
)

out
summary(out)
as.data.frame(out)
confint(out)

The default front door uses cross-fitted DML with built-in custom learners, so the short call above is enough for a first run. Use nuisance_method = "loo" when you specifically want the local-polynomial nuisance path.

Users who prefer column-name arguments can write the same call as:

out <- comp_did(
  yname = "y",
  tname = "post",
  dname = "d",
  xformula = ~ x1 + x2 + x3 + x4 + x5 + x6,
  data = d,
  K = 2,
  seed = 123
)

Local-Polynomial Path

For all-numeric continuous covariates, the formula interface can also be used with nuisance_method = "loo". The right-hand-side variables are treated as continuous local-polynomial covariates:

out_lp_cont <- comp_did(
  y ~ x1 + x2,
  data = d,
  tname = "post",
  dname = "d",
  nuisance_method = "loo"
)

For mixed-data nonparametric kernels, pass covariate blocks explicitly:

out_lp <- comp_did(
  yname = "y",
  tname = "post",
  dname = "d",
  data = d,
  continuous = c("x1", "x2"),
  unordered = c("x3", "x4"),
  ordered = c("x5", "x6"),
  nuisance_method = "loo",
  list_control_ps = list(n_start = 1, ps_min = 1e-5, lp_order = 1),
  list_control_or = list(n_start = 1, bw_constrained = FALSE, or_order = 1)
)

This distinction matters. Formula-created factor dummies are not valid unordered-discrete kernels. If nuisance_method = "loo" is used with a formula, the package checks that all formula covariates are numeric and stops otherwise.

The local-polynomial path is more computationally intensive than custom DML and is best used with explicit controls during exploratory work.

The default local-polynomial GPS path uses local_mode = "fast". It starts each local fit from one global parametric multinomial logit, uses overflow-safe log-sum-exp optimizer math, applies a light gradient gate, and normalizes clipped GPS rows while preserving the probability floor. Every returned GPS row is a valid four-cell probability vector.

list_control_ps = list(
  n_start = 1,
  ps_min = 1e-5,
  lp_order = 1,
  local_mode = "fast"
)

For a cold-start optimizer with the same stable math and normalized GPS rows, use mnl_start = "zero".

For the previous-fit warm start, set list_control_ps = list(..., warm_start = TRUE) or list_control_ps = list(..., mnl_start = "previous"). This reuses the previous local multinomial-logit solution as the next optimizer start and can be much faster, but it may change finite-iteration point estimates slightly.

Advanced users can tighten the local multinomial-logit optimizer with mnl_reltol, mnl_gradtol, mnl_maxit, and mnl_stable inside list_control_ps. This can reduce warm-start drift in some hard local neighborhoods, but it can also spend most of the warm-start speedup. GPS row normalization is enabled by default. Setting normalize_ps = FALSE is a low-level replication or sensitivity control; use it only when the analysis explicitly needs clipped but not row-normalized GPS fitted values.

Parametric Path

The parametric first-step engine is available through nuisance_method = "parametric". Its default parametric_method = "improved" uses the pairwise inverse-probability-tilting and weighted least-squares construction from the Sant’Anna-Xu parametric note. The alternative parametric_method = "base" uses a multinomial-logit GPS and cell-specific OLS outcome regressions, with the first-step influence-function corrections included in analytic and multiplier-bootstrap inference.

out_par <- comp_did(
  y ~ x1 + x2 + x3 + x4 + x5 + x6,
  data = d,
  tname = "post",
  dname = "d",
  nuisance_method = "parametric"
)

out_par_base <- comp_did(
  y ~ x1 + x2 + x3 + x4 + x5 + x6,
  data = d,
  tname = "post",
  dname = "d",
  nuisance_method = "parametric",
  parametric_method = "base"
)

The parametric path currently uses the package’s ratio-normalized stabilized weights and does not implement non-unit sampling weights.

When stationary = TRUE or stationarity_test = TRUE, the parametric path refits the stationary Sant’Anna-Zhao-style first steps instead of post-processing the unrestricted generalized propensity scores. Thus the stationary benchmark uses a binary treatment propensity score and the corresponding parametric influence function: logit/OLS corrections for parametric_method = "base" and IPT/WLS orthogonalized inference for parametric_method = "improved".

Application Guides

The Sequeira (2016) application data used in Table 4 of Sant’Anna and Xu (2026) are included as package data:

data("sequeira_bribes", package = "compdid")
dim(sequeira_bribes)
data("sequeira_application_reference", package = "compdid")

The paper reports the following Table 4 point estimates:

Estimator Prob(bribe) Log(1 + bribe) Log(1 + bribe/shpt.val.) Log(1 + bribe/shpt.tonn.)
TWFE - Linear Spec. -0.429 -3.748 -0.011 -1.914
TWFE - Interactive Spec. -0.296 -2.928 -0.010 -1.597
DR DiD, no-compositional changes -0.275 -2.542 -0.014 -0.918
DR DiD, robust to compositional changes -0.307 -2.888 -0.027 -1.131

There are two application guides, with different goals.

For external researchers and new empirical work, start with the best-practice guide:

vignette("sequeira-application", package = "compdid")

That guide uses the Sequeira data to demonstrate the public package workflow: comp_did(), DML nuisance estimation by default, clustered inference, multiple outcomes, and optional local-polynomial controls. It is not intended to numerically reproduce Table 4. The current package uses more guarded defaults than the historical paper scripts, including GPS row normalization and more stable local multinomial-logit controls, so estimates may move slightly.

For numerical replication of the published paper output, use:

vignette("sequeira-application-replication", package = "compdid")

That guide uses the archived paper-final nuisance fitted values stored in sequeira_application_reference, then recomputes the ATT estimators, stationarity tests, and 9999-draw clustered bootstrap with current exported package functions. It is the right guide for checking the paper numbers.

Fast verification from the packaged data:

Rscript dev/verify_application_table4.R

The fast verifier recomputes the two TWFE rows from sequeira_bribes and checks that the packaged Table 4 reference rows round to the printed paper values.

Exact replication of the DR Table 4 rows, clustered standard errors, and stationarity-test p-values is available as an installed script:

Rscript "$(Rscript -e 'cat(system.file("replication", "verify_sequeira_saved_nuisance.R", package = "compdid"))')"

This is the deterministic replication check to use in routine audits. It does not rerun the multi-hour local-polynomial cross-validation step.

To write the full application output files and paper-check CSVs with the same packaged paper-final nuisance fits:

Rscript "$(Rscript -e 'cat(system.file("replication", "sequeira_application.R", package = "compdid"))')" \
  --saved-nuisance \
  --out-dir=/tmp/compdid_sequeira_exact

To run the paper-scale nonparametric application replication with cross-validated local-polynomial nuisances and 9999 cluster-bootstrap draws, use the slow full-CV audit script:

Rscript "$(Rscript -e 'cat(system.file("replication", "sequeira_application.R", package = "compdid"))')" \
  --full \
  --out-dir=/tmp/compdid_sequeira_full

This from-scratch CV path is an audit path, not the recommended user workflow. The historical GPS bandwidth optimizer is toolchain-sensitive and can select different local minima on modern R/C++ toolchains. The script fails rather than silently claiming a paper match when that happens. Use --saved-nuisance for exact paper replication.

For a fresh full-sample application run with the current public local-polynomial engine:

Rscript "$(Rscript -e 'cat(system.file("replication", "sequeira_application.R", package = "compdid"))')" \
  --full \
  --public-locpol \
  --strict-locpol \
  --out-dir=/tmp/compdid_sequeira_package

Verification

Current release verification status for version 0.1.0:

devtools::test()
devtools::check(
  args = c("--no-manual", "--as-cran")
)
source("dev/audit_locpol_regression.R")
Rscript dev/release_audit.R
Rscript dev/benchmark_suite.R

The package currently passes local release checks with 0 errors, 0 warnings, and 0 notes on the local macOS/R 4.6 setup.

The local-polynomial regression audit checks eight fixed-bandwidth fixtures against the normalized default. Run it with --warm-start to benchmark the previous-fit fast path, --strict-warm-start for stricter previous-fit convergence, and --global-start for the plain parametric start:

Rscript dev/audit_locpol_regression.R --warm-start --strict-warm-start --global-start

Use the benchmark suite to track runtime, returned-object size, optional bench allocation estimates, GPS normalization diagnostics, and optional np::npreg comparison rows:

Rscript dev/benchmark_suite.R --np --bench --out=/tmp/compdid_benchmark.csv
Rscript dev/profile_locpol.R --modes --out=/tmp/compdid_locpol_profile.csv
Rscript dev/performance_guardrails.R

dev/release_audit.R is the one-command local release gate. It runs the unit tests, R CMD check with vignette rebuilding, Sequeira saved-nuisance verification, local-polynomial regression audit, performance guardrails, stress scripts, and a quick benchmark smoke test. GitHub Actions uses the same script so local and CI checks stay aligned.

The pkgdown website can be built locally with:

Rscript -e 'pkgdown::build_site()'

The package website is configured for https://psantanna.com/comp_did/.

To replicate the original pedrohcgs/DiD_Compositional_Changes simulation and Sequeira application workflows using the package API only, run:

Rscript dev/replicate_original_repo.R --compare-saved

See dev/original_replication.md for the full and smoke-test commands.

License

compdid is licensed under the MIT license. Copyright (c) 2026 Pedro H. C. Sant’Anna and Qi Xu.

Paper Alignment

The package follows Sant’Anna and Xu (2026), “Difference-in-Differences with compositional changes”, Journal of Econometrics, 253, 106147. The non-stationary estimator is the robust repeated-cross-section ATT estimator that allows compositional changes. The stationary estimator is the no-compositional-change comparison, and drdid_stationarity_test() implements the Hausman-type comparison between the two.

Development Notes

  • The package deliberately keeps mlr3, mlr3learners, and tuning packages in Suggests; they are required only for the dml_backend = "mlr3" path.
  • Non-unit sampling weights currently fail loudly in the core doubly robust second stage instead of being silently ignored.
  • The C++ local outcome-regression path has a defensive weighted-mean fallback for singular local weighted least-squares neighborhoods.