R/default.R

Defines functions fect.default fect.formula fect

Documented in fect fect.default fect.formula

## Causal inference using counterfactual estimators
## (fect: fixed effects counterfactuals)

## MAIN FUNCTION
## fect.formula()
## fect.default()

## DEPENDENT FUNCTIONS
## fect_fe() ## interactive fixed effects model
## fect_mc() ## matrix completion
## fect_boot() ## bootstrap

## fitness test
## fect_test ## wild bootstrap

## METHODS
## print.fect()
## plot.fect()

## several supporting functions

#####################################################################
## A Shell Function
#####################################################################

## generic function
fect <- function(
    formula = NULL,
    data, # a data frame (long-form)
    Y, # outcome
    D, # treatment
    X = NULL, # time-varying covariates
    W = NULL, # weight column name; convenience default for both roles
    W.est = NULL, # weight column for outcome-model fit (overrides W);
                  # NULL falls back to W
    W.agg = NULL, # weight column for ATT aggregation (overrides W);
                  # NULL falls back to W
    group = NULL, # cohort
    na.rm = FALSE, # remove missing values
    index, # c(unit, time) indicators
    force = "two-way", # fixed effects demeaning
    time.component.from = "notyettreated", # factor estimation sample: "notyettreated" or "nevertreated"
    em = TRUE, # EM algorithm for missing data; FALSE uses direct SVD (requires complete estimation sample)
    r = 0, # number of factors
    lambda = NULL, # mc method: regularization parameter
    nlambda = 10, ## mc method: regularization parameter
    CV = NULL, # cross-validation
    k = 20, # times of CV
    cv.prop = 0.1, ## fraction of eligible units sampled per fold (rolling) / proportion of obs masked per round (block)
    cv.method = "rolling", ## CV masking strategy (default flipped from "all_units" in v2.3.0)
    cv.nobs = 3, ## cv taking consecutive units
    cv.donut = 1, ## cv mspe (default flipped from 0 to match cv.buffer = 1 in v2.3.0)
    cv.buffer = 1, ## past-side buffer for cv.method = "rolling"
    criterion = "mspe", # for ife model: mspe, pc or both
    binary = FALSE, # probit model
    QR = FALSE, # QR or SVD for binary probit
    method = "fe", # method: e for fixed effects; ife for interactive fe; mc for matrix completion
    se = FALSE, # report uncertainties
    vartype = "bootstrap", # bootstrap or jackknife
    para.error = "auto", # parametric bootstrap error strategy: "auto", "ar", "empirical", "wild"
    cl = NULL,
    ci.method = "normal", # CI method for fect's est.* slots: "normal" (Wald: theta_hat +- z * SE) or "basic" (reflected pivot, Davison-Hinkley 1997 Sec. 5.2.1). For percentile / bc / bca on alternative estimands (att.cumu, aptt, log.att), call estimand(fit, type, ci.method) post-fit
    quantile.CI = NULL, # DEPRECATED: use ci.method instead. NULL sentinel = "not supplied"; legacy FALSE -> ci.method = "normal", legacy TRUE -> ci.method = "basic"
    nboots = 200, # number of bootstraps (sufficient for SE / normal CI; bump to 1000+ for tail-quantile CIs in estimand())
    alpha = 0.05, # significance level
    parallel = TRUE, # parallel computing
    cores = NULL, # number of cores
    tol = 1e-5, # tolerance level (tightened from 1e-3 in v2.4.3)
    max.iteration = 5000,
    seed = NULL, # set seed
    min.T0 = NULL, # minimum T0
    max.missing = NULL, # maximum missing
    proportion = 0.3, # use to fit the f test and equivalence test
    pre.periods = NULL, # fit test period
    f.threshold = 0.5, # equiv
    tost.threshold = NULL, # equiv
    knots = NULL,
    degree = 2, # wald = FALSE, # fit test
    group.fe = NULL, # additional additive FE columns absorbed via CFE machinery
    cfe = NULL,
    Z = NULL,
    gamma = NULL,
    Q = NULL,
    kappa = NULL,
    Q.type = NULL, # c(1,2,3,"bspline") or c("linear", "quadratic", "cubic", "bspline")
    Q.bspline.degree = NULL, # used only when Q.type includes "bspline"
    Z.param = NULL,
    Q.param = NULL,
    balance.period = NULL, # the pre and post periods for balanced samples
    fill.missing = FALSE, # whether to balance missing observations
    placeboTest = FALSE, # placebo test
    placebo.period = NULL, # placebo test period
    carryoverTest = FALSE, # carry-over test
    carryover.period = NULL, # carry-over period
    carryover.rm = NULL,
    loo = FALSE, # leave one period out placebo
    permute = FALSE, ## permutation test
    m = 2, ## block length
    normalize = FALSE, # accelerate option
    keep.sims = FALSE, # keep individual bootstrap/jackknife simulations
    cm = FALSE, # causal moderation
    loading.bound = "none",           # simplex projection of treated loadings
    gamma.loading = NULL,             # scalar gamma for simplex; NULL = CV
    gamma.loading.grid = NULL,        # optional grid for gamma CV
    cv.rule = "1se"                   # CV selection rule: "1se", "min", "1pct"
) {
    UseMethod("fect")
}

## formula method

fect.formula <- function(
    formula = NULL,
    data, # a data frame (long-form)
    Y, # outcome
    D, # treatment
    X = NULL, # time-varying covariates
    W = NULL, # weights; convenience default for both roles
    W.est = NULL, # weight column for outcome-model fit; see fect()
    W.agg = NULL, # weight column for ATT aggregation; see fect()
    group = NULL, # cohort
    na.rm = FALSE, # remove missing values
    index, # c(unit, time) indicators
    force = "two-way", # fixed effects demeaning
    time.component.from = "notyettreated", # factor estimation sample: "notyettreated" or "nevertreated"
    em = TRUE, # EM algorithm for missing data; FALSE uses direct SVD (requires complete estimation sample)
    r = 0, # nubmer of factors
    lambda = NULL, # mc method: regularization parameter
    nlambda = 10, ## mc method: regularization parameter
    CV = NULL, # cross-validation
    k = 20, # times of CV
    cv.prop = 0.1, ## fraction of eligible units sampled per fold (rolling) / proportion of obs masked per round (block)
    cv.method = "rolling",
    cv.nobs = 3,
    cv.donut = 1, ## cv mspe (default flipped from 0 to match cv.buffer = 1 in v2.3.0)
    cv.buffer = 1, ## past-side buffer for cv.method = "rolling"
    criterion = "mspe", # for ife model: mspe, pc or both
    binary = FALSE, # probit model
    QR = FALSE, # QR or SVD for binary probit
    method = "fe", # method: fe for fixed effects; ife for interactive fe; mc for matrix completion
    se = FALSE, # report uncertainties
    vartype = "bootstrap", # bootstrap or jackknife
    para.error = "auto", # parametric bootstrap error strategy: "auto", "ar", "empirical", "wild"
    cl = NULL,
    ci.method = "normal", # CI method for fect's est.* slots: "normal" or "basic"
    quantile.CI = NULL, # DEPRECATED: use ci.method instead
    nboots = 200, # number of bootstraps (sufficient for SE / normal CI; bump to 1000+ for tail-quantile CIs in estimand())
    alpha = 0.05, # significance level
    parallel = TRUE, # parallel computing
    cores = NULL, # number of cores
    tol = 1e-5, # tolerance level (tightened from 1e-3 in v2.4.3)
    max.iteration = 5000,
    seed = NULL, # set seed
    min.T0 = NULL,
    max.missing = NULL,
    proportion = 0.3,
    pre.periods = NULL,
    f.threshold = 0.5, # equiv
    tost.threshold = NULL,
    knots = NULL,
    degree = 2, # wald = FALSE,
    group.fe = NULL,
    cfe = NULL,
    Z = NULL,
    gamma = NULL,
    Q = NULL,
    kappa = NULL,
    Q.type = NULL,
    Q.bspline.degree = NULL,
    Z.param = NULL,
    Q.param = NULL,
    balance.period = NULL, # the pre and post periods for balanced samples
    fill.missing = FALSE, # whether to balance missing observations
    placeboTest = FALSE, # placebo test
    placebo.period = NULL, # placebo test period
    carryoverTest = FALSE, # carry-over test
    carryover.period = NULL, # carry-over period
    carryover.rm = NULL,
    loo = FALSE, # leave one period out placebo
    permute = FALSE, ## permutation test
    m = 2, ## block length
    normalize = FALSE,
    keep.sims = FALSE,
    cm = FALSE,
    loading.bound = "none",
    gamma.loading = NULL,
    gamma.loading.grid = NULL,
    cv.rule = "1se"
) {
    ## parsing
    varnames <- all.vars(formula)
    Yname <- varnames[1]
    Dname <- varnames[2]
    if (length(varnames) > 2) {
        Xname <- varnames[3:length(varnames)]
    } else {
        Xname <- NULL
    }

    namesData <- colnames(data)
    for (i in 1:length(varnames)) {
        if (!varnames[i] %in% namesData) {
            stop(paste(
                "variable \"",
                varnames[i],
                "\" is not in the data set.",
                sep = ""
            ))
        }
    }

    ## check binary outcome
    if (binary == TRUE) {
        unique_y <- sort(unique(data[, Yname]))
        if (length(unique_y) != 2) {
            stop("Outcome should only contain 0 and 1.")
        } else {
            if (sum(unique_y == c(0, 1)) != 2) {
                stop("Outcome should only contain 0 and 1.")
            }
        }
    }

    ## run the model
    out <- fect.default(
        formula = NULL,
        data = data,
        Y = Yname,
        D = Dname,
        X = Xname,
        W = W,
        W.est = W.est,
        W.agg = W.agg,
        group = group,
        na.rm = na.rm,
        balance.period = balance.period,
        fill.missing = fill.missing,
        index = index,
        force = force,
        time.component.from = time.component.from,
        em = em,
        r = r,
        lambda = lambda,
        nlambda = nlambda,
        CV = CV,
        k = k,
        cv.prop = cv.prop,
        cv.method = cv.method,
        cv.nobs = cv.nobs,
        cv.donut = cv.donut,
        cv.buffer = cv.buffer,
        criterion = criterion,
        binary = binary,
        QR = QR,
        method = method,
        se = se,
        vartype = vartype,
        para.error = para.error,
        cl = cl,
        ci.method = ci.method,
        quantile.CI = quantile.CI,
        nboots = nboots,
        alpha = alpha,
        parallel = parallel,
        cores = cores,
        tol = tol,
        max.iteration = max.iteration,
        seed = seed,
        min.T0 = min.T0,
        max.missing = max.missing,
        proportion = proportion,
        pre.periods = pre.periods,
        f.threshold = f.threshold,
        tost.threshold = tost.threshold,
        knots = knots,
        degree = degree,
        group.fe = group.fe,
        cfe = cfe,
        Z = Z,
        gamma = gamma,
        Q = Q,
        kappa = kappa,
        Q.type = Q.type,
        Q.bspline.degree = Q.bspline.degree,
        Z.param = Z.param,
        Q.param = Q.param,
        placebo.period = placebo.period,
        placeboTest = placeboTest,
        carryoverTest = carryoverTest,
        carryover.period = carryover.period,
        carryover.rm = carryover.rm,
        loo = loo,
        permute = permute,
        m = m,
        normalize = normalize,
        keep.sims = keep.sims,
        cm = cm,
        loading.bound      = loading.bound,
        gamma.loading      = gamma.loading,
        gamma.loading.grid = gamma.loading.grid,
        cv.rule            = cv.rule
    )

    out$call <- match.call()
    out$formula <- formula
    return(out)
}


## default function

fect.default <- function(
    formula = NULL,
    data, # a data frame (long-form)
    Y, # outcome
    D, # treatment
    X = NULL, # time-varying covariates
    W = NULL, # weights; convenience default for both roles
    W.est = NULL, # weight column for outcome-model fit; see fect()
    W.agg = NULL, # weight column for ATT aggregation; see fect()
    group = NULL, # cohort
    na.rm = FALSE, # remove missing values
    index, # c(unit, time) indicators
    force = "two-way", # fixed effects demeaning
    time.component.from = "notyettreated", # factor estimation sample: "notyettreated" or "nevertreated"
    em = TRUE, # EM algorithm for missing data; FALSE uses direct SVD (requires complete estimation sample)
    r = 0, # nubmer of factors
    lambda = NULL, ## mc method: regularization parameter
    nlambda = 0,
    CV = NULL, # cross-validation
    k = 20, # times of CV
    cv.prop = 0.1,
    cv.method = "all_units",
    cv.nobs = 3,
    cv.donut = 1, ## cv mspe
    cv.buffer = 1, ## past-side buffer for cv.method = "rolling"
    criterion = "mspe",
    binary = FALSE, # probit model
    QR = FALSE, # QR or SVD for binary probit
    method = "fe", # method: ife for interactive fe; mc for matrix completion
    se = FALSE, # report uncertainties
    vartype = "bootstrap", # bootstrap or jackknife
    para.error = "auto", # parametric bootstrap error strategy: "auto", "ar", "empirical", "wild"
    cl = NULL,
    ci.method = "normal", # CI method for fect's est.* slots: "normal" or "basic"
    quantile.CI = NULL, # DEPRECATED: use ci.method instead
    nboots = 200, # number of bootstraps (sufficient for SE / normal CI; bump to 1000+ for tail-quantile CIs in estimand())
    alpha = 0.05, # significance level
    parallel = TRUE, # parallel computing
    cores = NULL, # number of cores
    tol = 1e-5, # tolerance level (tightened from 1e-3 in v2.4.3)
    max.iteration = 5000,
    seed = NULL, # set seed
    min.T0 = NULL,
    max.missing = NULL,
    proportion = 0.3,
    pre.periods = NULL,
    f.threshold = 0.5, # equiv
    tost.threshold = NULL,
    knots = NULL,
    degree = 2, # wald = FALSE,
    group.fe = NULL,
    cfe = NULL,
    Z = NULL,
    gamma = NULL,
    Q = NULL,
    kappa = NULL,
    Q.type = NULL,
    Q.bspline.degree = NULL,
    Z.param = NULL,
    Q.param = NULL,
    balance.period = NULL, # the pre and post periods for balanced samples
    fill.missing = FALSE, # whether to balance missing observations
    placeboTest = FALSE, # placebo test
    placebo.period = NULL, # placebo test period
    carryoverTest = FALSE, # carry-over test
    carryover.period = NULL, # carry-over period
    carryover.rm = NULL,
    loo = FALSE, # leave one period out placebo
    permute = FALSE, ## permutation test
    m = 2, ## block length
    normalize = FALSE,
    keep.sims = FALSE,
    cm=FALSE,
    loading.bound = "none",
    gamma.loading = NULL,
    gamma.loading.grid = NULL,
    cv.rule = "1se"
) {
    ## -------------------------------##
    ## Checking Parameters
    ## -------------------------------##
    cv.rule <- .fect_validate_cv_rule(cv.rule)
    placeboEquiv <- loo
    permu.dimension <- "time"

    ## read data
    if (is.data.frame(data) == FALSE || length(class(data)) > 1) {
        data <- as.data.frame(data)
        ## warning("Not a data frame.")
    }

    ## ----------------------------------------------------------------
    ## group.fe: validate, auto-route method, normalize into index[3:]
    ## ----------------------------------------------------------------
    ## group.fe is a character vector of column names naming additive simple
    ## FE groupings absorbed via the existing CFE pipeline (X.extra.FE). It
    ## is a discoverable surface for the legacy index = c(unit, time, extra,
    ## extra, ...) syntax. See statsclaw-workspace/fect/runs/2026-05-21-
    ## higher-level-fe.md for the design memo.
    if (!is.null(group.fe)) {
        if (!is.character(group.fe)) {
            stop("\"group.fe\" must be a character vector of column names.",
                 call. = FALSE)
        }
        ## (a) columns exist
        missing.cols <- setdiff(group.fe, colnames(data))
        if (length(missing.cols) > 0) {
            stop("group.fe column(s) not found in data: ",
                 paste(missing.cols, collapse = ", "), call. = FALSE)
        }
        ## (b) conflict with legacy index[3:] form: hard error
        if (length(index) > 2) {
            stop("Specify additional FE via group.fe OR extra index slots, ",
                 "not both. index[3:] is the legacy form; group.fe is preferred.",
                 call. = FALSE)
        }
        ## (c) overlap with index[1:2]: warn and drop the duplicate entries
        dup.idx <- intersect(group.fe, index[1:2])
        if (length(dup.idx) > 0) {
            warning("group.fe entry/entries (",
                    paste(dup.idx, collapse = ", "),
                    ") duplicate index[1:2]; dropping from group.fe.",
                    call. = FALSE)
            group.fe <- setdiff(group.fe, index[1:2])
        }
        ## (d) auto-route method (per D3 in design memo). When routing fe ->
        ## cfe, also pre-set r = 0 and CV = FALSE so the call behaves like
        ## the user's intended "FE only" model. (The standard fe -> ife r=0
        ## coercion at L711-714 of this file uses ife as its backing path;
        ## the cfe backing requires explicit r/CV to avoid hitting the
        ## interactive-FE code that expects a populated cfe= list.)
        if (length(group.fe) > 0) {
            if (method == "fe") {
                method <- "cfe"
                r <- 0
                if (is.null(CV)) CV <- FALSE
            } else if (method %in% c("ife", "mc", "both", "gsynth")) {
                stop("group.fe with method = \"", method, "\" is not supported. ",
                     "Use method = \"cfe\", r = N explicitly to get free latent ",
                     "factors with group-level FE.",
                     call. = FALSE)
            }
            ## method == "cfe": keep as-is, no message
        }
        ## (e) normalize: append group.fe to index for downstream X.extra.FE pipeline
        if (length(group.fe) > 0) {
            index <- c(index, group.fe)
        }
        ## (f) default cl from group.fe[1] when cl is NULL (the placeholder
        ## value) and group.fe is single-column. We use is.null(cl) here
        ## because missing(cl) doesn't survive the fect.formula -> fect.default
        ## dispatch (cl is explicitly forwarded). Documented: cl = NULL is
        ## the default placeholder and does NOT suppress clustering; use
        ## cl = FALSE to suppress explicitly.
        if (is.null(cl) && length(group.fe) == 1) {
            cl <- group.fe[1]
        } else if (length(group.fe) > 1 && is.null(cl)) {
            stop("Multi-column group.fe requires explicit cl (e.g., cl = '",
                 group.fe[1], "', or cl = index[1] for unit-level clustering).",
                 call. = FALSE)
        }
    }
    ## (g) cl must be a single column name. cl = FALSE is rejected to avoid
    ## the misleading "no clustering" framing: even without an explicit
    ## cluster column the case bootstrap still resamples units, which is
    ## unit-level clustering. Users who want explicit unit-level clustering
    ## should pass cl = index[1] (e.g., cl = "id").
    if (isFALSE(cl)) {
        stop("cl = FALSE is not supported. The case bootstrap always ",
             "resamples units; to cluster at the unit level explicitly, ",
             "pass cl = '", index[1], "' (or cl = index[1]).",
             call. = FALSE)
    }

    ## Snapshot the cl column name now (before downstream reshape to matrix)
    ## so print(fit) / summary(fit) can show the user-facing cluster label.
    cl.label <- if (is.character(cl) && length(cl) == 1) cl else NULL

    ## ----------------------------------------------------------------
    ## Nesting check: each group.fe column must be constant within index[1].
    ## This is required because group.fe is documented as "additive simple FE
    ## on a coarsening of the unit identifier" --- e.g., state on county.
    ## Without this check, default.R:1672 silently reshapes a non-constant
    ## column into a TT*N matrix and produces wrong fits.
    ##
    ## We do NOT apply this check to the legacy `index = c(unit, time, extra,
    ## ...)` form because that form has historically supported BOTH (a)
    ## nested additive FE (the group.fe-style use) AND (b) cell-level
    ## interactions like region_time (which by design vary within unit). The
    ## strict modern surface (group.fe) is for case (a) only; the legacy
    ## surface keeps its full original scope.
    ## ----------------------------------------------------------------
    if (length(group.fe) > 0 && all(index[1] %in% colnames(data))) {
        for (gfe in group.fe) {
            nest <- tapply(data[[gfe]], data[[index[1]]],
                           function(x) length(unique(stats::na.omit(x))))
            bad <- names(nest)[!is.na(nest) & nest > 1]
            if (length(bad) > 0) {
                bad.show <- if (length(bad) > 10)
                    paste0(paste(bad[1:10], collapse = ", "),
                           ", and ", length(bad) - 10, " more")
                else paste(bad, collapse = ", ")
                stop("group.fe column '", gfe,
                     "' is not constant within index[1] = '", index[1],
                     "'. Offending units: ", bad.show,
                     "\n  (group.fe is for additive simple FE on a coarsening ",
                     "of the unit identifier. For cell-level interactions ",
                     "that vary within unit, use the legacy `index = c(unit, ",
                     "time, ...)` form or the `cfe = list(...)` interactive-",
                     "FE argument instead.)",
                     call. = FALSE)
            }
        }
    }

    ## index
    if (
        (length(index) != 2 | sum(index %in% colnames(data)) != 2) &
            method != "cfe"
    ) {
        stop(
            "\"index\" option misspecified. Try, for example, index = c(\"unit.id\", \"time\")."
        )
    }

    if (!is.null(Z.param)) {
        Zgamma <- setNames(as.list(names(Z.param)), unlist(Z.param))
    } else {
        Zgamma <- NULL
    }

    if (!is.null(Q.param)) {
        kappaQ <- setNames(as.list(names(Q.param)), unlist(Q.param))
    } else {
        kappaQ <- NULL
    }

    id <- index[1]
    time <- index[2]


    if (cm == TRUE & ! method %in% c("fe", "ife")) {
        stop("\"cm\" option is only available for the \"fe\" and \"ife\" methods.")
    }

    ## Save user's literal method argument before any silent coercion (e.g.
    ## fe -> ife r=0 below). Referenced by the parametric/nevertreated gate
    ## further down so error messages name the user's actual method = "fe".
    method_arg <- method

    if (se == 1) {
        if (!vartype %in% c("bootstrap", "jackknife", "parametric")) {
            stop(
                "\"vartype\" must be one of \"bootstrap\", \"jackknife\", or \"parametric\".",
                call. = FALSE
            )
        }
        if (vartype == "parametric" && method %in% c("mc", "both")) {
            stop(
                "The \"parametric\" option is not available for the \"mc\" or \"both\" methods."
            )
        }
        if (vartype == "jackknife" && !is.null(cl)) {
            warning(
                "vartype = \"jackknife\" with cl = ... : the cl argument is ignored. ",
                "fect's jackknife is leave-one-unit-out and does not support a ",
                "cluster (block) jackknife. The resulting SEs do not account for ",
                "within-cluster correlation. Use vartype = \"bootstrap\" with cl ",
                "for cluster-aware inference.",
                call. = FALSE
            )
        }
        if (!para.error %in% c("auto", "ar", "empirical", "wild")) {
            stop(
                "\"para.error\" must be one of \"auto\", \"ar\", \"empirical\", or \"wild\".",
                call. = FALSE
            )
        }
    }

    ## Default: W populates both roles (back-compat for callers that just set W).
    if (is.null(W.est)) W.est <- W
    if (is.null(W.agg)) W.agg <- W

    ## Validate the resolved weight columns.
    validate_weight_col <- function(col, label) {
        if (is.null(col)) return(invisible())
        if (length(col) != 1 || !is.character(col)) {
            stop("`", label, "` must be a single column name.")
        }
        if (!col %in% colnames(data)) {
            stop("`", label, "` (\"", col, "\") is not in the dataset.")
        }
        if (!is.numeric(data[, col])) {
            stop("`", label, "` (\"", col, "\") must be numeric.")
        }
        if (any(data[, col] < 0, na.rm = TRUE)) {
            stop("`", label, "` (\"", col, "\") must be non-negative.")
        }
    }
    validate_weight_col(W,     "W")
    validate_weight_col(W.est, "W.est")
    validate_weight_col(W.agg, "W.agg")

    ## v2.3.1 supports only a single weight column (with role gating). Truly
    ## different columns for fit vs aggregation (e.g. survey weights x IPW)
    ## requires carrying two parallel matrices through the dispatcher and is
    ## scheduled for v2.4.0. Stop with an informative message if requested now.
    if (!is.null(W.est) && !is.null(W.agg) && !identical(W.est, W.agg)) {
        stop(
            "Distinct weight columns for `W.est` and `W.agg` are scheduled ",
            "for fect v2.4.0. v2.3.1 supports a single weight column with ",
            "role-gated routing: set `W.est = W.agg = \"col\"` (or `W = \"col\"`) ",
            "for survey/sample weights, `W.agg = \"col\"` alone for IPW / ",
            "balancing weights, or `W.est = \"col\"` alone for ",
            "robust-regression / heteroskedasticity weights."
        )
    }

    ## Resolve a single column name `Wname` for downstream code paths. The
    ## per-role flags `use.W.in.fit` and `use.W.in.agg` carry the role
    ## information.
    if (is.null(W) && !is.null(W.est)) W <- W.est
    if (is.null(W) && !is.null(W.agg)) W <- W.agg

    ## Drop rows where the weight column is exactly 0.
    if (!is.null(W) && any(data[, W] == 0, na.rm = TRUE)) {
        data <- data[data[, W] > 0, , drop = FALSE]
    }

    use.W.in.fit <- !is.null(W.est)
    use.W.in.agg <- !is.null(W.agg)

    ## check duplicated observations
    unique_label <- unique(paste(
        data[, index[1]],
        "_",
        data[, index[2]],
        sep = ""
    ))
    if (length(unique_label) != dim(data)[1]) {
        stop(
            "Observations are not uniquely defined by unit and time indicators."
        )
    }

    ## force
    if (force == "none") {
        # force = 0 "none": no additive fixed effects imposed
        force <- 0
    } else if (force == "unit") {
        # force = 1 "unit": unit fixed-effect (default)
        force <- 1
    } else if (force == "time") {
        # force = 2 "time": time fixed-effect
        force <- 2
    } else if (force == "two-way") {
        # force = 3 "two-way": two-way fixed-effect
        force <- 3
    }

    if (!force %in% c(0, 1, 2, 3)) {
        stop(
            "\"force\" option misspecified; choose from c(\"none\", \"unit\", \"time\", \"two-way\")."
        )
    }

    ## binary
    if (binary == 1) {
        method <- "ife"
        normalize <- FALSE
    }

    ## method
    if (
        !method %in%
            c(
                "fe",
                "ife",
                "mc",
                "both",
                "gsynth",
                "cfe"
            )
    ) {
        stop(
            "\"method\" option misspecified; choose from c(\"fe\",\"gsynth\", \"ife\", \"mc\", \"both\", \"cfe\")."
        )
    }

    ## validate time.component.from
    if (!time.component.from %in% c("notyettreated", "nevertreated")) {
        stop("\"time.component.from\" must be \"notyettreated\" or \"nevertreated\".")
    }

    ## mc does not use explicit factor estimation
    if (method %in% c("mc", "both") && time.component.from == "nevertreated") {
        stop("\"time.component.from = 'nevertreated'\" is not supported for the \"mc\" or \"both\" methods. ",
             "Matrix completion does not use explicit factor estimation.")
    }

    ## em=FALSE requires a complete estimation sample
    if (isFALSE(em) && time.component.from == "notyettreated") {
        stop("\"em = FALSE\" is not compatible with \"time.component.from = 'notyettreated'\". ",
             "The not-yet-treated estimation sample has missing data by construction.")
    }

    ## gsynth always uses never-treated units only
    if (method == "gsynth") {
        if (time.component.from == "notyettreated") {
            time.component.from <- "nevertreated"
        }
        em <- FALSE
    }

    if (is.null(min.T0)) {
        if (method == "fe") {
            min.T0 <- 1
        }
        if (method %in% c("ife", "mc", "both", "gsynth", "cfe")) {
            min.T0 <- 5
        }
    } else {
        if (min.T0 <= 0) {
            stop("\"min.T0\" option should be larger than 0.\n")
        }
    }

    ## the default setting of CV
    if (is.null(CV)) {
        if (method == "fe") {
            r <- 0
            CV <- FALSE
            method <- "ife"
        } else if (method == "cfe") {
            if (time.component.from == "nevertreated") {
                if (length(r) == 1) {
                    CV <- FALSE
                } else if (length(r) > 1) {
                    CV <- TRUE
                }
            } else {
                CV <- FALSE
            }
        } else if (method == "both") {
            CV <- TRUE
            if (length(r) == 1 & r == 0) {
                r <- c(0:5)
            }
        } else if (method %in% c("ife", "gsynth")) {
            if (length(r) == 1) {
                CV <- FALSE
            } else if (length(r) > 1) {
                CV <- TRUE
            }
        } else if (method == "mc") {
            if (length(lambda) == 1) {
                CV <- FALSE
            } else if (length(lambda) > 1 | is.null(lambda)) {
                CV <- TRUE
            }
        }
    } else {
        if (method == "fe") {
            r <- 0
            CV <- FALSE
            method <- "ife"
        } else if (method == "cfe") {
            if (time.component.from != "nevertreated") {
                CV <- FALSE
            }
        } else if (method == "both") {
            CV <- TRUE
        }
    }

    if (!criterion %in% c("mspe", "gmspe", "moment", "pc")) {
        stop(
            "\"criterion\" option misspecified; choose from c(\"mspe\", \"gmspe\",\"moment\", \"pc\")."
        )
    }

    # if (!criterion %in% c("mspe","pc")) {
    #    stop("\"criterion\" option misspecified; choose from c(\"mspe\", \"pc\").")
    # }

    if (method == "both" && criterion == "pc") {
        stop(
            "\"ife\" and \"mc\" method cannot be compared; try using \"mspe\" as criteria."
        )
    }

    ## r
    if (method %in% c("ife", "both", "cfe") & r[1] < 0) {
        stop(
            "\"r\" option misspecified. The number of factors must be non-negative."
        )
    }

    ## lambda
    if (method %in% c("mc", "both")) {
        if (!is.null(lambda)) {
            if (sum(lambda < 0) > 0) {
                stop("\"lambda\" option misspecified. It must be non-negative.")
            }
        }
        if (CV == FALSE & is.null(lambda)) {
            message("No lambda is supplied. FEct is applied.")
            method <- "ife"
            r <- 0
        }
    }

    ## leave one period out placebo
    if (placeboEquiv == TRUE) {
        if (se != TRUE) {
            message(
                "For leave one period out placebo test, automatically set \"se\" to TRUE."
            )
            se <- TRUE
        }
        if (placeboTest == TRUE) {
            message(
                "For leave one period out placebo test, automatically set \"placeboTest\" to FALSE."
            )
            placeboTest <- FALSE
        }
        if (carryoverTest == TRUE) {
            message(
                "For leave one period out placebo test, automatically set \"carryoverTest\" to FALSE."
            )
            carryoverTest <- FALSE
        }
    }

    if (placeboTest == TRUE & carryoverTest == TRUE) {
        stop(
            "\"placeboTest\" and \"carryoverTest\" can't be performed simultaneously."
        )
    }

    ## CV

    if (CV == TRUE) {
        if (placeboTest == TRUE) {
            stop(
                "Placebo test cannot be performed while doing cross-validation."
            )
        }

        if (carryoverTest == TRUE) {
            stop(
                "Carry-over test cannot be performed while doing cross-validation."
            )
        }

        if (method %in% c("ife", "both", "gsynth", "cfe")) {
            if (length(r) == 2 & r[1] > r[2]) {
                stop(
                    "\"r\" option misspecified. The first element should be smaller than the second element in r().\n"
                )
            }
        }
        if (method %in% c("mc", "both")) {
            if (nlambda <= 0) {
                stop("\"nlambda\" option misspecified.\n")
            }
        }
    } else {
        if (
            !method %in%
                c("gsynth", "ife", "mc", "cfe")
        ) {
            stop(
                "\"method\" option misspecified; please choose from c(\"gsynth\",\"ife\", \"mc\", \"cfe\")."
            )
        }
    }


    if (permute == 1) {
        if (placeboTest == TRUE) {
            stop("\"permute\" can't be used with \"placeboTest\".")
        }
        if (carryoverTest == TRUE) {
            stop("\"permute\" can't be used with \"carryoverTest\".")
        }
        if (loo == TRUE) {
            stop("\"permute\" can't be used with \"loo\".")
        }
        if (!permu.dimension %in% c("time", "unit")) {
            stop("\"permu.dimension\" must be in c(\"unit\",\"time\") .")
        }
    }

    if (length(r) == 1) {
        # if (r>=5) {
        #    r.end <- r
        # }
        # else {
        #    r.end <- 5
        # }
        r.end <- r
    } else {
        r.end <- max(r)
        r <- min(r)
    }

    ## uncertainty estimates
    if (is.logical(se) == FALSE & !se %in% c(0, 1)) {
        stop("\"se\" is not a logical flag.")
    }

    ## ci.method: "normal" (Wald: theta_hat +- z * SE) or "basic" (reflected
    ## pivot: 2*theta_hat - quantile(boot, c(1-alpha/2, alpha/2))). Wider 5-method
    ## surface (basic, percentile, bc, bca, normal) lives on estimand() for
    ## alternative estimands (att.cumu, aptt, log.att); see ?estimand and
    ## chapter 7 of the fect User Manual.
    ##
    ## quantile.CI is the deprecated legacy arg; map it to ci.method with a
    ## one-time warning when the user supplied it explicitly. The NULL sentinel
    ## (default) means "user did not supply it" so the warning stays silent
    ## under the modern API path.
    if (!is.null(quantile.CI)) {
        warning(
            "Argument `quantile.CI` is deprecated as of fect 2.4.2 and will be ",
            "removed in a future release. Use `ci.method = \"normal\"` (the ",
            "default; equivalent to `quantile.CI = FALSE`) or `ci.method = \"basic\"` ",
            "(equivalent to `quantile.CI = TRUE`) instead.\n\n",
            "For percentile / bc / bca CIs on alternative estimands ",
            "(att.cumu, aptt, log.att), call `estimand(fit, type, ci.method)` ",
            "post-fit; see chapter 7 of the fect User Manual.",
            call. = FALSE
        )
        if (!is.logical(quantile.CI) && !quantile.CI %in% c(0, 1)) {
            stop("\"quantile.CI\" is not a logical flag.", call. = FALSE)
        }
        ci.method <- if (isTRUE(as.logical(quantile.CI))) "basic" else "normal"
    }
    ## fect's built-in CI machinery for ci.method = "basic" reads raw
    ## quantiles of the bootstrap distribution.  This is calibrated for
    ## vartype = "bootstrap" only --- the case bootstrap is naturally
    ## centered at theta-hat.  vartype = "parametric" stores eff.boot
    ## centered at 0 (under H0); the reflected CI 2*theta_hat - quantile(boot)
    ## then collapses around 2*theta_hat instead of theta_hat, giving 0%
    ## coverage.  vartype = "jackknife" leave-one-out values are not
    ## exchangeable draws from the sampling distribution either (E&T 1993
    ## ch11; D&H 1997 Sec. 3.2.1).  estimand() applies a location-shift fix for
    ## parametric and a hard-error for jackknife on non-normal ci.methods;
    ## fect's built-in path does neither, so we hard-error here and point
    ## users to estimand().
    .ci.method.allowed <- c("normal", "basic")
    if (length(ci.method) != 1L || !is.character(ci.method) ||
        !ci.method %in% .ci.method.allowed) {
        if (is.character(ci.method) && length(ci.method) == 1L &&
            ci.method %in% c("percentile", "bc", "bca")) {
            stop(
                "ci.method = \"", ci.method, "\" is not supported in fect(); ",
                "fect()'s built-in CI machinery offers only \"normal\" (Wald) ",
                "and \"basic\" (reflected pivot).\n\n",
                "For \"percentile\", \"bc\", and \"bca\" CIs, fit with the ",
                "default ci.method and call `estimand(fit, type, ci.method = \"",
                ci.method, "\")` post-fit. The full 5-method surface is the ",
                "estimand() path; see ?estimand and chapter 7 of the fect ",
                "User Manual.",
                call. = FALSE
            )
        }
        stop(
            "`ci.method` must be one of \"normal\" or \"basic\"; got \"",
            ci.method, "\".",
            call. = FALSE
        )
    }
    ## Reject ci.method = "basic" on jackknife fits.  Jackknife pseudo-values
    ## are influence-function-flavored leave-one-out quantities, not exchangeable
    ## draws from the sampling distribution of theta-hat (E&T 1993 ch11; D&H
    ## 1997 Sec. 3.2.1).  The reflected pivot interval has no defensible meaning
    ## here.  Matches estimand()'s behaviour --- estimand hard-errors on
    ## non-"normal" ci.methods for jackknife fits.
    if (ci.method == "basic" && !is.null(vartype) &&
        identical(vartype, "jackknife")) {
        stop(
            "ci.method = \"basic\" is not supported for vartype = \"jackknife\". ",
            "Jackknife produces an SE estimate via the Tukey pseudo-value ",
            "formula, not a sampling distribution of theta-hat; reflection-based ",
            "CIs require exchangeable draws from a sampling distribution ",
            "(Efron & Tibshirani 1993, Chapter 11; Davison & Hinkley 1997, ",
            "Section 3.2.1).\n\n",
            "Use ci.method = \"normal\" (the only valid CI for jackknife: ",
            "theta-hat +- z * SE_jack) or refit with vartype = \"bootstrap\" ",
            "for the basic interval.",
            call. = FALSE
        )
    }
    ## ci.method = "basic" on vartype = "parametric" is supported via a
    ## location-shift fix in the CI computation downstream (R/boot.R, around
    ## line 3590): the parametric path stores eff.boot centered at 0 (under
    ## H0), and the reflected pivot CI 2*theta_hat - quantile(boot) collapses
    ## around 2*theta_hat without a shift.  fect() applies the same shift
    ## that R/po-estimands.R applies inside estimand() (commit b4e9fbf), so
    ## fit$est.avg with ci.method = "basic" on a parametric fit matches
    ## estimand(fit, "att", ci.method = "basic") byte-equally for the
    ## avg-level + per-event-time CIs.  The shift is currently applied at
    ## those two slots only; for other slots (calendar, cohort, subgroup,
    ## balanced, by-W, placebo, carryover), basic on parametric is not
    ## yet patched and may produce 0% coverage CIs --- call estimand() for
    ## those slots.
    ## Bridge to the existing internal dispatch in fect_boot, which is gated by
    ## a logical `quantile.CI`. After this resolution, .quantile.CI.bool is the
    ## single source of truth for the bootstrap-CI branch downstream.
    .quantile.CI.bool <- (ci.method == "basic")
    ## Tail-CI replicate warning (mirrors estimand's .check_tail_ci_replicates):
    ## `basic` reads tail quantiles of the bootstrap distribution; the literature
    ## floor for tail-quantile CIs is B >= 1000 (Efron 1987 Sec. 3; DiCiccio & Efron
    ## 1996 Sec. 4). Warn at fit time so users don't need to refit.
    if (ci.method == "basic" && isTRUE(se) && !is.null(nboots) &&
        is.numeric(nboots) && nboots < 1000 &&
        !identical(Sys.getenv("TESTTHAT"), "true")) {
        warning(
            "ci.method = \"basic\" reads tail quantiles of the bootstrap ",
            "distribution; with nboots = ", nboots, " (< 1000) the 5th / 195th ",
            "order statistics that the basic interval depends on may be ",
            "unstable (Efron 1987 Sec. 3; DiCiccio & Efron 1996 Sec. 4 recommend ",
            "B >= 1000). Refit with `fect(..., nboots = 1000)` for ",
            "publication-grade CIs. The point estimate and SE are unaffected.",
            call. = FALSE
        )
    }

    ## normalize
    if (is.logical(normalize) == FALSE & !normalize %in% c(0, 1)) {
        stop("\"normalize\" is not a logical flag.")
    }

    ## nboots
    if (se == TRUE & nboots <= 0) {
        stop("\"nboots\" option misspecified. Try, for example, nboots = 1000.")
    }

    ## parallel & cores
    ## parallel accepts: TRUE, FALSE, "cv", "boot", c("cv","boot")
    valid_parallel_strings <- c("cv", "boot")
    if (!isTRUE(parallel) && !identical(parallel, FALSE)) {
        parallel_chars <- as.character(parallel)
        bad <- setdiff(parallel_chars, valid_parallel_strings)
        if (length(bad) > 0) {
            stop(
                "\"parallel\" must be TRUE, FALSE, \"cv\", \"boot\", or c(\"cv\",\"boot\"). ",
                "Got: ", paste0("\"", bad, "\"", collapse = ", ")
            )
        }
    }
    do_parallel_cv   <- isTRUE(parallel) || "cv"   %in% as.character(parallel)
    do_parallel_boot <- isTRUE(parallel) || "boot" %in% as.character(parallel)

    if ((do_parallel_cv || do_parallel_boot) && !is.null(cores)) {
        if (!is.numeric(cores) || cores <= 0) {
            stop("\"cores\" option misspecified. Try, for example, cores = 2.")
        }
    }

    ## --- loading.bound validation (REQ-bounded-loadings) ---
    if (!is.character(loading.bound) || length(loading.bound) != 1L ||
        !(loading.bound %in% c("none", "simplex"))) {
        stop("'loading.bound' must be one of 'none', 'simplex'.")
    }
    if (loading.bound == "simplex") {
        if (!(method %in% c("ife", "gsynth"))) {
            stop("'loading.bound = \"simplex\"' is only supported for method = \"ife\" or \"gsynth\" in this version.")
        }
        if (!identical(time.component.from, "nevertreated")) {
            stop("'loading.bound = \"simplex\"' requires time.component.from = \"nevertreated\". ",
                 "The not-yet-treated dispatch is not supported in this version.")
        }
    }
    if (!is.null(gamma.loading)) {
        if (!is.numeric(gamma.loading) || length(gamma.loading) != 1L ||
            !is.finite(gamma.loading) || gamma.loading <= 0) {
            stop("'gamma.loading' must be a single positive finite numeric, or NULL.")
        }
    }
    if (!is.null(gamma.loading.grid)) {
        if (!is.numeric(gamma.loading.grid) || any(!is.finite(gamma.loading.grid)) ||
            any(gamma.loading.grid <= 0)) {
            stop("'gamma.loading.grid' must be a positive finite numeric vector, or NULL.")
        }
    }

    ## tol
    if (tol <= 0) {
        stop("\"tol\" option misspecified. Try using the default option.")
    }

    ## seed
    if (is.null(seed) == FALSE) {
        if (is.numeric(seed) == FALSE) {
            stop("\"seed\" should be a number.")
        }
    }

    ## remove missing values
    if (is.logical(na.rm) == FALSE & !na.rm %in% c(0, 1)) {
        stop("\"na.rm\" is not a logical flag.")
    }

    if (!is.null(balance.period)) {
        if (length(balance.period) != 2) {
            stop(" should be of length 2.")
        }
        if (balance.period[1] > 0) {
            stop(
                "The first element in \"balance.period\" should be no larger than 0."
            )
        }
        if (balance.period[2] < 1) {
            stop(
                "The second element in \"balance.period\" should be no smaller than 1."
            )
        }
        if (is.logical(fill.missing) == FALSE & !fill.missing %in% c(0, 1)) {
            stop("\"fill.missing\" is not a logical flag.")
        }
        if (!is.null(group)) {
            stop("\"group\" should not be used with \"balance.period\".\n")
        }
        if (!is.null(carryover.rm)) {
            stop(
                "\"balance.period\" should not be used with \"carryover.rm\".\n"
            )
        }
        balance.periods <- c(balance.period[1]:balance.period[2])
        # treat the units with history balance.periods as a certain group
    }

    # cohort
    if (!is.null(group)) {
        if (!group %in% names(data)) {
            stop("\"group\" misspecified.\n")
        }
        ## warn if group coincides with unit id but some units never treated
        if (!is.null(index) && length(index) >= 1 && group == index[1]) {
            if (!is.null(D) && D %in% names(data)) {
                id.vec <- data[, index[1]]
                d.vec <- data[, D]
                ## treat anything different from 0/NA as treated
                if (is.factor(d.vec) || is.character(d.vec)) {
                    d.numeric <- suppressWarnings(as.numeric(as.character(d.vec)))
                } else {
                    d.numeric <- suppressWarnings(as.numeric(d.vec))
                }
                if (all(is.na(d.numeric))) {
                    d.numeric <- suppressWarnings(as.numeric(d.vec))
                }
                treated.flag <- ifelse(is.na(d.numeric), 0, d.numeric != 0)
                treat.count <- tapply(treated.flag, id.vec, function(x) sum(x, na.rm = TRUE))
                never.tr <- names(treat.count)[is.na(treat.count) | treat.count == 0]
                if (length(never.tr) > 0) {
                    sample.ids <- utils::head(never.tr, 3)
                    warning(
                        paste0(
                            "The \"group\" option is set to the unit identifier (",
                            group,
                            "), but ",
                            length(never.tr),
                            " unit(s) never receive treatment (e.g., ",
                            paste(sample.ids, collapse = ", "),
                            "). ",
                            "Consider omitting the \"group\" option or using a cohort indicator instead."
                        ),
                        call. = FALSE
                    )
                }
            }
        }
    }

    if (!is.null(cl)) {
        if (!cl %in% names(data)) {
            stop("\"cl\" misspecified.\n")
        }
        if (length(cl) != 1) {
            stop("Length of \"cl\" must be 1.\n")
        }
        if (cl == index[1]) {
            cl <- NULL
        }
    }


    if (method == "cfe") {
        if (length(index) > 2) {
            for (extra.FE in index[3:length(index)]) {
                if (!extra.FE %in% names(data)) {
                    stop("\"index\" misspecified.\n")
                }
                if (extra.FE %in% index[1:2]) {
                    stop("\"index\" misspecified.\n")
                }
            }
        }

        if (!is.null(Q.type)) {
            if (!is.null(Q)) {
                stop("\"Q\" and \"Q.type\" cannot be used simultaneously.")
            }
            Q <- c()
            time.raw <- data[, time]
            if (is.factor(time.raw)) {
                time.num <- suppressWarnings(as.numeric(as.character(time.raw)))
            } else {
                time.num <- suppressWarnings(as.numeric(time.raw))
            }
            if (all(is.na(time.num))) {
                ## fallback: use ordered time index if not numeric
                time.num <- as.numeric(factor(time.raw, levels = sort(unique(time.raw))))
            }
            for (i in seq_along(Q.type)) {
                Q.i <- tolower(as.character(Q.type[i]))
                if (Q.i == "linear" || Q.i == "1") {
                    data[, paste(time, "1", sep = ".")] <- time.num**1
                    Q <- c(Q, paste(time, "1", sep = "."))
                } else if (Q.i == "quadratic" || Q.i == "2") {
                    data[, paste(time, "2", sep = ".")] <- time.num**2
                    Q <- c(Q, paste(time, "2", sep = "."))
                } else if (Q.i == "cubic" || Q.i == "3") {
                    data[, paste(time, "3", sep = ".")] <- time.num**3
                    Q <- c(Q, paste(time, "3", sep = "."))
                } else if (Q.i == "bspline" || Q.i == "b" || Q.i == "bs") {
                    n.t <- length(unique(time.num))
                    if (n.t < 2) {
                        stop("\"Q.type='bspline'\" requires at least 2 distinct time values.")
                    }
                    if (is.null(Q.bspline.degree)) {
                        degree.bs <- min(3, n.t - 1)
                    } else {
                        degree.bs <- suppressWarnings(as.integer(Q.bspline.degree))
                        if (length(degree.bs) != 1 || is.na(degree.bs)) {
                            stop("\"Q.bspline.degree\" must be a single numeric/integer value.")
                        }
                        degree.bs <- min(max(1L, degree.bs), n.t - 1L)
                    }
                    df.bs <- max(degree.bs + 1, min(degree.bs + 2, n.t))
                    bs.mat <- splines::bs(time.num, df = df.bs, degree = degree.bs, intercept = FALSE)
                    bs.names <- paste0(time, ".bs", seq_len(ncol(bs.mat)))
                    for (j in seq_len(ncol(bs.mat))) {
                        data[, bs.names[j]] <- bs.mat[, j]
                    }
                    Q <- c(Q, bs.names)
                } else {
                    stop(
                        "\"Q.type\" must be in c(1, 2, 3, \"bspline\") or c(\"linear\", \"quadratic\", \"cubic\", \"bspline\")."
                    )
                }
            }
        }

        if (!is.null(Zgamma)) {
            if (is.null(Z)) {
                stop("\"Z\" is not specified while \"Zgamma\" is specified.")
                # Z = unique(unname(unlist(Zgamma)))
            }
            if (is.null(gamma)) {
                gamma = names(Zgamma)
            }
        }
        if (!is.null(kappaQ)) {
            if (is.null(Q)) {
                stop("\"Q\" is not specified while \"kappaQ\" is specified.")
                # Q = unique(unname(unlist(kappaQ)))
            }
            if (is.null(kappa)) {
                kappa = names(kappaQ)
            }
        }
    }

    if (method != "cfe") {
        if (!is.null(group)) {
            data <- data[, unique(c(index, Y, D, X, W, group, cl))]
        } else {
            data <- data[, unique(c(index, Y, D, X, W, cl))] ## some variables may not be used
        }
    } else if (method == "cfe") {
        all.var <- unique(c(
            index,
            Z,
            Q,
            gamma,
            kappa,
            Y,
            D,
            X,
            W,
            group,
            cl
        ))
        data <- data[, all.var]
    }

    if (na.rm == TRUE) {
        data <- na.omit(data)
    } else {
        if (length(index) > 2) {
            for (extra.FE in index[3:length(index)]) {
                if (sum(is.na(data[, extra.FE])) >= 1) {
                    stop(
                        "\"index\" should not have missing values when setting \"na.rm\" to FALSE."
                    )
                }
            }
        }
        if (
            sum(is.na(data[, D])) >= 1 |
                sum(is.na(data[, index[1]])) >= 1 |
                sum(is.na(data[, index[2]])) >= 1
        ) {
            stop(
                "\"D\" or \"index\" should not have missing values when setting \"na.rm\" to FALSE."
            )
        }
    }

    ## placebo period
    if (placeboTest == TRUE && !is.null(placebo.period)) {
        if (sum(placebo.period > 0) > 0) {
            stop("\"placebo.period\" should not be greater than 0.")
        } else {
            if (length(placebo.period) > 2) {
                stop("\"placebo.period\" option misspecified. ")
            }
        }
    }

    ## carry-over period
    if (carryoverTest == TRUE && !is.null(carryover.period)) {
        if (sum(carryover.period <= 0) > 0) {
            stop("\"carryover.period\" should be larger than 0.")
        } else {
            if (length(carryover.period) > 2) {
                stop("\"carryover.period\" option misspecified. ")
            }
        }
    }

    ## deal with Inf and -Inf
    if (Inf %in% data[, Y] | -Inf %in% data[, Y]) {
        data[which(data[, Y] == Inf), Y] <- NA
        data[which(data[, Y] == -Inf), Y] <- NA
        warning(
            "Detect infinite values in outcome, automatically replace them with NA.\n"
        )
    }

    ## -------------------------------##
    ## Parsing raw data
    ## -------------------------------##

    ## store data and variable names
    data.old <- data
    Yname <- Y
    Dname <- D
    Xname <- X
    clname <- cl
    Wname <- W

    if (!is.null(clname)) {
        if (!clname %in% index) {
            data[, clname] <- as.numeric(as.factor(data[, clname]))
        }
    }

    ## normalize
    norm.para <- NULL
    if (normalize == TRUE) {
        sd.Y <- sd(as.matrix(data[, Yname]), na.rm = TRUE)
        data[, c(Yname, Xname)] <- data[, c(Yname, Xname)] / sd.Y
        norm.para <- sd.Y ## normalized parameter
    }

    ## check index and treatment indicator
    if (!class(data[, Dname]) %in% c("numeric", "integer")) {
        ## data[, Dname] <- as.numeric(as.character(data[, Dname]))
        stop("Treatment indicator should be a numeric value.")
    }

    ## check missingness
    # if (sum(is.na(data[, Yname])) > 0 & na.rm == TRUE) {
    #    stop(paste("Missing values in variable \"", Yname,"\".", sep = ""))
    # }
    if (sum(is.na(data[, Dname])) > 0) {
        stop(paste("Missing values in variable \"", Dname, "\".", sep = ""))
    }
    if (
        !(1 %in%
            data[, Dname] &
            0 %in% data[, Dname] &
            length(unique(data[, Dname])) == 2)
    ) {
        stop(paste("Error values in variable \"", Dname, "\".", sep = ""))
    }

    if (class(data[, index[1]])[1] == "factor") {
        data[, index[1]] <- as.character(data[, index[1]])
    }

    if (class(data[, index[2]])[1] == "factor") {
        data[, index[2]] <- as.character(data[, index[2]])
    }

    TT.old <- TT <- length(unique(data[, time]))
    N.old <- N <- length(unique(data[, id]))
    p <- length(Xname)
    id.series <- unique(sort(data[, id])) ## unit id
    time.uni <- unique(sort(data[, time])) ## period

    if (!is.null(knots)) {
        for (i in 1:length(knots)) {
            knots[i] <- which(time.uni == knots[i])
        }
    }

    ## sort data
    data <- data[order(data[, id], data[, time]), ]
    if (na.rm == FALSE) {
        # if X and Y have missing values while D doesn't have missing values, save the value of D
        # check if some units or periods are completely missing after drop missing values of X or Y
        data.full <- data[, c(Dname, id, time)]
        data <- na.omit(data)
        data.old <- data
        TT <- length(unique(data[, time]))
        N <- length(unique(data[, id]))
        if (TT != TT.old) {
            message(
                "Some periods are totally removed after drop missing values of the outcome or covariates.\n"
            )
        }
        if (N != N.old) {
            message(
                "Some units are totally removed after drop missing values of the outcome or covariates.\n"
            )
        }
        id.series <- unique(sort(data[, id])) ## unit id
        time.uni <- unique(sort(data[, time])) ## period

        # remove these dropped units or periods in data.full
        if (length(setdiff(data.full[, id], data[, id])) > 0) {
            rm.na.id <- setdiff(data.full[, id], data[, id])
            data.full <- data.full[which(!data.full[, id] %in% rm.na.id), ]
        } else {
            rm.na.id <- NULL
        }

        if (length(setdiff(data.full[, time], data[, time])) > 0) {
            rm.na.time <- setdiff(data.full[, time], data[, time])
            data.full <- data.full[which(!data.full[, time] %in% rm.na.time), ]
        } else {
            rm.na.time <- NULL
        }
        # here the size of data.full should be smaller than TT*N, larger than length(data)
    }

    ## max.missing
    if (is.null(max.missing)) {
        max.missing <- TT
    }

    if (method == "cfe") {
        if (length(index) > 2) {
            for (extra.FE in index[3:length(index)]) {
                data[, extra.FE] <- as.numeric(as.factor(data[, extra.FE]))
            }
        }
    }

    ## gen group matrix
    if (!is.null(group)) {
        rawgroup <- data[, group]
        newgroup <- as.numeric(as.factor(rawgroup))
        data[, group] <- newgroup
        rawgroup <- cbind.data.frame(rawgroup, newgroup)
        rawgroup <- rawgroup[!duplicated(rawgroup[, 1]), ]
    }

    ## message("\nOK1\n")
    ## check variation in x
    if (p > 0) {
        for (i in 1:p) {
            if (sum(is.na(data[, Xname[i]])) > 0 & na.rm == TRUE) {
                stop(paste(
                    "Missing values in variable \"",
                    Xname[i],
                    "\".",
                    sep = ""
                ))
            }
            if (force %in% c(1, 3)) {
                if (
                    sum(
                        tapply(data[, Xname[i]], data[, id], var),
                        na.rm = TRUE
                    ) ==
                        0
                ) {
                    stop(paste(
                        "Variable \"",
                        Xname[i],
                        "\" is unit-invariant. Try to remove it.",
                        sep = ""
                    ))
                }
            }
            if (force %in% c(2, 3)) {
                if (
                    sum(
                        tapply(data[, Xname[i]], data[, time], var),
                        na.rm = TRUE
                    ) ==
                        0
                ) {
                    stop(paste(
                        "Variable \"",
                        Xname[i],
                        "\" is time-invariant. Try to remove it.",
                        sep = ""
                    ))
                }
            }
        }
    }

    ## check index
    if (sum(is.na(data[, id])) > 0) {
        stop(paste("Missing values in variable \"", id, "\".", sep = ""))
    }
    if (sum(is.na(data[, time])) > 0) {
        stop(paste("Missing values in variable \"", time, "\".", sep = ""))
    }

    ## check balanced panel and fill unbalanced panel
    if (dim(data)[1] < TT * N) {
        data[, time] <- as.numeric(as.factor(data[, time]))
        data[, id] <- as.numeric(as.factor(data[, id]))

        ob.indicator <- data[, time]
        id.indicator <- table(data[, id])
        sub.start <- 1
        for (i in 1:(N - 1)) {
            sub.start <- sub.start + id.indicator[i]
            sub.end <- sub.start + id.indicator[i + 1] - 1
            ob.indicator[sub.start:sub.end] <- ob.indicator[sub.start:sub.end] +
                i * TT
        }

        variable <- c(Yname, Dname, Xname, id, time)

        if (!is.null(group)) {
            variable <- c(variable, group)
        }

        if (!is.null(W)) {
            variable <- c(variable, Wname)
        }

        if (method == "cfe") {
            if (length(index) > 2) {
                variable <- unique(c(
                    index[3:length(index)],
                    Z,
                    Q,
                    gamma,
                    kappa,
                    variable
                ))
            } else {
                variable <- unique(c(Z, Q, gamma, kappa, variable))
            }
        }

        if (!is.null(cl)) {
            variable <- unique(c(variable, cl))
        }

        data_I <- matrix(0, N * TT, 1)
        data_I[ob.indicator, 1] <- 1
        data_ub <- as.matrix(data[, variable])
        data <- data_ub_adj(data_I, data_ub)
        colnames(data) <- variable
        ## data is a TT*N matrix filled with observed pairs (Y/X, D).

        ## if these exists observations whose Y/X is missing but D is observed.
        if (na.rm == FALSE) {
            data.full[, time] <- as.numeric(as.factor(data.full[, time]))
            ob.indicator.full <- data.full[, time]
            id.indicator.full <- table(data.full[, id])
            sub.start <- 1
            for (i in 1:(N - 1)) {
                sub.start <- sub.start + id.indicator.full[i]
                sub.end <- sub.start + id.indicator.full[i + 1] - 1
                ob.indicator.full[sub.start:sub.end] <- ob.indicator.full[
                    sub.start:sub.end
                ] +
                    i * TT
            }
            data_I.full <- matrix(0, N * TT, 1)
            data_I.full[ob.indicator.full, 1] <- 1
            data_ub.full <- as.matrix(data.full[, Dname])
            data.D.full <- data_ub_adj(data_I.full, data_ub.full)
            colnames(data.D.full) <- Dname
            ## replace D in data with D in data.full
            data[, Dname] <- data.D.full[, Dname]
        }
    }

    # recode treatment status for osbervations exposed to carryover effects
    hasCarryover <- 0
    if (!is.null(carryover.rm)) {
        if (length(carryover.rm) == 1 & class(carryover.rm)[1] == "numeric") {
            if (carryover.rm > 0) {
                newT <- c(1:TT)
                data <- data[order(data[, id], data[, time]), ]
                tempID <- unique(data[, id])
                for (i in tempID) {
                    subpos <- which(data[, id] == i)
                    subtime <- data[subpos, time]
                    subd <- data[subpos, Dname]
                    if (sum(subd) >= 1) {
                        tr.time <- subtime[which(subd == 1)]
                        cr.time <- c() # carryover period
                        for (k in 1:carryover.rm) {
                            cr.time <- c(cr.time, tr.time + k)
                        }
                        # note: if a period has both treatment effect and carryover effect,
                        # regard carryover effect as 0
                        cr.time <- unique(cr.time)
                        cr.time <- setdiff(cr.time, tr.time)

                        cr.pos <- subpos[which(subtime %in% cr.time)]

                        if (length(cr.pos) > 0) {
                            data[cr.pos, Dname] <- 2
                        }
                    }
                }
            }
        }
    }

    if (2 %in% data[, Dname]) {
        hasCarryover <- 1
    }

    ## indicator matrix: index matrix that indicates if data is observed
    I.D <- I <- matrix(1, TT, N)
    Y.ind <- matrix(data[, Yname], TT, N)
    D.ind <- matrix(data[, Dname], TT, N)
    I[is.nan(Y.ind)] <- 0
    I.D[is.nan(D.ind)] <- 0
    ## I has more zeros than I.D
    ## I.D is used in the function get_term

    if (0 %in% I) {
        data[is.nan(data)] <- 0
    }

    ## group indicator
    G.old <- G <- NULL
    if (!is.null(group)) {
        G <- matrix(data[, group], TT, N)
        ## replace group index 0(missing) for each unit
        if (!0 %in% G) {
            if (sum(apply(G, 2, var)) > 0) {
                stop(
                    "A unit in different periods should have the same group index.\n"
                )
            }
        } else {
            if (
                max(apply(G, 2, function(vec) {
                    return(length(table(vec)))
                })) >
                    2
            ) {
                stop(
                    "A unit in different periods should have the same group index.\n"
                )
            }
            G <- apply(G, 2, function(vec) {
                return(rep(max(vec), length(vec)))
            })
        }
        G.old <- G
    }

    if (!is.null(W)) {
        W <- matrix(data[, Wname], TT, N)
    }

    if (!is.null(cl)) {
        cl <- matrix(data[, clname], TT, N)
        # for each column, replace 0 with the mean of non-zero values
        cl <- apply(cl, 2, function(column) {
            column[column == 0] <- mean(column[column != 0])
            return(column)
        })
    }

    ## treatment indicator: incorporates reversal treatments
    ## D==1 -> treatment
    D.origin <- D <- matrix(data[, Dname], TT, N)
    ## outcome variable
    Y <- matrix(data[, Yname], TT, N)
    ## time-varying covariates
    X <- array(0, dim = c(TT, N, p))
    if (p > 0) {
        for (i in 1:p) {
            X[,, i] <- matrix(data[, Xname[i]], TT, N)
        }
    }

    index.matrix <- list()

    data.colnames <- colnames(data)

    X.extra.FE <- array(0, dim = c(TT, N, length(index) - 2))

    X.Z <- array(0, dim = c(TT, N, length(Z)))
    if (is.null(gamma)) {
        if (!is.null(Z)) {
            gamma <- index[2]
            X.gamma <- array(0, dim = c(TT, N, 1))
            if (is.null(Zgamma)) {
                Zgamma <- list()
                Zgamma[[gamma]] <- Z
            }
        } else {
            gamma <- NULL # Explicitly set gamma to NULL when Z is NULL
            X.gamma <- array(0, dim = c(TT, N, 0))
            if (is.null(Zgamma)) {
                Zgamma <- list()
            }
        }
    } else {
        if (is.null(Z)) {
            stop("\"gamma\" cannot be specified while \"Z\" is not specified.")
        } else {
            X.gamma <- array(0, dim = c(TT, N, length(gamma)))
            if (is.null(Zgamma)) {
                Zgamma <- list()
                Zgamma[[gamma]] <- Z
            }
        }
    }

    X.Q <- array(0, dim = c(TT, N, length(Q)))
    if (is.null(kappa)) {
        if (!is.null(Q)) {
            kappa <- index[1]
            X.kappa <- array(0, dim = c(TT, N, 1))
            if (is.null(kappaQ)) {
                kappaQ <- list()
                kappaQ[[kappa]] <- Q
            }
        } else {
            kappa <- NULL # Explicitly set kappa to NULL when Q is NULL
            X.kappa <- array(0, dim = c(TT, N, 0))
            if (is.null(kappaQ)) {
                kappaQ <- list()
            }
        }
    } else {
        if (is.null(Q)) {
            stop("\"kappa\" cannot be specified while \"Q\" is not specified.")
        } else {
            X.kappa <- array(0, dim = c(TT, N, length(kappa)))
            if (is.null(kappaQ)) {
                kappaQ <- list()
                kappaQ[[kappa]] <- Q
            }
        }
    }

    if (method == "cfe") {
        if (length(index) > 2) {
            for (i in 1:(length(index) - 2)) {
                X.extra.FE[,, i] <- matrix(data[, index[i + 2]], TT, N)
            }
        }

        if (!is.null(Z)) {
            for (i in 1:length(Z)) {
                X.Z[,, i] <- matrix(data[, Z[i]], TT, N)
            }
        }

        if (!is.null(Q)) {
            for (i in 1:length(Q)) {
                X.Q[,, i] <- matrix(data[, Q[i]], TT, N)
            }
        }

        if (length(gamma) > 1) {
            for (i in 1:length(gamma)) {
                X.gamma[,, i] <- matrix(data[, gamma[i]], TT, N)
            }
        } else if (length(gamma) == 1) {
            X.gamma[,, 1] <- matrix(data[, gamma], TT, N)
        }

        if (length(kappa) > 1) {
            for (i in seq_along(kappa)) {
                kappa.val <- data[, kappa[i]]
                if (!is.numeric(kappa.val)) {
                    kappa.val <- suppressWarnings(as.numeric(as.character(kappa.val)))
                    if (all(is.na(kappa.val))) {
                        kappa.val <- as.numeric(factor(data[, kappa[i]]))
                    }
                }
                X.kappa[,, i] <- matrix(kappa.val, TT, N)
            }
        } else if (length(kappa) == 1) {
            kappa.val <- data[, kappa]
            if (!is.numeric(kappa.val)) {
                kappa.val <- suppressWarnings(as.numeric(as.character(kappa.val)))
                if (all(is.na(kappa.val))) {
                    kappa.val <- as.numeric(factor(data[, kappa]))
                }
            }
            X.kappa[,, 1] <- matrix(kappa.val, TT, N)
        }

        Zgamma.id <- list()
        for (key in names(Zgamma)) {
            value <- c()
            for (i in 1:length(Zgamma[[key]])) {
                value <- c(value, which(Z == Zgamma[[key]][i]))
            }
            Zgamma.id[[which(gamma == key)]] <- value
        }

        kappaQ.id <- list()
        for (key in names(kappaQ)) {
            value <- c()
            for (i in 1:length(kappaQ[[key]])) {
                value <- c(value, which(Q == kappaQ[[key]][i]))
            }
            kappaQ.id[[which(kappa == key)]] <- value
        }
    } else {
        Zgamma.id <- list()
        kappaQ.id <- list()
    }

    ## ----------------------------------------------------------- ##
    II <- I
    II[which(D == 1)] <- 0 ## regard treated values as missing

    II.cm <- matrix(0, TT, N)
    II.cm[which(D == 1)] <- 1
    II.cm[is.nan(Y.ind)] <- 0

    # Unbalance Check
    ## 1. remove units that have too control status
    T0 <- apply(II, 2, sum)
    T0.min <- min(T0)

    if (time.component.from == "nevertreated") {
        ## Validate: enough never-treated units for factor estimation
        D.cum <- apply(D, 2, function(vec) { cummax(vec) })
        D.unit.sum <- colSums(D.cum)
        co.never <- which(D.unit.sum == 0)

        if (length(co.never) == 0) {
            stop("\"time.component.from\" is set to \"nevertreated\" but no never-treated units found in the data.")
        }
        if (length(co.never) < r + 1) {
            stop(paste0("\"time.component.from\" is set to \"nevertreated\" but only ",
                         length(co.never), " never-treated units found. Need at least ",
                         r + 1, " for r = ", r, "."))
        }

        if (!(method %in% c("gsynth", "ife") ||
              (method == "cfe" && time.component.from == "nevertreated"))) {
            ## Zero out II for methods that don't do their own co/tr split
            ## (gsynth, ife+nevertreated, cfe+nevertreated route to fect_nevertreated which splits internally)
            ever.treated <- setdiff(1:ncol(II), co.never)
            II[, ever.treated] <- 0
        }
    }

    # Unbalance Check
    ## 1. remove units that have too control status

    if (sum(T0[which(apply(D, 2, sum) > 0)] >= min.T0) == 0) {
        stop(
            "All treated units have been removed. Please specify a smaller min.T0.\n"
        )
    }
    ## T0.min : minimum T0
    ## min.T0: manually set
    ## rm.tr.id: relative location of treated units (within all treated units)
    ## that will be removed
    if (T0.min < min.T0) {
        message(paste0(
            "For identification purposes, units whose number of untreated periods <",
            min.T0,
            " are dropped automatically.\n"
        ))
    }

    rm.id <- sort(unique(c(
        which((TT - apply(I, 2, sum)) > max.missing),
        which(T0 < min.T0)
    )))
    ## rm.id <- which(T0 < min.T0) ## removed id
    ## rem.id <- which(T0 >= min.T0) ## remaining id
    rem.id <- setdiff(1:N, rm.id)

    if (length(rm.id) == N) {
        stop("All units have been removed.\n")
    }

    if (length(rm.id) > 0) {
        n.keep <- length(rem.id)
        if (n.keep <= 10) {
            pct.keep <- round(100 * n.keep / N, 1)
            message(paste0(
                "After dropping units (max.missing/min.T0): retained ",
                pct.keep, "% of units.\n"
            ))
        }
    }

    if (length(rm.id) > 0) {
        X.old <- X
        if (p > 0) {
            X <- array(0, dim = c(TT, (N - length(rm.id)), p))
            for (i in 1:p) {
                subX <- X.old[,, i]
                X[,, i] <- as.matrix(subX[, -rm.id])
            }
        } else {
            X <- array(0, dim = c(TT, (N - length(rm.id)), 0))
        }

        # N <- N - length(rm.id)
        Y <- as.matrix(Y[, -rm.id])
        D <- as.matrix(D[, -rm.id])
        I <- as.matrix(I[, -rm.id]) ## after removing
        I.D <- as.matrix(I.D[, -rm.id])
        II <- as.matrix(II[, -rm.id])
        II.cm <- as.matrix(II.cm[, -rm.id])
        if (!is.null(group)) {
            G <- as.matrix(G[, -rm.id])
        }
        if (!is.null(cl)) {
            cl <- as.matrix(cl[, -rm.id])
        }
        if (!is.null(W)) {
            W <- as.matrix(W[, -rm.id])
        }
        if (method == "cfe") {
            rm.id.diff <- setdiff(1:dim(X.extra.FE)[2], rm.id)
            X.extra.FE <- X.extra.FE[, rm.id.diff, , drop = FALSE]
            X.Z <- X.Z[, rm.id.diff, , drop = FALSE]
            X.Q <- X.Q[, rm.id.diff, , drop = FALSE]
            X.gamma <- X.gamma[, rm.id.diff, , drop = FALSE]
            X.kappa <- X.kappa[, rm.id.diff, , drop = FALSE]
        }
    }

    ## message("\nOK1\n")

    ## 2. check if some periods when all units are missing or treated
    I.use <- apply(II, 1, sum)
    if (0 %in% I.use) {
        for (i in 1:TT) {
            if (I.use[i] == 0) {
                message(
                    "\nThere are not any observations under control at ",
                    time.uni[i],
                    ", drop that period.\n"
                )
            }
        }
        TT <- TT - sum(I.use == 0)
        time.uni <- time.uni[-which(I.use == 0)]

        I <- I[-which(I.use == 0), ] ## remove that period
        I.D <- I.D[-which(I.use == 0), ]
        II <- II[-which(I.use == 0), ] ## remove that period
        II.cm <- II.cm[-which(I.use == 0), ] ## remove that period
        D <- D[-which(I.use == 0), ] ## remove that period
        Y <- Y[-which(I.use == 0), ] ## remove that period

        if (!is.null(group)) {
            G <- G[-which(I.use == 0), ]
        }

        if (!is.null(W)) {
            W <- W[-which(I.use == 0), ]
        }

        if (!is.null(cl)) {
            cl <- cl[-which(I.use == 0), ]
        }

        if (method == "cfe") {
            X.extra.FE <- X.extra.FE[-which(I.use == 0), , , drop = FALSE]
            X.Z <- X.Z[-which(I.use == 0), , , drop = FALSE]
            X.Q <- X.Q[-which(I.use == 0), , , drop = FALSE]
            X.gamma <- X.gamma[-which(I.use == 0), , , drop = FALSE]
            X.kappa <- X.kappa[-which(I.use == 0), , , drop = FALSE]
        }

        X.old <- X
        if (p > 0) {
            X <- array(0, dim = c(TT, (N - length(rm.id)), p))
            for (i in 1:p) {
                subX <- X.old[,, i]
                X[,, i] <- as.matrix(subX[-which(I.use == 0), ])
            }
        } else {
            X <- array(0, dim = c(TT, (N - length(rm.id)), 0))
        }
    }

    ## message("\nOK2\n")
    ## 3. relative period
    T.on <- matrix(NA, TT, (N - length(rm.id)))
    D1 <- D2 <- NULL
    T.on.carry <- NULL
    if (hasCarryover == 0) {
        for (i in 1:(N - length(rm.id))) {
            T.on[, i] <- get_term(D[, i], I.D[, i], type = "on")
        }
    } else {
        # separate T.on and T.on.carry
        D1 <- D2 <- D
        D1[which(D1 == 2)] <- 0
        D2[which(D2 == 1)] <- 0
        D2[which(D2 == 2)] <- 1

        T.on.carry <- matrix(NA, TT, (N - length(rm.id)))

        for (i in 1:(N - length(rm.id))) {
            T.on[, i] <- get_term(D1[, i], I.D[, i], type = "on")
            T.on.carry[, i] <- get_term(D2[, i], I.D[, i], type = "on")
        }
        T.on[which(D == 2)] <- NA ## remove carryover effect
        T.on.carry[which(T.on.carry <= 0)] <- NA ## only keep carryover effect
    }
    rm(D1, D2)
    # calendar.time <- as.matrix(replicate((N - length(rm.id)), c(time.uni)))

    ## 3.1 balance samples
    ## for balance group, add group indicator
    T.on.balance <- matrix(NA, TT, (N - length(rm.id)))
    if (!is.null(balance.period)) {
        if (fill.missing == FALSE) {
            T.on.miss <- T.on
            T.on.miss[which(I == 0)] <- NA
            T.on.balance <- apply(T.on.miss, 2, function(x) {
                v_replace(balance.periods, x)
            })
        }
        if (fill.missing == TRUE) {
            T.on.balance <- apply(T.on, 2, function(x) {
                v_replace(balance.periods, x)
            })
        }
        if (sum(!is.na(T.on.balance)) == 0) {
            stop("No Balanced Sample Found.\n")
        }
    }

    ## 4. check reversals
    D1 <- D
    if (hasCarryover == 1) {
        D1[which(D == 2)] <- 1
    }
    D.fake <- apply(D1, 2, function(vec) {
        cumsum(vec)
    })
    D.fake <- ifelse(D.fake > 0, 1, 0)
    D.fake[which(I.D == 0)] <- 0
    Nrev <- sum(apply(D.fake == D1, 2, sum) != TT)
    hasRevs <- ifelse(Nrev > 0, 1, 0)
    if (hasRevs == FALSE & carryoverTest == TRUE) {
        stop(
            "Treatment status have no reversals. Cannot perform \"carryoverTest\" in this case."
        )
    }
    if (hasRevs == TRUE & method == "gsynth") {
        stop("Gsynth can't be used when treatments have reversals.")
    }
    if (se == 1 && vartype == "parametric" && hasRevs == TRUE) {
        stop(
            "Parametric bootstrap is not valid when treatment reversal is present. ",
            "Use vartype='bootstrap' or 'jackknife'."
        )
    }
    ## Parametric bootstrap requires nevertreated control-pool isolation.
    ## Placed AFTER the reversal-check gate so reversal users get the more
    ## actionable reversal message first. Uses method_arg (saved before the
    ## silent fe -> ife coercion) so the message names the user's literal
    ## method argument.
    if (se == 1 && vartype == "parametric" && time.component.from != "nevertreated") {
        stop(sprintf(
            paste0(
                "vartype = \"parametric\" requires time.component.from = \"nevertreated\".\n",
                "  Your call: method = \"%s\", time.component.from = \"%s\".\n\n",
                "The parametric pseudo-treated bootstrap requires a control pool ",
                "isolated from treated-unit pre-treatment cells. Pass ",
                "time.component.from = \"nevertreated\" (if never-treated controls ",
                "exist) or use vartype = \"bootstrap\" or \"jackknife\"."
            ),
            method_arg, time.component.from
        ), call. = FALSE)
    }
    ## para.error = "empirical" or "wild" requires fully-observed panel.
    if (se == 1 && vartype == "parametric" &&
        para.error %in% c("empirical", "wild") &&
        (0 %in% I)) {
        stop(
            "para.error = \"", para.error, "\" requires a fully-observed panel ",
            "(no missing cells in the observation matrix I). ",
            "The current data has unobserved cells. ",
            "Use para.error = \"ar\" or para.error = \"auto\" (which selects \"ar\" automatically ",
            "when missing data are present).",
            call. = FALSE
        )
    }

    ## 5. switch-off periods
    T.off <- NULL
    if (hasRevs == 1) {
        T.off <- matrix(NA, TT, (N - length(rm.id)))
        for (i in 1:(N - length(rm.id))) {
            T.off[, i] <- get_term(D1[, i], I.D[, i], type = "off")
        }
    }
    rm(D1)

    ## 6. regard placebo period as under treatment
    if (placeboTest == TRUE) {
        II.origin <- II
        if (length(placebo.period) == 1) {
            placebo.pos <- which(T.on == placebo.period)
            II[placebo.pos] <- 0
        } else {
            placebo.pos <- which(
                T.on >= placebo.period[1] & T.on <= placebo.period[2]
            )
            II[placebo.pos] <- 0
        }

        ## remove treated units that have too few observations
        T0.2 <- apply(II, 2, sum)

        if (sum(T0.2[which(apply(D, 2, sum) > 0)] >= min.T0) == 0) {
            stop("All treated units have been removed in placebo test.\n")
        }

        rm.id.2.pos <- sort(which(T0.2 < min.T0))
        rm.id.2 <- rem.id[rm.id.2.pos]
        rem.id.2 <- setdiff(rem.id, rm.id.2)

        rem.id <- rem.id.2
        rm.id <- setdiff(1:N, rem.id)

        if (length(rm.id.2) > 0) {
            X.old <- X
            if (p > 0) {
                X <- array(0, dim = c(TT, (N - length(rm.id)), p))
                for (i in 1:p) {
                    subX <- X.old[,, i]
                    X[,, i] <- as.matrix(subX[, -rm.id.2.pos])
                }
            } else {
                X <- array(0, dim = c(TT, (N - length(rm.id)), 0))
            }

            # N <- N - length(rm.id)
            Y <- as.matrix(Y[, -rm.id.2.pos])
            D <- as.matrix(D[, -rm.id.2.pos])
            I <- as.matrix(I[, -rm.id.2.pos]) ## after removing
            I.D <- as.matrix(I.D[, -rm.id.2.pos])
            II <- as.matrix(II[, -rm.id.2.pos])
            II.origin <- as.matrix(II.origin[, -rm.id.2.pos])
            T.on <- as.matrix(T.on[, -rm.id.2.pos])
            if (hasCarryover) {
                T.on.carry <- as.matrix(T.on.carry[, -rm.id.2.pos])
            }
            if (hasRevs) {
                T.off <- as.matrix(T.off[, -rm.id.2.pos])
            }
            if (!is.null(group)) {
                G <- as.matrix(G[, -rm.id.2.pos])
            }
            if (!is.null(W)) {
                W <- as.matrix(W[, -rm.id.2.pos])
            }
            if (!is.null(cl)) {
                cl <- as.matrix(cl[, -rm.id.2.pos])
            }

            if (method == "cfe") {
                rm.id.2.diff <- setdiff(1:dim(X.extra.FE)[2], rm.id.2.pos)
                X.extra.FE <- X.extra.FE[, rm.id.2.diff, , drop = FALSE]
                X.Z <- X.Z[, rm.id.2.diff, , drop = FALSE]
                X.Q <- X.Q[, rm.id.2.diff, , drop = FALSE]
                X.gamma <- X.gamma[, rm.id.2.diff, , drop = FALSE]
                X.kappa <- X.kappa[, rm.id.2.diff, , drop = FALSE]
            }
        }
    }

    ## 7. Carryover Test
    ## testcarryover.period = c(1,3)
    if (
        hasRevs == 1 &
            carryoverTest == TRUE &
            is.null(carryover.period) == FALSE
    ) {
        II.origin <- II
        if (length(carryover.period) == 1) {
            carryover.pos <- which(T.off == carryover.period)
            II[carryover.pos] <- 0
        } else {
            carryover.pos <- which(
                T.off >= carryover.period[1] & T.off <= carryover.period[2]
            )
            II[carryover.pos] <- 0
        }
        ## remove treated units that have too few observations
        T0.3 <- apply(II, 2, sum)

        if (sum(T0.3[which(apply(D, 2, sum) > 0)] >= min.T0) == 0) {
            stop("All treated units have been removed in carryover test.\n")
        }

        rm.id.3.pos <- sort(which(T0.3 < min.T0))
        rm.id.3 <- rem.id[rm.id.3.pos]
        rem.id.3 <- setdiff(rem.id, rm.id.3)

        rem.id <- rem.id.3
        rm.id <- setdiff(1:N, rem.id)

        if (length(rm.id.3) > 0) {
            X.old <- X
            if (p > 0) {
                X <- array(0, dim = c(TT, (N - length(rm.id)), p))
                for (i in 1:p) {
                    subX <- X.old[,, i]
                    X[,, i] <- as.matrix(subX[, -rm.id.3.pos])
                }
            } else {
                X <- array(0, dim = c(TT, (N - length(rm.id)), 0))
            }

            # N <- N - length(rm.id)
            Y <- as.matrix(Y[, -rm.id.3.pos])
            D <- as.matrix(D[, -rm.id.3.pos])
            I <- as.matrix(I[, -rm.id.3.pos]) ## after removing
            I.D <- as.matrix(I.D[, -rm.id.3.pos])
            II <- as.matrix(II[, -rm.id.3.pos])
            II.origin <- as.matrix(II.origin[, -rm.id.3.pos])
            T.on <- as.matrix(T.on[, -rm.id.3.pos])
            if (hasCarryover) {
                T.on.carry <- as.matrix(T.on.carry[, -rm.id.3.pos])
            }
            if (hasRevs) {
                T.off <- as.matrix(T.off[, -rm.id.3.pos])
            }
            if (!is.null(group)) {
                G <- as.matrix(G[, -rm.id.3.pos])
            }
            if (!is.null(W)) {
                W <- as.matrix(W[, -rm.id.3.pos])
            }
            if (!is.null(cl)) {
                cl <- as.matrix(cl[, -rm.id.3.pos])
            }
            if (method == "cfe") {
                rm.id.3.diff <- setdiff(1:dim(X.extra.FE)[2], rm.id.3.pos)
                X.extra.FE <- X.extra.FE[, rm.id.3.diff, , drop = FALSE]
                X.Z <- X.Z[, rm.id.3.diff, , drop = FALSE]
                X.Q <- X.Q[, rm.id.3.diff, , drop = FALSE]
                X.gamma <- X.gamma[, rm.id.3.diff, , drop = FALSE]
                X.kappa <- X.kappa[, rm.id.3.diff, , drop = FALSE]
            }
        }
    }

    ## recover treatment indicators
    D.pos <- NULL
    ## DD <- D
    if (hasCarryover) {
        D.pos <- which(D == 2)
        if (length(D.pos) > 0) {
            D[D.pos] <- 0
        }
    }

    ## 8. Finally, check enough observations
    if (min(apply(II, 1, sum)) == 0) {
        if (placeboTest == 1) {
            stop(
                "Some periods do not have any observations. Please set a smaller range for placebo period."
            )
        } else if (carryoverTest == 1) {
            stop(
                "Some periods do not have any observations. Please set a smaller range for carryover period."
            )
        } else {
            stop("Some periods do not have any observations.")
        }
    }

    if (time.component.from != "nevertreated" && min(apply(II, 2, sum)) == 0) {
        if (placeboTest == 1) {
            stop(
                "Some units do not have any observations. Please set a smaller range for placebo period."
            )
        } else if (carryoverTest == 1) {
            stop(
                "Some units do not have any observations. Please set a smaller range for carryover period."
            )
        } else {
            stop("Some units do not have any untreated observations.")
        }
    }
    ## cohort
    g.level <- NULL
    if (!is.null(group)) {
        g.level <- unique(c(G))
        g.level <- g.level[!is.na(g.level)]
        rownames(rawgroup) <- rawgroup[, "newgroup"]
        names(g.level) <- rawgroup[as.character(g.level), "rawgroup"]
        g.level <- sort(g.level)
    }

    ## -------------------------------##
    ## Register clusters
    ## -------------------------------##

    old.future.plan <- NULL

    if ((se == TRUE | permute == TRUE) & !do_parallel_boot) {
        ## set seed
        if (is.null(seed) == FALSE) {
            set.seed(seed + 1)
        }
    }

    if ((se == TRUE | permute == TRUE) & do_parallel_boot) {
        ## set seed
        if (is.null(seed) == FALSE) {
            set.seed(seed)
        }
        if (is.null(cores) == TRUE) {
            cores <- max(1L, min(parallelly::availableCores(omit = 2L), 8L))
        }
        old.future.plan <- future::plan()
        suppressWarnings(suppressPackageStartupMessages({
            ## v2.4.2+: route through .fect_make_future_cluster so workers
            ## pre-load mvtnorm / future / etc. silently. Avoids the
            ## "package was built under R version X.Y.Z" warnings firing
            ## per worker on first parallel package use (especially under
            ## parametric bootstrap where mvtnorm::rmvnorm fires on every
            ## worker for every replicate).
            future::plan(future::cluster,
                         workers = .fect_make_future_cluster(cores))
            doFuture::registerDoFuture()
        }))
        if (is.null(seed) == FALSE) {
            registerDoRNG(seed)
        }
        avail <- parallelly::availableCores()
        msg_line <- sprintf("Parallel computing: using %d of %d available cores.", cores, avail)
        pad <- strrep(" ", max(0, 56 - nchar(msg_line)))
        message("\n",
            " +----------------------------------------------------------+\n",
            " | ", msg_line, pad, " |\n",
            " |                                                          |\n",
            " | To change: set cores = <n> in fect().                    |\n",
            " | Default: min(available - 2, 8).                          |\n",
            " +----------------------------------------------------------+\n")
    }

    ## -------------------------------##
    ## run main program
    ## -------------------------------##
    if (se == FALSE) {
        if (CV == TRUE) {
            if (binary == FALSE) {
                out <- fect_cv(
                    Y = Y,
                    D = D,
                    X = X,
                    W = W,
                    W.in.fit = use.W.in.fit,
                    I = I,
                    II = II,
                    T.on = T.on,
                    T.off = T.off,
                    T.on.carry = T.on.carry,
                    T.on.balance = T.on.balance,
                    balance.period = balance.period,
                    method = method,
                    criterion = criterion,
                    k = k,
                    cv.prop = cv.prop,
                    cv.method = cv.method,
                    cv.nobs = cv.nobs,
                    cv.donut = cv.donut,
                    cv.buffer = cv.buffer,
                    min.T0 = min.T0,
                    r = r,
                    r.end = r.end,
                    proportion = proportion,
                    nlambda = nlambda,
                    lambda = lambda,
                    force = force,
                    hasRevs = hasRevs,
                    tol = tol,
                    max.iteration = max.iteration,
                    norm.para = norm.para,
                    group.level = g.level,
                    group = G,
                    time.component.from = time.component.from,
                    X.extra.FE = X.extra.FE,
                    X.Z = X.Z,
                    X.Q = X.Q,
                    X.gamma = X.gamma,
                    X.kappa = X.kappa,
                    Zgamma.id = Zgamma.id,
                    kappaQ.id = kappaQ.id,
                    parallel = parallel,
                    cores = cores,
                    do_parallel_cv   = do_parallel_cv,
                    do_parallel_boot = do_parallel_boot,
                    cv.rule = cv.rule
                )
            } else {
                out <- fect_binary_cv(
                    Y = Y,
                    D = D,
                    X = X,
                    I = I,
                    II = II,
                    T.on = T.on,
                    T.off = T.off,
                    k = k,
                    cv.prop = cv.prop,
                    cv.method = cv.method,
                    cv.nobs = cv.nobs,
                    cv.buffer = cv.buffer,
                    min.T0 = min.T0,
                    r = r,
                    r.end = r.end,
                    QR = QR,
                    force = force,
                    hasRevs = hasRevs,
                    tol = tol,
                    group.level = g.level,
                    group = G
                )
            }
        } else {
            ## non-binary case
            if (method == "ife" && time.component.from == "nevertreated") {
                ## nevertreated: route to fect_nevertreated (the nevertreated estimator)
                out <- fect_nevertreated(
                    Y = Y,
                    D = D,
                    X = X,
                    W = W,
                    W.in.fit = use.W.in.fit,
                    I = I,
                    II = II,
                    cm=cm,
                    II.cm = II.cm,
                    T.on = T.on,
                    T.off = T.off,
                    r = r,
                    CV = 0,
                    T.on.balance = T.on.balance,
                    balance.period = balance.period,
                    binary = binary,
                    QR = QR,
                    force = force,
                    hasRevs = hasRevs,
                    tol = tol,
                    max.iteration = max.iteration,
                    boot = 0,
                    norm.para = norm.para,
                    placeboTest = placeboTest,
                    placebo.period = placebo.period,
                    carryoverTest = carryoverTest,
                    carryover.period = carryover.period,
                    group.level = g.level,
                    group = G,
                    parallel = parallel,
                    cores = cores,
                    loading.bound      = loading.bound,
                    gamma.loading      = gamma.loading,
                    gamma.loading.grid = gamma.loading.grid
                )
            } else if (method == "ife") {
                out <- fect_fe(
                    Y = Y,
                    D = D,
                    X = X,
                    W = W,
                    W.in.fit = use.W.in.fit,
                    I = I,
                    II = II,
                    T.on = T.on,
                    T.off = T.off,
                    r.cv = r,
                    T.on.carry = T.on.carry,
                    T.on.balance = T.on.balance,
                    balance.period = balance.period,
                    binary = binary,
                    QR = QR,
                    force = force,
                    hasRevs = hasRevs,
                    tol = tol,
                    max.iteration = max.iteration,
                    boot = 0,
                    norm.para = norm.para,
                    placeboTest = placeboTest,
                    placebo.period = placebo.period,
                    carryoverTest = carryoverTest,
                    carryover.period = carryover.period,
                    group.level = g.level,
                    group = G
                )
            } else if (method == "cfe" && time.component.from == "nevertreated") {
                ## cfe + nevertreated: route to fect_nevertreated with method="cfe"
                out <- fect_nevertreated(
                    Y = Y,
                    D = D,
                    X = X,
                    W = W,
                    W.in.fit = use.W.in.fit,
                    I = I,
                    II = II,
                    T.on = T.on,
                    T.off = T.off,
                    r = r,
                    CV = 0,
                    T.on.balance = T.on.balance,
                    balance.period = balance.period,
                    binary = binary,
                    QR = QR,
                    force = force,
                    hasRevs = hasRevs,
                    tol = tol,
                    max.iteration = max.iteration,
                    boot = 0,
                    norm.para = norm.para,
                    placeboTest = placeboTest,
                    placebo.period = placebo.period,
                    carryoverTest = carryoverTest,
                    carryover.period = carryover.period,
                    group.level = g.level,
                    group = G,
                    ## CFE-specific parameters
                    method = "cfe",
                    X.extra.FE = X.extra.FE,
                    X.Z = X.Z,
                    X.Q = X.Q,
                    X.gamma = X.gamma,
                    X.kappa = X.kappa,
                    Zgamma.id = Zgamma.id,
                    kappaQ.id = kappaQ.id,
                    parallel = parallel,
                    cores = cores
                )
            } else if (method == "cfe") {
                out <- fect_cfe(
                    Y = Y,
                    D = D,
                    X = X,
                    W = W,
                    W.in.fit = use.W.in.fit,
                    X.extra.FE = X.extra.FE,
                    X.Z = X.Z,
                    X.Q = X.Q,
                    X.gamma = X.gamma,
                    X.kappa = X.kappa,
                    Zgamma.id = Zgamma.id,
                    kappaQ.id = kappaQ.id,
                    I = I,
                    II = II,
                    T.on = T.on,
                    T.off = T.off,
                    r.cv = r,
                    T.on.carry = T.on.carry,
                    T.on.balance = T.on.balance,
                    balance.period = balance.period,
                    binary = FALSE,
                    QR = QR,
                    force = force,
                    hasRevs = hasRevs,
                    tol = tol,
                    max.iteration = max.iteration,
                    boot = 0,
                    norm.para = norm.para,
                    placeboTest = placeboTest,
                    placebo.period = placebo.period,
                    carryoverTest = carryoverTest,
                    carryover.period = carryover.period,
                    group.level = g.level,
                    group = G
                )
            } else if (method == "gsynth") {
                out <- fect_nevertreated(
                    Y = Y,
                    D = D,
                    X = X,
                    W = W,
                    W.in.fit = use.W.in.fit,
                    I = I,
                    II = II,
                    T.on = T.on,
                    T.off = T.off,
                    r = r,
                    CV = 0,
                    T.on.balance = T.on.balance,
                    balance.period = balance.period,
                    binary = binary,
                    QR = QR,
                    force = force,
                    hasRevs = hasRevs,
                    tol = tol,
                    max.iteration = max.iteration,
                    boot = 0,
                    norm.para = norm.para,
                    placeboTest = placeboTest,
                    placebo.period = placebo.period,
                    carryoverTest = carryoverTest,
                    carryover.period = carryover.period,
                    group.level = g.level,
                    group = G,
                    parallel = parallel,
                    cores = cores
                )
            } else if (method == "mc") {
                out <- fect_mc(
                    Y = Y,
                    D = D,
                    X = X,
                    W = W,
                    W.in.fit = use.W.in.fit,
                    I = I,
                    II = II,
                    T.on = T.on,
                    T.off = T.off,
                    T.on.carry = T.on.carry,
                    T.on.balance = T.on.balance,
                    balance.period = balance.period,
                    lambda.cv = lambda,
                    force = force,
                    hasRevs = hasRevs,
                    tol = tol,
                    max.iteration = max.iteration,
                    boot = 0,
                    norm.para = norm.para,
                    placeboTest = placeboTest,
                    placebo.period = placebo.period,
                    carryoverTest = carryoverTest,
                    carryover.period = carryover.period,
                    group.level = g.level,
                    group = G
                )
            }

            if ("try-error" %in% class(out)) {
                stop("\nCannot estimate.\n")
            }
        }
    } else {
        # SE == TRUE

        out <- fect_boot(
            Y = Y,
            D = D,
            X = X,
            W = W,
            W.in.fit = use.W.in.fit,
            W.in.agg = use.W.in.agg,
            I = I,
            II = II,
            cm = cm,
            II.cm = II.cm,
            T.on = T.on,
            T.off = T.off,
            T.on.carry = T.on.carry,
            cl = cl,
            T.on.balance = T.on.balance,
            balance.period = balance.period,
            method = method,
            degree = degree,
            cfe = cfe,
            X.extra.FE = X.extra.FE,
            X.Z = X.Z,
            X.Q = X.Q,
            X.gamma = X.gamma,
            X.kappa = X.kappa,
            Zgamma.id = Zgamma.id,
            kappaQ.id = kappaQ.id,
            ind.matrix = index.matrix,
            knots = knots,
            criterion = criterion,
            CV = CV,
            k = k,
            cv.prop = cv.prop,
            cv.method = cv.method,
            cv.nobs = cv.nobs,
            r = r,
            r.end = r.end,
            nlambda = nlambda,
            lambda = lambda,
            alpha = alpha,
            binary = binary,
            QR = QR,
            force = force,
            hasRevs = hasRevs,
            tol = tol,
            max.iteration = max.iteration,
            norm.para = norm.para,
            placeboTest = placeboTest,
            placebo.period = placebo.period,
            carryoverTest = carryoverTest,
            carryover.period = carryover.period,
            vartype = vartype,
            para.error = para.error,
            quantile.CI = .quantile.CI.bool,
            nboots = nboots,
            parallel = parallel,
            cores = cores,
            do_parallel_cv = do_parallel_cv,
            group.level = g.level,
            group = G,
            keep.sims = keep.sims,
            time.component.from = time.component.from,
            loading.bound      = loading.bound,
            gamma.loading      = gamma.loading,
            gamma.loading.grid = gamma.loading.grid
        )

    }

    if ((out$validX == 0) & (p != 0)) {
        warning(
            "Multi-colinearity among covariates. Try removing some of them.\r"
        )
    }

    pre.est.att <- pre.att.bound <- NULL
    pre.term <- NULL
    N_bar <- NULL
    ## leave one period out placebo test for pre-treatment periods

    if (is.null(proportion) == TRUE) {
        proportion <- 0
    }
    max.count <- max(out$count)
    max.pre.periods <- out$time[which(
        out$count >= max.count * proportion & out$time <= 0
    )]
    all.pre.periods <- out$time[which(out$time <= 0)]
    if (is.null(pre.periods) == TRUE) {
        pre.periods <- max.pre.periods
    } else if (length(pre.periods) > 0) {
        pre.periods <- intersect(
            pre.periods[1]:pre.periods[length(pre.periods)],
            max.pre.periods
        )
    } else {
        pre.periods <- NULL
    }

    if (!is.null(pre.periods)) {
        pre.term <- pre.periods
        N_bar <- max(out$count[which(out$time %in% pre.periods)])
    }

    if (placeboEquiv == TRUE) {
        pre.term <- all.pre.periods
        r.cv <- out$r.cv
        lambda.cv <- out$lambda.cv
        method <- out$method
        if (method == "fe") {
            method <- "ife"
        }

        message("\nOut-of-Sample Test...\n")
        # if (is.null(placebo.period)) {
        #    pre.term <- pre.term.min:0
        # } else {
        #    pre.term.bound <- sort(placebo.period)
        #    pre.term <- pre.term.bound[1]:pre.term.bound[length(pre.term.bound)]
        # }

        placebo.period <- pre.term[1]:pre.term[length(pre.term)]

        pre.est.att <- matrix(NA, length(pre.term), 6)
        pre.att.bound <- matrix(NA, length(pre.term), 2)
        pre.att.boot <- matrix(NA, length(pre.term), nboots)

        rownames(pre.est.att) <- rownames(pre.att.bound) <- pre.term
        colnames(pre.est.att) <- c(
            "ATT",
            "S.E.",
            "CI.lower",
            "CI.upper",
            "p.value",
            "count.on"
        )
        colnames(pre.att.bound) <- c("CI.lower", "CI.upper")

        if (!is.null(group)) {
            all_group_names <- names(g.level)
            pre.est.att.group <- list()
            pre.att.bound.group <- list()
            pre.att.boot.group <- list()

            # only use the selected pre.periods before
            for (group.name in all_group_names) {
                pre.term.sub <- intersect(
                    out$group.output[[group.name]]$time.on,
                    pre.term
                )
                pre.est.att.sub <- matrix(NA, length(pre.term.sub), 6)
                pre.att.bound.sub <- matrix(NA, length(pre.term.sub), 2)
                pre.att.boot.sub <- matrix(NA, length(pre.term.sub), nboots)
                if (length(pre.term.sub) > 0) {
                    rownames(pre.att.boot.sub) <- rownames(
                        pre.est.att.sub
                    ) <- rownames(pre.att.bound.sub) <- pre.term.sub
                }
                colnames(pre.est.att.sub) <- c(
                    "ATT",
                    "S.E.",
                    "CI.lower",
                    "CI.upper",
                    "p.value",
                    "count.on"
                )
                colnames(pre.att.bound.sub) <- c("CI.lower", "CI.upper")

                pre.est.att.group[[group.name]] <- pre.est.att.sub
                pre.att.bound.group[[group.name]] <- pre.att.bound.sub
                pre.att.boot.group[[group.name]] <- pre.att.boot.sub
            }
        }

        jj <- length(pre.term)
        pre.term <- sort(pre.term, decreasing = TRUE)

        for (kk in pre.term) {
            placebo.pos <- which(T.on == kk)

            pX <- X
            pY <- Y
            pD <- D
            pI <- I
            pII <- II
            pT.on <- T.on
            pT.off <- T.off
            pG <- G
            pW <- W
            if (!is.null(cl)) {
                p.cl <- cl
            } else {
                p.cl <- NULL
            }

            pII[placebo.pos] <- 0 ## placebo treatment

            ## remove treated units that have too few observations
            T0.2 <- apply(pII, 2, sum)

            if (sum(T0.2[which(apply(D, 2, sum) > 0)] >= min.T0) == 0) {
                message("\n")
                message(paste(
                    "All treated units have been removed for period ",
                    kk,
                    sep = ""
                ))
                message("\n")
                jj <- jj - 1
            } else {
                te <- paste("Pre-period ", kk, sep = "")
                if (kk == 0) {
                    te <- paste(te, "(one period before treatment)", sep = " ")
                }
                message("\n")
                message(te)
                message("\n")

                rem.id.new <- rem.id
                rm.id.2.pos <- sort(which(T0.2 < min.T0))
                rm.id.2 <- rem.id.new[rm.id.2.pos]
                rem.id.2 <- setdiff(rem.id.new, rm.id.2)

                rem.id.new <- rem.id.2
                rm.id.new <- setdiff(1:N, rem.id.new)

                if (length(rm.id.2) > 0) {
                    X.old <- pX
                    if (p > 0) {
                        pX <- array(0, dim = c(TT, (N - length(rm.id.new)), p))
                        for (i in 1:p) {
                            subX <- X.old[,, i]
                            pX[,, i] <- as.matrix(subX[, -rm.id.2.pos])
                        }
                    } else {
                        pX <- array(0, dim = c(TT, (N - length(rm.id.new)), 0))
                    }

                    # N <- N - length(rm.id)
                    pY <- as.matrix(Y[, -rm.id.2.pos])
                    pD <- as.matrix(D[, -rm.id.2.pos])
                    pI <- as.matrix(I[, -rm.id.2.pos]) ## after removing
                    pII <- as.matrix(II[, -rm.id.2.pos])
                    pT.on <- as.matrix(T.on[, -rm.id.2.pos])
                    if (!is.null(cl)) {
                        p.cl <- cl[-rm.id.2.pos]
                    } else {
                        p.cl <- NULL
                    }
                    if (hasRevs) {
                        pT.off <- as.matrix(T.off[, -rm.id.2.pos])
                    }
                    if (!is.null(group)) {
                        ## group <- group[-rm.id.2.pos]
                        ## rawgroup <- rawgroup[-rm.id.2.pos]
                        pG <- as.matrix(G[, -rm.id.2.pos])
                    }
                    if (!is.null(W)) {
                        ## group <- group[-rm.id.2.pos]
                        ## rawgroup <- rawgroup[-rm.id.2.pos]
                        pW <- as.matrix(W[, -rm.id.2.pos])
                    }
                }

                p.out <- fect_boot(
                    Y = pY,
                    D = pD,
                    X = pX,
                    W = pW,
                    W.in.fit = use.W.in.fit,
                    W.in.agg = use.W.in.agg,
                    I = pI,
                    II = pII,
                    T.on = pT.on,
                    T.off = pT.off,
                    cl = p.cl,
                    T.on.carry = T.on.carry,
                    method = method,
                    degree = degree,
                    X.extra.FE = X.extra.FE,
                    X.Z = X.Z,
                    X.Q = X.Q,
                    X.gamma = X.gamma,
                    X.kappa = X.kappa,
                    Zgamma.id = Zgamma.id,
                    kappaQ.id = kappaQ.id,
                    knots = knots,
                    criterion = criterion,
                    CV = 0,
                    k = k,
                    cv.prop = cv.prop,
                    cv.method = cv.method,
                    cv.nobs = cv.nobs,
                    r = r.cv,
                    r.end = r.end,
                    nlambda = nlambda,
                    lambda = lambda.cv,
                    alpha = alpha,
                    binary = binary,
                    QR = QR,
                    force = force,
                    hasRevs = 0,
                    tol = tol,
                    max.iteration = max.iteration,
                    norm.para = norm.para,
                    placeboTest = 0,
                    placebo.period = NULL,
                    carryoverTest = 0,
                    carryover.period = NULL,
                    vartype = vartype,
                    nboots = nboots,
                    parallel = parallel,
                    quantile.CI = .quantile.CI.bool,
                    cores = cores,
                    group.level = g.level,
                    group = pG,
                    dis = FALSE
                )

                p.est.att <- p.out$est.att
                p.att.bound <- p.out$att.bound
                p.pos <- which(as.numeric(rownames(p.est.att)) == kk)
                pre.est.att[jj, ] <- p.est.att[p.pos, ]
                pre.att.bound[jj, ] <- p.att.bound[p.pos, ]
                pre.att.boot[jj, ] <- p.out$att.boot.original[p.pos, ]
                pre.period.name <- rownames(pre.est.att)[jj]

                if (!is.null(group)) {
                    for (group.name in all_group_names) {
                        check.sub <- p.out$group.output[[group.name]]$att.on
                        if (!is.null(check.sub)) {
                            p.est.att.sub <- p.out$est.group.output[[
                                group.name
                            ]]$att.on
                            p.att.bound.sub <- p.out$est.group.output[[
                                group.name
                            ]]$att.on.bound
                            p.att.boot.sub <- p.out$est.group.output[[
                                group.name
                            ]]$att.on.boot
                            p.pos.sub <- which(
                                as.numeric(rownames(p.est.att.sub)) == kk
                            )
                            if (length(p.pos.sub) == 1) {
                                pre.est.att.group[[group.name]][
                                    pre.period.name,
                                ] <- p.est.att.sub[p.pos.sub, ]
                                pre.att.bound.group[[group.name]][
                                    pre.period.name,
                                ] <- p.att.bound.sub[p.pos.sub, ]
                                pre.att.boot.group[[group.name]][
                                    pre.period.name,
                                ] <- p.att.boot.sub[p.pos.sub, ]
                            }
                        }
                    }
                }
                jj <- jj - 1
            }
        }

        pre.est.group.output <- NULL
        if (!is.null(group)) {
            pre.est.group.output <- list()
            for (group.name in all_group_names) {
                sub.output <- list()
                sub.output$pre.est.att <- pre.est.att.group[[group.name]]
                sub.output$pre.att.bound <- pre.att.bound.group[[group.name]]
                sub.output$pre.att.boot <- pre.att.boot.group[[group.name]]
                pre.est.group.output[[group.name]] <- sub.output
            }
        }
    }

    ## permutation test
    if (permute == TRUE) {
        message("Permuting under sharp null hypothesis ... ")

        out.permute <- fect_permu(
            Y = Y,
            X = X,
            D = D,
            I = I,
            r.cv = out$r.cv,
            lambda.cv = out$lambda.cv,
            m = m,
            permu.dimension = permu.dimension,
            method = out$method,
            degree = degree,
            knots = knots,
            force = force,
            tol = tol,
            norm.para = norm.para,
            nboots = nboots,
            parallel = parallel,
            cores = cores
        )

        permute.p <- sum(out.permute > abs(out$att.avg)) / length(out.permute)

        permute.result <- list(permute.att.avg = out.permute, p = permute.p)
    }

    if (!is.null(old.future.plan)) {
        future::plan(old.future.plan)
    }

    ## message("\nOK4\n")

    ## -------------------------------##
    ## storage
    ## -------------------------------##

    iname.old <- iname <- unique(sort(data.old[, id]))
    ## tname.old <- tname <- unique(sort(data.old[,time]))
    if (!0 %in% I.use) {
        tname.old <- tname <- unique(sort(data.old[, time]))
    } else {
        tname.old <- tname <- unique(sort(data.old[, time]))[which(I.use != 0)]
    }

    if (length(rm.id) > 0) {
        remove.id <- iname[rm.id]
        iname <- iname[-rm.id]
    }

    N.rem <- dim(D)[2]
    unit.type <- rep(NA, N.rem) ## 1 for control; 2 for treated; 3 for reversal;
    for (i in 1:N.rem) {
        di <- D[, i]
        ii <- I[, i]
        if (length(unique(di[which(ii == 1)])) == 1) {
            ## treated or control
            if (0 %in% unique(di[which(ii == 1)])) {
                unit.type[i] <- 1 ## control
            } else {
                unit.type[i] <- 2 ## treated
            }
        } else {
            unit.type[i] <- 3 ## reversal
        }
    }

    # 1 treated
    # 2 control
    # 3 missing
    # 4 removed
    # 5 placebo or carryover
    obs.missing <- matrix(0, TT, N) ## not under treatment
    obs.missing[, rem.id] <- D + as.matrix(abs(I - 1)) * 3 ## under treatment
    obs.missing[which(obs.missing == 0)] <- 2
    if (placeboTest | carryoverTest) {
        obs.missing[, rem.id] <- obs.missing[, rem.id] + 3 * (II.origin != II) ## placebo or carryover
    }
    if (carryoverTest & !is.null(carryover.rm)) {
        obs.missing[intersect(which(D.origin == 2), which(I == 1))] <- 6
    }
    obs.missing[which(obs.missing == 4)] <- 3 # in case if I.D!=I
    obs.missing[, rm.id] <- 4 ## removed

    colnames(obs.missing) <- unique(sort(data.old[, id]))
    rownames(obs.missing) <- tname

    obs.missing.balance <- NA
    if (!is.null(balance.period)) {
        obs.missing.balance <- obs.missing
        obs.missing.sub <- obs.missing[, rem.id]
        obs.missing.sub[which(T.on.balance > 0)] <- 7
        obs.missing.sub[which(T.on.balance <= 0)] <- 8
        obs.missing.sub[which(I == 0)] <- 3
        obs.missing.balance[, rem.id] <- obs.missing.sub
    }

    sample <- matrix(obs.missing %in% c(1L, 2L, 5L), nrow = TT, ncol = N,
                     dimnames = dimnames(obs.missing))

    # if cross-validation:

    if (p > 0) {
        Xname.tmp <- Xname
        rownames(out$beta) <- Xname.tmp
        colnames(out$beta) <- c("Coef")
        if (binary == TRUE) {
            rownames(out$marginal) <- Xname.tmp
        }
        if (se == TRUE) {
            rownames(out$est.beta) <- Xname.tmp
            colnames(out$est.beta) <- c(
                "Coef",
                "S.E.",
                "CI.lower",
                "CI.upper",
                "p.value"
            )
            if (binary == TRUE) {
                rownames(out$est.marginal) <- Xname.tmp
            }
            if (placeboTest == TRUE) {
                colnames(out$est.placebo) <- c(
                    "Coef",
                    "S.E.",
                    "CI.lower",
                    "CI.upper",
                    "p.value",
                    "CI.lower(90%)",
                    "CI.upper(90%)"
                )
                rownames(out$est.placebo) <- c("Placebo effect")
            }
            if (carryoverTest == TRUE) {
                colnames(out$est.carryover) <- c(
                    "Coef",
                    "S.E.",
                    "CI.lower",
                    "CI.upper",
                    "p.value",
                    "CI.lower(90%)",
                    "CI.upper(90%)"
                )
                rownames(out$est.carryover) <- c("Carryover effect")
            }
        }
    }
    colnames(out$eff) <- iname
    rownames(out$eff) <- tname
    out$eff.calendar <- cbind(
        matrix(out$eff.calendar, ncol = 1),
        out$N.calendar
    )
    out$eff.calendar.fit <- cbind(
        matrix(out$eff.calendar.fit, ncol = 1),
        out$N.calendar
    )
    rownames(out$eff.calendar) <- tname
    rownames(out$eff.calendar.fit) <- tname
    colnames(out$eff.calendar) <- c("ATT-calendar", "count")
    colnames(out$eff.calendar.fit) <- c("ATT-calendar Fitted", "count")

    if (se == TRUE) {
        rownames(out$est.eff.calendar) <- tname
        rownames(out$est.eff.calendar.fit) <- tname
    }

    ## cohort effect
    if (!is.null(group)) {
        out$group <- rawgroup
        # out$G <- group
        # out$group2 <- rawgroup2
        if (se == 1) {
            rownames(out$est.group.att) <- names(g.level)
        }
        out$g.level <- g.level
    }

    if (is.null(tost.threshold) == TRUE) {
        tost.threshold <- 0.36 * sqrt(out$sigma2.fect)
    }

    output <- c(
        list(
            Y.dat = Y,
            D.dat = D,
            I.dat = I,
            Y = Yname,
            D = Dname,
            X = Xname,
            W = Wname,
            W.est.col = if (use.W.in.fit) Wname else NULL,
            W.agg.col = if (use.W.in.agg) Wname else NULL,
            W.in.fit = use.W.in.fit,
            W.in.agg = use.W.in.agg,
            T.on = T.on,
            G = G.old,
            balance.period = balance.period,
            hasRevs = hasRevs,
            T.off = T.off,
            T.on.balance = T.on.balance,
            index = index,
            id = iname,
            rawtime = tname,
            binary = binary,
            loo = loo,
            proportion = proportion,
            pre.periods = pre.periods,
            tost.threshold = tost.threshold,
            placeboTest = placeboTest,
            placebo.period = placebo.period,
            carryoverTest = carryoverTest,
            carryover.period = carryover.period,
            ## Stored on the fit object so plot logic does not need to
            ## re-parse `x$call` --- robust under do.call(), positional
            ## args, and call-rewriting wrappers.
            carryover.rm = carryover.rm,
            ## Reserved slot for the post-hoc estimand API (v2.4.0+).
            ## NULL for plain imputation estimators (FE / IFE / MC / CFE /
            ## GSC); future doubly-robust estimators will populate this
            ## with the per-cell debias correction so that
            ## eff = (Y_obs - Y0_hat) + eff_debias is the cell-level
            ## score. See ref/po-estimands-contract.md Sec. 3.
            eff_debias = NULL,
            unit.type = unit.type,
            obs.missing = obs.missing,
            obs.missing.balance = obs.missing.balance,
            sample = sample,
            ## Original input panel (pre-drop), preserved so
            ## panelView::panelview(fit) can render the full set of
            ## units --- including those fect dropped (always-treated,
            ## insufficient pre-period, etc.) --- as "Not used" cells.
            data.long = data.old[, c(index, Yname, Dname), drop = FALSE],
            time.component.from = time.component.from,
            em = em
        ),
        out
    )

    if (1 %in% rm.id) {
        output <- c(output, list(remove.id = remove.id))
        ## message("list of removed units:",remove.id)
        ## message("\n\n")
    }

    # if (se == TRUE) {
    #    suppressWarnings(test.out <- diagtest(output, pre.periods = pre.periods,
    #        f.threshold = f.threshold, tost.threshold = tost.threshold))
    #    output <- c(output, list(test.out = test.out))
    # }

    if (permute == TRUE) {
        output <- c(output, list(permute = permute.result))
    }

    if (placeboEquiv == TRUE) {
        output <- c(
            output,
            list(
                pre.est.att = pre.est.att,
                pre.att.bound = pre.att.bound,
                pre.att.boot = pre.att.boot,
                pre.est.group.output = pre.est.group.output
            )
        )
    }

    # if (placeboEquiv || placeboTest || carryoverTest) {
    # classic equivalence test, placeboTest, and carryoverTest
    # this can also be used in placeboTest

    # classic equivalence test
    if (loo == TRUE) {
        output$loo <- FALSE
    }

    if (se == 1) {
        suppressWarnings(
            test.out <- diagtest(
                output,
                pre.periods = pre.periods,
                f.threshold = f.threshold,
                tost.threshold = tost.threshold,
                N_bar = N_bar
            )
        )
        output <- c(output, list(test.out = test.out))
    }

    # loo equivalence test
    if (loo == TRUE) {
        output$loo <- TRUE
    }
    if (loo == TRUE && se == 1) {
        suppressWarnings(
            test.out <- diagtest(
                output,
                pre.periods = pre.periods,
                f.threshold = f.threshold,
                tost.threshold = tost.threshold,
                N_bar = N_bar
            )
        )
        output <- c(output, list(loo.test.out = test.out))
    }

    output <- c(output, list(call = match.call()))

    ## When W is supplied AND the aggregation surface should be weighted
    ## (W or W.agg supplied), route the W-weighted aggregations into the
    ## canonical slot names so fit$att, fit$time, fit$count, fit$att.avg,
    ## fit$att.off etc. agree with fit$est.att, fit$est.avg, plot(fit), and
    ## print(fit). When only W.est is supplied, the aggregation surface
    ## stays unweighted (canonical slots untouched). Either way, strip the
    ## redundant *.W slots from the user-facing fit object so there is only
    ## one canonical aggregation. (Inferential slots --- est.att, est.avg,
    ## est.att90, att.bound, att.boot, att.vcov, est.placebo, est.carryover,
    ## off variants --- are routed inside fect_boot() before reaching here.)
    if (!is.null(Wname) && isTRUE(use.W.in.agg)) {
        if (!is.null(output$att.on.W))      output$att        <- output$att.on.W
        if (!is.null(output$time.on.W))     output$time       <- output$time.on.W
        if (!is.null(output$count.on.W))    output$count      <- output$count.on.W
        if (!is.null(output$att.avg.W))     output$att.avg    <- output$att.avg.W
        if (!is.null(output$att.off.W))     output$att.off    <- output$att.off.W
        if (!is.null(output$time.off.W))    output$time.off   <- output$time.off.W
        if (!is.null(output$count.off.W))   output$count.off  <- output$count.off.W
        if (!is.null(output$att.placebo.W))   output$att.placebo   <- output$att.placebo.W
        if (!is.null(output$att.carryover.W)) output$att.carryover <- output$att.carryover.W
    }
    if (!is.null(Wname)) {
        output[c(
            "att.on.W", "time.on.W", "count.on.W", "att.avg.W",
            "att.on.sum.W", "W.on.sum",
            "att.off.W", "time.off.W", "count.off.W",
            "att.off.sum.W", "W.off.sum",
            "att.placebo.W", "att.carryover.W"
        )] <- NULL
    }

    ## v2.4.3+: convergence diagnostic. The EM check is
    ##   ||fit_new - fit_old||_F / ||fit_old||_F < tol
    ## When niter >= max.iteration, the EM was halted before satisfying
    ## the tol gate. The reported att.avg may not reflect a converged
    ## minimum; users should inspect (and ideally raise max.iteration)
    ## before publishing.
    if (!is.null(output$niter) && length(output$niter) == 1 &&
        is.finite(output$niter) && output$niter >= max.iteration) {
        warning(sprintf(
            paste(
                "EM did not converge within max.iteration = %d (final niter = %d).",
                "Estimates may be under-converged. Consider raising max.iteration",
                "and/or tightening tol. If att.avg shifts substantially under",
                "longer runs, the data may be poorly conditioned for this model",
                "specification (e.g. very small Ntr, near-collinear factors,",
                "or a misspecified rank).", sep = "\n  "),
            as.integer(max.iteration), as.integer(output$niter)
        ), call. = FALSE)
    }

    ## Surface the user's group.fe input on the fit object so print/summary
    ## can show the absorbed FE composition (per D8 in design memo). Also
    ## store the user-facing cl column name (cl gets reshaped to a matrix
    ## internally for the bootstrap; we want the original label here).
    output$group.fe <- group.fe
    output$cl.label <- cl.label

    class(output) <- "fect"
    return(output)
} ## Program fect ends

Try the fect package in your browser

Any scripts or data that you put into this service are public.

fect documentation built on May 31, 2026, 1:06 a.m.