5  Complex Fixed Effects

The Complex Fixed Effects (CFE) estimator extends the standard two-way fixed effects counterfactual by incorporating additional model components: extra additive fixed effects, time-invariant covariates with time-varying coefficients, unit-specific loadings on known time trends, and interactive fixed effects (latent factors). Each component helps to relax specific assumptions about the data-generating process, and this chapter introduces them one at a time. R script used in this chapter can be downloaded here.

In Chapter 4, we introduced factor-based methods (IFE and MC) that model latent common factors. CFE generalizes this framework by allowing researchers to incorporate additional observed structure alongside latent factors.

data(simdata)
data(sim_region)
data(sim_linear)
data(sim_trend)

5.1 When to use CFE

The standard IFEct estimator (method = "ife") assumes that the untreated potential outcome is driven by observed time-varying covariates, interactive fixed effects, plus unit and time fixed effects:

\[Y_{it}^{0} = \mu + X_{it}'\beta + \sum_{m=1}^{r} \lambda_{im} f_{tm} + \alpha_i + \xi_t + e_{it}\]

This may be insufficient when:

  1. Units belong to groups with group-level time shocks (additional FEs needed),
  2. Time-invariant characteristics have effects that change over time (\(Z'\gamma\)),
  3. Units follow known time trends with heterogeneous intensity (\(\kappa' Q\)),

The CFE estimator models the untreated potential outcome as:

\[Y_{it}^{0} = \mu + X_{it}'\beta + \sum_{m=1}^{r} \lambda_{im} f_{tm} + \alpha_i + \xi_t + \omega_{k(i)} + Z_i'\gamma_{g(t)} + \kappa_i' Q_t + e_{it}\]

where:

  • \(\mu\) is the grand mean,
  • \(X_{it}'\beta\) captures time-varying covariates with constant coefficients,
  • \(\lambda_{im} f_{tm}\) are interactive fixed effects (latent factors),
  • \(\alpha_i\) and \(\xi_t\) are unit and time fixed effects,
  • \(\omega_{k(i)}\) are additional group-level fixed effects,
  • \(Z_i'\gamma_{g(t)}\) captures time-invariant covariates with coefficients that vary by time group,
  • \(\kappa_i' Q_t\) captures unit-specific loadings on known time trends,
  • \(e_{it}\) is the idiosyncratic error.

Most of the above elements, except additional group-level fixed effects \(\omega_{k(i)}\), can be captured by an interactive fixed effects model. Knowing \(Z_i\) or \(Q_t\), however, can improve efficiency. In practice, we often do not know the exact functional form of \(Q\), and \(Z\) may suffer from severe measurement error. Therefore, including these terms may not yield the expected gains and may instead inflate variance.

In general, we recommend a data-driven approach to model selection, such as using fect_mspe(), which is based on cross-validation.

5.1.1 Observed vs. unobserved

Component What the researcher provides What fect estimates
\(X_{it}\) (time-varying covariates) Covariates in the formula (right-hand side) \(\beta\) — constant coefficients
\(Z_i\) (time-invariant covariates) Column names via Z argument \(\gamma_{g(t)}\) — coefficients that vary by time group
\(Q_t\) (known time basis) Either column names via Q, or auto-generated via Q.type \(\kappa_i\) — unit-specific loadings
Group indicators Extra elements in index \(\omega_k\) — group fixed effects
Latent factors Number of factors via r Both \(\lambda_i\) (loadings) and \(f_t\) (factors)

5.1.2 Algorithm

The model is estimated via an EM-style block coordinate descent algorithm. The algorithm iterates between imputing missing or treated entries with current fitted values, updating each component block — covariates (\(\beta\)), time-invariant covariate coefficients (\(\gamma\)), unit-specific trend loadings (\(\kappa\)), additive fixed effects (\(\alpha\), \(\xi\), \(\omega\)), and interactive fixed effects via SVD (\(\lambda\), \(f\)) — and checking convergence. The entire procedure is implemented in C++ for computational efficiency.

The CFE estimator is activated by method = "cfe". The key arguments that control the model components are: index (additional FEs), Z and gamma (time-invariant covariates), Q.type or Q and kappa (time trends), and r (latent factors). The rest of this chapter introduces each component individually.


5.2 Additional fixed effects

When units belong to groups — such as regions, industry sectors, or cohorts — group-level time shocks can affect outcomes and correlate with treatment assignment. In unbalanced panels where units enter or exit at different times, the group structure becomes especially important: without accounting for group effects, the counterfactual imputation is biased and placebo tests fail.

5.2.1 Data-generating process

The untreated potential outcome in this DGP follows:

\[Y_{it}^{0} = \alpha_i + \xi_t + \delta_{g(i),t} + e_{it}\]

where \(\alpha_i\) is a unit fixed effect, \(\xi_t\) is a time fixed effect, and \(\delta_{g(i),t}\) is a region-specific time effect that serves as the confounding source. Because treatment assignment and timing both depend on region, \(\delta_{g(i),t}\) correlates with treatment status — creating exactly the kind of bias that additional fixed effects are designed to absorb.

We generate an unbalanced panel with 300 units belonging to 5 regions. Each region has a distinct linear time trend, and treatment probability and timing both depend on region. Units in higher-numbered regions enter the panel later, creating the kind of unbalancedness common in real datasets (e.g., firms entering markets, countries joining surveys).

The sim_region dataset ships with the package. It is an unbalanced panel with 500 units belonging to 5 regions. The DGP generation script is in data-raw/sim_region.R.

head(sim_region)
#>   time id region         Y D region_time
#> 1    1  1      1 2.2627625 0         1.1
#> 2    2  1      1 2.6074670 0         1.2
#> 3    3  1      1 2.0547940 0         1.3
#> 4    4  1      1 0.3864159 0         1.4
#> 5    5  1      1 2.6774928 0         1.5
#> 6    6  1      1 2.5262563 0         1.6

5.2.2 Without additional fixed effects

We first try the standard FE estimator, which ignores the region-level shocks:

out.fe.only <- fect(Y ~ D, data = sim_region,
  index = c("id", "time"),
  method = "fe", force = "two-way",
  se = TRUE, parallel = TRUE, cores = 16, nboots = 200,
  placeboTest = TRUE, placebo.period = c(-2, 0))
plot(out.fe.only, 
     stats = c("placebo.p", "equiv.p"),
     main = "FE Only — Placebo Test")

The placebo test detects significant pre-trends (low p-value) because the model fails to account for region-level confounding. In the unbalanced panel, group-level time trends \(\delta_{g(i),t}\) interact with the differential entry times across regions to create spurious pre-trends that the standard two-way fixed effects model cannot absorb.

5.2.3 With additional fixed effects

Now we interact region with time to create group×period fixed effects, which absorb the region-specific time shocks \(\delta_{g(i),t}\). A simple region intercept FE cannot capture these shocks because they vary across both regions and time periods. By passing region_time as the third index element, the CFE estimator absorbs the full set of region-by-period effects:

out.cfe.region <- fect(Y ~ D, data = sim_region,
  index = c("id", "time", "region_time"),
  method = "cfe", force = "two-way",
  se = TRUE, parallel = TRUE, cores = 16, nboots = 200,
  placeboTest = TRUE, placebo.period = c(-2, 0))
plot(out.cfe.region, 
     stats = c("placebo.p", "equiv.p"),
     main = "CFE with Region×Time FE — Placebo Test")

The placebo test now passes (high p-value), confirming that the region×time fixed effects absorb the group-level confounding. By including the interacted region_time variable in the index argument, the CFE estimator absorbs the full set of region-specific time shocks \(\delta_{g(i),t}\) during counterfactual imputation, removing the bias that was driving the spurious pre-trends.

In the index argument, elements beyond the first two (unit, time) are treated as additional fixed-effect grouping variables. You can include multiple additional groupings: index = c("id", "time", "region_time", "sector_time").

5.3 Time-invariant covariates with time-varying coefficients

When time-invariant unit characteristics \(Z_i\) (e.g., baseline GDP, initial population) have effects that change over time, a simple additive control is insufficient. The CFE model allows \(Z_i'\gamma_{g(t)}\), where \(\gamma\) varies by time group, capturing the time-varying nature of these effects.

5.3.1 The challenge with interactive structures

We use the existing simdata dataset. In the true DGP, \(Y_{it}^{0}\) includes the interactive term \(L_{1,i} \cdot F_{1,t}\) — a product of the unit-level factor loading \(L_1\) and the time-varying factor \(F_1\). If we observe \(L_1\) as a covariate but estimate it with a constant coefficient, we miss the time-varying nature of its effect. The CFE \(Z/\gamma\) mechanism handles this.

First, we estimate the FE model, which ignores the interactive structure entirely:

out.fe.base <- fect(Y ~ D + X1 + X2, data = simdata,
  index = c("id", "time"),
  method = "fe", force = "two-way",
  se = TRUE, parallel = TRUE, cores = 16, nboots = 200,
  placeboTest = TRUE, placebo.period = c(-2, 0))
plot(out.fe.base, 
     stats = c("placebo.p", "equiv.p"),
     main = "FE Only (simdata) — Placebo Test")

The FE estimator fails the placebo test because it cannot account for the interactive term \(L_1 \cdot F_1\). Now we include \(L_1\) as a time-invariant covariate with time-varying coefficients. We create a gamma variable — here we use time itself as the grouping variable, which gives the most flexible specification (one coefficient per period):

simdata$gamma_t <- simdata$time
out.cfe.z <- fect(Y ~ D + X1 + X2, data = simdata,
  index = c("id", "time"),
  method = "cfe", force = "two-way",
  Z = "L1", gamma = "gamma_t",
  se = TRUE, parallel = TRUE, cores = 16, nboots = 200,
  placeboTest = TRUE, placebo.period = c(-2, 0))
plot(out.cfe.z, 
     stats = c("placebo.p", "equiv.p"),
     main = "CFE with Z = L1 — Placebo Test")

By allowing \(L_1\)’s coefficient to vary across time periods, CFE approximates the true interactive structure \(L_{1,i} \cdot F_{1,t}\). The placebo test shows improvement. However, there is still a second latent factor \(L_2 \cdot F_2\) that is not captured — we will address this in Section 4.5.

The Z argument takes a character vector of column names for time-invariant covariates. The gamma argument specifies a column that defines the time grouping for the \(Z\) coefficients. Using time as the gamma variable gives the most flexible specification; coarser groupings (e.g., early/middle/late) reduce the number of parameters.


5.5 CFE with factors

Sometimes the data contains both observed structure (known covariates whose effects vary over time) and unobserved latent factors. The CFE estimator can combine \(Z'\gamma\) and interactive fixed effects \(\lambda' f\) in a single model.

We use simdata, which has two latent factors (\(L_1, F_1\) and \(L_2, F_2\)). We pretend \(L_1\) is an observed time-invariant covariate and model the remaining factor structure with \(r = 1\) latent factor.

simdata$gamma_t <- simdata$time

5.5.1 Model comparison via fect_mspe

We fit four models and compare their out-of-sample prediction accuracy:

  1. FE only (baseline),
  2. CFE with \(Z = L_1\) only (one observed loading, no factors),
  3. CFE with \(Z = L_1\) + 1 latent factor (correct specification),
  4. IFE with 2 latent factors (correct for IFE, but less efficient than CFE).
# Model 1: FE only
out.fe <- fect(Y ~ D + X1 + X2, data = simdata,
  index = c("id", "time"),
  method = "fe", force = "two-way", se = FALSE)

# Model 2: CFE with Z = L1 only
out.cfe.z.only <- fect(Y ~ D + X1 + X2, data = simdata,
  index = c("id", "time"),
  method = "cfe", force = "two-way",
  Z = "L1", gamma = "gamma_t",
  se = FALSE, max.iteration = 20000)

# Model 3: CFE with Z = L1 + 1 factor
out.cfe.z.f1 <- fect(Y ~ D + X1 + X2, data = simdata,
  index = c("id", "time"),
  method = "cfe", force = "two-way",
  Z = "L1", gamma = "gamma_t",
  r = 1, se = FALSE, max.iteration = 20000)

# Model 4: IFE with 2 factors
out.ife.r2 <- fect(Y ~ D + X1 + X2, data = simdata,
  index = c("id", "time"),
  method = "ife", force = "two-way",
  r = 2, se = FALSE, max.iteration = 20000)
mspe.out <- fect_mspe(list(FE = out.fe,
       CFE_Z = out.cfe.z.only,
       CFE_Z_F1 = out.cfe.z.f1,
       IFE_r2 = out.ife.r2),
  seed = 1234)
print(mspe.out$summary[, c("Model", "MSPE", "RMSE", "MAD")])
#>      Model     MSPE     RMSE      MAD
#> 1    CFE_Z 18.86736 4.343657 7.050430
#> 2 CFE_Z_F1 14.28647 3.779745 5.315171
#> 3       FE 26.75372 5.172400 9.317792
#> 4   IFE_r2 15.22551 3.901988 5.552019

All three metrics consistently rank the CFE model with observed \(Z\) and one latent factor as the best specification. CFE with \(Z + 1\) factor has the lowest MSPE because it uses observed information (\(L_1\)) efficiently and estimates only the remaining unobserved factor (\(L_2\)). The IFE with \(r=2\) should also do well but slightly worse, since it estimates both factors entirely from the data.

5.5.2 Best model: placebo test

out.cfe.best <- fect(Y ~ D + X1 + X2, data = simdata,
  index = c("id", "time"),
  method = "cfe", force = "two-way",
  Z = "L1", gamma = "gamma_t",
  r = 1,
  se = TRUE, parallel = TRUE, cores = 16, nboots = 200,
  placeboTest = TRUE, placebo.period = c(-2, 0))
plot(out.cfe.best, 
     stats = c("placebo.p", "equiv.p"),
     main = "CFE (Z + 1 Factor) — Placebo Test")

The placebo test passes, confirming that the CFE model with one observed loading and one latent factor adequately captures the DGP.

fect_mspe() compares out-of-sample prediction accuracy across different model specifications. A lower MSPE indicates better counterfactual prediction. This is useful for selecting among CFE configurations when the true model is unknown.


5.6 Grouped coefficients

In some applications, the \(Z\)-\(\gamma\) or \(\kappa\)-\(Q\) coefficients should not all share the same grouping structure. For example:

  • Z.param: Suppose you have two time-invariant covariates — baseline GDP and initial population — and their effects change at different rates. GDP effects may shift by decade, while population effects shift by political era. Z.param allows you to assign different gamma groupings to different \(Z\) variables.
  • Q.param: Similarly, if you specify multiple \(Q\) time bases, Q.param controls which unit-grouping variable is used for each \(Q\) basis.

5.6.1 Z.param syntax

# Example with Z.param (not run — requires appropriate data)
# out <- fect(Y ~ D, data = mydata,
#   index = c("unit", "time"),
#   method = "cfe", force = "two-way",
#   Z = c("baseline_gdp", "baseline_pop"),
#   gamma = c("decade", "political_era"),
#   Z.param = list(decade = "baseline_gdp",
#                  political_era = "baseline_pop"))

This means: baseline_gdp uses decade for its time grouping, while baseline_pop uses political_era.

5.6.2 Q.param syntax

Q.param = list(sector = c("Q_linear", "Q_quadratic"), region = "Q_spline")

This means: the linear and quadratic trend bases are grouped by sector, while the spline basis is grouped by region.

5.6.3 When to use grouped coefficients

  • Use Z.param when different covariates’ effects change at different timescales.
  • Use Q.param when different trend components should vary by different unit groupings.
  • Without Z.param/Q.param, all \(Z\) variables share all gamma groupings (fully crossed), and all \(Q\) variables share all kappa groupings — this may overparameterize the model.

Z.param and Q.param are optional. Without them, all \(Z\) (or \(Q\)) variables are assigned to all gamma (or kappa) groupings. Use them to impose structure when you have domain knowledge about which covariates should share which groupings.


5.7 Cross-validation

The number of latent factors r in a CFE specification can be selected by cross-validation through the main fect() dispatcher — set CV = TRUE and pass cv.method = "rolling" to use rolling-window CV (recommended on macro panels with serially correlated residuals; see Chapter 4). CFE-specific structural arguments (Z, gamma, Q, Q.type, kappa, plus extra index columns for additional grouping FEs) are held fixed at the user-supplied spec; CV varies only r.

fit.cfe <- fect(Y ~ D, data = sim_region,
  index   = c("id", "time", "region_time"),
  method  = "cfe", force = "two-way",
  CV      = TRUE, r = c(0, 3),
  cv.method = "rolling",
  cv.buffer = 1, cv.nobs = 3, k = 20, cv.prop = 0.1,
  cv.rule = "1se",
  se = TRUE, parallel = TRUE, cores = 16, nboots = 200
)
fit.cfe$r.cv  # selected r

Block CV (cv.method = "block") is also available as a drop-in replacement; see the Cross-Validation section of the cheatsheet for the full parameter reference and the deprecation plan for the legacy "all_units" / "treated_units" values.


5.8 Summary of CFE-specific arguments

Argument Type Description
method = "cfe" character Activates the CFE estimator
index character vector c(unit, time, ...) — elements beyond the first two are extra additive FEs
Z character vector Names of time-invariant covariate columns
gamma character vector Names of columns defining time grouping for \(Z\) coefficients
Q character vector Names of known time trend columns (user-supplied)
kappa character vector Names of columns defining unit/group assignment for \(Q\) loadings
Q.type character vector Auto-generate \(Q\): "linear", "quadratic", "cubic", "bspline"
Q.bspline.degree integer Degree of B-spline when Q.type = "bspline" (default: auto)
Z.param named list Block assignment: which \(Z\) columns share which \(\gamma\) grouping
Q.param named list Block assignment: which \(Q\) columns share which \(\kappa\) grouping
r integer or vector Number of latent factors; use c(0, 5) with CV = TRUE to select
force character Additive FE: "none", "unit", "time", "two-way"

The CFE estimator combines all these components into a single unified model. Each component relaxes a specific assumption about the data-generating process. In practice, researchers should use fect_mspe() (Section 4.5) to compare specifications and placebo tests to validate the chosen model.