You have a binary-treatment panel and several plausible estimators: classic difference-in-differences (DID), synthetic difference-in-differences (SDID), synthetic control (SC), matrix completion (MC), and the Triply RObust Panel (TROP) estimator of Athey, Imbens, Qu & Viviano (2025). Each lives in its own package with its own data format, its own notion of a “treatment effect” and its own plot. Comparing them on the same dataset means re-shaping data five times and reconciling five different output objects by hand.
cfcompare does that reconciliation for you. You pass one
long-format data frame and a vector of method names; you get back one
tidy table of ATTs and one plot.
sim_panel() draws from a low-rank factor model
Y_it(0) = alpha_i + beta_t + L_it + eps_it and adds a
constant treatment effect to treated cells, so we know the ground
truth.
df <- sim_panel(N = 16, T = 9, n_treated = 3, t0 = 7, rank = 2,
att = 2, noise = 1, seed = 1)
head(df)The columns are the unit id (id), time (t),
observed outcome (y), the 0/1 treatment indicator
(w), and the oracle untreated outcome (y0, for
evaluation only). The true ATT here is 2.
cmp <- panel_compare(
df,
outcome = "y", treatment = "w", unit = "id", time = "t",
methods = c("DID", "MC", "TROP"),
se = "jackknife",
control = trop_control(n_cv_cells = 12L, cv_cycles = 1L)
)
cmpThe att element is a cf_att_tbl: one row
per method, all on the same schema.
Here methods was given explicitly. If you omit it,
panel_compare() runs its default set — DID,
SDID, MC, TROP, and
DIFP (the native engines plus synthetic DID). To run the
defaults without a particular method, pass exclude rather
than retyping the list,
e.g. panel_compare(..., exclude = "DIFP"). The same
methods / exclude arguments work in
panel_rmse() and rmse_curve().
Every estimate carries the number of treated cells and units, the
estimated rank of the low-rank component L (for the native
engines), and a note column that records anything that was
skipped.
autoplot() on the comparison gives a forest plot of the
ATTs with their confidence intervals:
plot_counterfactual() overlays the observed
treated-average path against each estimator’s predicted untreated path,
so you can see where the methods disagree, not just by how
much:
trop() is the workhorse. Called on its own it
cross-validates the three penalties — time-weight decay, unit-weight
decay, and the nuclear-norm penalty on L — by leave-one-out
on the control cells.
fit <- trop(df, "y", "w", "id", "t", se = "jackknife",
control = trop_control(n_cv_cells = 12L, cv_cycles = 1L))
fitThe fitted object exposes the per-cell effects, the estimated untreated-outcome matrix, and the selected penalties:
The TROP program nests DID and matrix completion. Fixing the penalties by hand matches them numerically, which is useful as a sanity check:
did <- trop(df, "y", "w", "id", "t",
lambda = list(time = 0, unit = 0, nn = Inf))
mc <- trop(df, "y", "w", "id", "t",
lambda = list(time = 0, unit = 0, nn = 5))
c(DID = did$att, MC = mc$att, TROP = fit$att)With lambda_nn = Inf and uniform weights the low-rank
term vanishes and the fit is ordinary two-way fixed effects; with a
finite lambda_nn and uniform weights it is matrix
completion; letting cross-validation pick the unit and time weights
gives the full TROP estimator.
If you already ran an estimator elsewhere — say synthdid
directly — coerce it onto the shared schema with as_att()
and combine it with native fits:
The native engines (DID, MC, TROP) accept block,
staggered, and non-absorbing binary
treatments: treatment is simply the 0/1 indicator of active
treatment in each cell. SDID and SC are
defined for block designs only and are skipped, with a note, on other
designs.
Standard errors from the native engines are jackknife (leave-one-treated-unit-out) when there are at least two treated units, and placebo when there is a single treated unit. These are practical approximations; see Section 6 of the paper for the estimator’s formal inference theory.
The native solver runs on base R by default, but two settings speed
it up on larger panels (both via trop_control()):
svd — the soft-impute step uses a
truncated SVD by default
(svd = "truncated"), computing only the leading singular
triplets with RSpectra when it is installed (and falling
back to the full base-R svd() otherwise). The TROP paper
explicitly permits a truncated decomposition for the low-rank step, and
it matches the full SVD to numerical tolerance. Pass
svd = "full" to force the exact full decomposition; that is
what the README’s numerical-agreement checks against the official Python
package use, so the comparison is exact rather than up to truncation
tolerance.workers — the bootstrap / jackknife /
placebo standard errors, the cross-validation cells, and the
panel_rmse() placebo runs are run in parallel when
workers > 1 (using
future.apply/future, if installed). Estimates
remain reproducible given seed.Athey, S., Imbens, G. W., Qu, Z., & Viviano, D. (2025). Triply Robust Panel Estimators. arXiv:2508.21536.