Comparing panel estimators with cfcompare

The problem

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.

library(cfcompare)

A simulated panel

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.

Comparing estimators

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)
)
cmp

The att element is a cf_att_tbl: one row per method, all on the same schema.

cmp$att

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.

Plots

autoplot() on the comparison gives a forest plot of the ATTs with their confidence intervals:

autoplot(cmp)

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:

plot_counterfactual(cmp)

One estimator at a time

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))
fit

The fitted object exposes the per-cell effects, the estimated untreated-outcome matrix, and the selected penalties:

head(fit$tau_cells)
fit$lambda

Recovering special cases

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.

Bringing your own results

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:

library(synthdid)
sd <- synthdid_estimate(Y, N0, T0)

combined <- as_att(list(
  fit,
  as_att(sd, method = "SDID", outcome = "y")
))
autoplot(combined)

Designs and inference

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.

Performance

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.
trop(df, "y", "w", "id", "t", se = "bootstrap",
     control = trop_control(svd = "truncated", workers = 4))

Reference

Athey, S., Imbens, G. W., Qu, Z., & Viviano, D. (2025). Triply Robust Panel Estimators. arXiv:2508.21536.