Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions DESCRIPTION
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
Package: fect
Type: Package
Title: Fixed Effects Counterfactual Estimators
Version: 2.4.5
Date: 2026-05-29
Version: 2.4.6
Date: 2026-06-09
Authors@R:
c(person("Yiqing", "Xu", , "yiqingxu@stanford.edu", role = c("aut", "cre")),
person("Licheng", "Liu", , "lichengl@stanford.edu", role = c("aut")),
Expand Down
10 changes: 10 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
<!-- markdownlint-disable MD025 -->
# fect 2.4.6

* New `vartype = "conformal"`: a cross-sectional conformal prediction interval for the average treatment effect on the treated. It ranks the treated unit's average post-treatment prediction error against the leave-one-control-out errors of the donors, giving a distribution-free interval with no bootstrap draws. The interval is closed-form and never empty.
* Conformal options: `conformal.scale` (`"sd"` default, the studentized statistic; also `"none"`, `"rmspe"`, `"mad"`, `"diff"`), `conformal.center` (`"mean"`/`"median"`), `conformal.weight` (`"cell"`/`"unit"`/`"precision"` for multiple treated units), `conformal.band` (`"pointwise"`/`"simultaneous"`), and `conformal.cutoff` (`"per-period"`/`"pooled"`). The simultaneous (uniform) band is also stored in `fit$est.att.sim`.
* Conformal supports both block (common-onset) and **staggered adoption** (per-cohort donor pools, union-window calibration, and event-time-aggregated bands). `plot()` renders the event-study and counterfactual for conformal fits, and `est.att90` is a true inner `1 - 2*alpha` band.
* `vartype = "conformal"` requires a separated (controls-only) fit and `method` not in `c("mc", "both")`. It currently covers the core synthetic-control case (block or staggered); group, reversal, weighted (`W`), balanced-panel, placebo, and carryover options still use `bootstrap`/`jackknife`.
* `conformal.fit`: a user-facing hook for custom separated learners under `vartype = "conformal"`. Supply `f(Y, X, time, control.ids, target.id, T0)` returning the imputed untreated path; both sides of the calibration --- every held-out control and each treated unit (fitted on its own pre-window) --- run through the same learner. With a simplex synthetic-control learner this reproduces the hand-rolled Proposition 99 conformal interval exactly.
* `time.component.from` now defaults to `NULL` (auto): under `vartype = "conformal"` with `method` `"fe"`/`"ife"`/`"cfe"` it resolves to `"nevertreated"` so the main fit is strictly separated like the calibration (a message is emitted); otherwise the legacy `"notyettreated"`. Explicit `"notyettreated"` with conformal is honored with a weak-separation warning.
* Fix: `vartype = "conformal"` with covariates crashed during output labelling (`est.beta` does not exist for conformal --- no coefficient draws); the labelling is now null-guarded. Staggered + covariate conformal fits are covered by a regression test.

# fect 2.4.5

* Add `group.fe` to `fect()` for absorbing coarser fixed effects, such as state FE with county-level data. Closes #139. Clustered SE defaults to `group.fe[1]`; override with `cl = "<column>"`.
Expand Down
103 changes: 103 additions & 0 deletions R/boot.R
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,12 @@ fect_boot <- function(
carryover.period = NULL,
vartype = "bootstrap",
para.error = "auto",
conformal.scale = "sd",
conformal.center = "mean",
conformal.weight = "cell",
conformal.band = "pointwise",
conformal.cutoff = "per-period",
conformal.fit = NULL,
quantile.CI = FALSE,
nboots = 200,
parallel = TRUE,
Expand Down Expand Up @@ -543,6 +549,103 @@ fect_boot <- function(
fit.out <- out$Y.ct
N_unit <- dim(out$res)[2]

## ---- vartype = "conformal": cross-sectional conformal interval -----------
## Rank the treated gaps against the leave-one-control-out donor gaps (Family A
## level statistic, see conformal.R), bypassing the resampling / jackknife SE
## machinery. Populate the est.* slots so print / plot / esplot work unchanged,
## then return early (no bootstrap draws).
if (vartype == "conformal") {
## Phase 2 scope is the core SC case; unsupported options error clearly (they
## are added in later phases). Reversals, weights, balance, placebo, carryover
## and group estimates still go through bootstrap / jackknife.
if (!is.null(group)) {
stop("vartype = 'conformal' does not yet support group-level estimates; ",
"use vartype = 'bootstrap' or 'jackknife'.", call. = FALSE)
}
if (!is.null(balance.period) || !is.null(W) || hasRevs == 1 ||
isTRUE(placeboTest) || isTRUE(carryoverTest)) {
stop("vartype = 'conformal' does not yet support reversals, weights, ",
"balanced-panel, placebo or carryover tests; use vartype = ",
"'bootstrap' or 'jackknife' for those.", call. = FALSE)
}

cc <- conformal_calibrate(
Y = Y, D = D, X = X, I = I, II = II, T.on = T.on,
r.cv = out$r.cv, eff = out$eff,
method = method, predictive = time.component.from,
force = force, hasRevs = hasRevs, tol = tol, max.iteration = max.iteration,
norm.para = norm.para,
scale = conformal.scale, center = conformal.center,
weight = conformal.weight, band.type = conformal.band,
cutoff = conformal.cutoff, alpha = alpha,
conformal.fit = conformal.fit
)

## back out a nominal S.E. from the symmetric conformal CI (display only;
## conformal inference is rank-based, the S.E. column is informational).
z <- stats::qnorm(1 - alpha / 2)
se.from <- function(lo, hi) ifelse(is.finite(hi - lo), (hi - lo) / (2 * z), NA_real_)

## --- average effect -> est.avg / est.avg.unit. Point = the conformal
## weighted center cc$att (so the symmetric CI is centered on the reported
## estimate); for the default weight = "cell" this equals fect's att.avg.
## conformal reports a single weighting, so both print rows show it.
se.avg <- se.from(cc$ci[1], cc$ci[2])
est.avg <- t(as.matrix(c(cc$att, se.avg, cc$ci[1], cc$ci[2], cc$p.value)))
colnames(est.avg) <- c("ATT.avg", "S.E.", "CI.lower", "CI.upper", "p.value")
est.avg.unit <- t(as.matrix(c(cc$att, se.avg, cc$ci[1], cc$ci[2], cc$p.value)))
colnames(est.avg.unit) <- c("ATT.avg.unit", "S.E.", "CI.lower", "CI.upper", "p.value")

## --- per-period band (calendar-indexed) -> est.eff.calendar(.fit). The
## ATT-calendar point is the conformal weighted per-period effect (band eff).
band <- cc$band
se.cal <- se.from(band[, "CI.lower"], band[, "CI.upper"])
est.eff.calendar <- cbind(band[, "eff"], se.cal,
band[, "CI.lower"], band[, "CI.upper"], band[, "p.value"], calendar.N)
colnames(est.eff.calendar) <- c("ATT-calendar", "S.E.", "CI.lower", "CI.upper", "p.value", "count")
est.eff.calendar.fit <- cbind(calendar.eff.fit, se.cal,
band[, "CI.lower"], band[, "CI.upper"], band[, "p.value"], calendar.N)
colnames(est.eff.calendar.fit) <- colnames(est.eff.calendar)

## --- event-time band -> est.att (rownames = out$time). conformal_calibrate
## returns the band already aggregated by relative period (correct for both
## block and staggered); match its rownames to out$time by value.
map_et <- function(etb) {
m <- matrix(NA_real_, length(time.on), 6,
dimnames = list(time.on,
c("ATT", "S.E.", "CI.lower", "CI.upper", "p.value", "count")))
etrow <- as.numeric(rownames(etb))
for (k in seq_along(time.on)) {
r <- which(etrow == time.on[k])
if (!length(r)) next
v <- etb[r[1L], ]
m[k, ] <- c(v["eff"], se.from(v["CI.lower"], v["CI.upper"]),
v["CI.lower"], v["CI.upper"], v["p.value"], out$count[k])
}
m
}
est.att <- map_et(cc$band.et) # the (1 - alpha) band (conformal.band)
est.att.sim <- map_et(cc$band.et.sim) # the uniform / simultaneous band
est.att90 <- map_et(cc$band.et.inner) # the inner (1 - 2*alpha) conformal band
att.bound <- est.att90[, c("CI.lower", "CI.upper"), drop = FALSE]
rownames(att.bound) <- time.on

result <- list(
est.avg = est.avg,
est.avg.unit = est.avg.unit,
est.att = est.att,
est.att.sim = est.att.sim,
est.att90 = est.att90,
att.bound = att.bound,
est.eff.calendar = est.eff.calendar,
est.eff.calendar.fit = est.eff.calendar.fit,
vartype = "conformal",
conformal = cc[c("scale", "center", "weight", "band.type", "cutoff",
"status", "n.calib", "form")]
)
return(c(out, result))
}

if (!is.null(group)) {
group.output.origin <- out$group.output
group.output.name <- names(out$group.output)
Expand Down
Loading