Formulas, standard errors and the sample¶
Formulas are parsed by pyfixest’s own parser, so the syntax and the resulting coefficient names are pyfixest’s. Every covariate term is then compiled into a Polars expression, so the design is built while the data streams, one bucket at a time, and never has to be built in memory: a categorical expanded into hundreds of indicators is carried through the partitioning as the one column it comes from.
Covariates¶
Transformations and interactions. I() for arithmetic, log(), exp()
and sqrt(), and : and * for interactions:
feols_stream("log_earn ~ I(age ** 2) + log(age) + age_squared:age_cubed"
" | worker_id + firm_id + year", data, workdir="scratch")
Categoricals. C(x) for a categorical covariate; string columns work too.
One level is absorbed as the reference, exactly as pyfixest chooses it, and the
coefficients are named as in pyfixest (C(occ)[T.1], cat[k0]:age_squared):
feols_stream("log_earn ~ C(occ) + cat:age_squared | worker_id + firm_id + year",
data, workdir="scratch")
Event studies. i(over, by, ref=...) gives one coefficient per level of
over, interacted with by, dropping the reference level. The simulated data
has a treatment effect of 0.1 that switches on in 2010:
event = feols_stream(
"log_earn ~ i(year, treat, ref=2009) + age_squared | worker_id + firm_id",
data, workdir="scratch", vcov={"CRV1": "firm_id"})
│ year::2008:treat ┆ -0.017075 ┆ 0.006682 ┆ -2.555356 ┆ 0.010718 │
│ year::2010:treat ┆ 0.098927 ┆ 0.007478 ┆ 13.228988 ┆ 1.2587e-37 │
│ year::2011:treat ┆ 0.102635 ┆ 0.00711 ┆ 14.435942 ┆ 5.3516e-44 │
Reporting plots it with pyfixest’s iplot.
Fixed effects¶
Any number of dimensions works, including one (a within regression) and none
(OLS with an intercept); see one fixed effect, or none.
^ combines columns into one dimension, with a separate effect per firm–year
cell:
feols_stream("log_earn ~ age_squared + age_cubed | worker_id + firm_id^year",
data, workdir="scratch", vcov={"CRV1": "firm_id"})
Varying slopes on the streamed dimension use fixest syntax, worker_id[t]; see
varying slopes.
Singletons. Observations alone in their level of some fixed effect are
dropped, as in pyfixest, repeating until none is left, since dropping one can
create another in a different dimension (fixef_rm="singleton", the default;
fixef_rm="none" keeps them). They contribute nothing to the coefficients but
count toward N, the number of clusters and the fixed-effect parameters. Finding
them costs a few extra passes over the fixed-effect columns and memory for the
singleton levels, not for the rows.
Connected components of the worker–firm graph are computed and reported,
and a fit can be restricted to the largest connected set with
connected="largest"; see connected sets.
Several models at once¶
Several outcomes (y1 + y2 ~ ...), cumulative or plain stepwise covariate sets
(csw(), sw()), and stepwise fixed-effect sets:
multi = feols_stream(
"log_earn + y2 ~ csw(age_squared, age_cubed)"
" | sw(worker_id + firm_id, worker_id + firm_id + year)",
data, workdir="scratch", vcov="hetero")
len(multi) # 8 models
multi.tidy() # one table, with a column for the formula
multi.fetch_model(0) # or iterate over multi
Models sharing a fixed-effect set share one pass over the data and one solve,
which is the point of asking for them together. They are estimated on the rows
where all of their variables are non-missing (pyfixest drops missing values
model by model). The result is an HDFEMulti.
Missing values and the sample¶
Rows with a null or non-finite value in any variable the formula uses are
dropped, as pyfixest drops them. In the simulated data x_na is null in about
5% of rows:
fit = feols_stream("log_earn ~ age_squared + x_na | worker_id + firm_id + year",
data, workdir="scratch")
fit.n_obs # 161,340 of the file's 169,833 rows
fit.sample() says which rows were used and why the others were not. It returns
the source, lazily, with three columns added: row_id, each row’s position in
the source before any row was dropped; in_sample; and dropped_because, null
for the rows used and otherwise "missing", "min_degree", "disconnected",
"leave_one_out", "singleton" or "separation":
fit.sample().group_by("dropped_because").len().collect()
┌─────────────────┬────────┐
│ dropped_because ┆ len │
╞═════════════════╪════════╡
│ null ┆ 161340 │
│ missing ┆ 8493 │
└─────────────────┴────────┘
The residual file is in the estimator’s order, not the source’s, so every row
carries row_id (row_id= renames the column, for a source that already has
one by that name). resid() joins back onto the source on it, for looking at
the data net of what the fit dropped, or at the residuals by a column the model
did not use:
rows = fit.sample().join(fit.resid().select("row_id", "resid"), on="row_id", how="left")
rows.group_by("occ").agg(pl.col("resid").mean()).collect()
Nothing is stored per row for this: the frame is the source plus the small
tables of dropped levels, and row_id adds a few bytes per row to the working
and residual files. It relies on the source reading back in the same order,
which a file, a glob or a DataFrame does and a LazyFrame does only if its plan
does (not after a group_by, an unordered join or a random sample). For a model
list, multi.sample() returns {formula: frame}; models fitted on the same
fixed effects share one sample.
Predictions¶
fit.predict(newdata) predicts for new rows: a DataFrame, a LazyFrame, or a
Parquet path or glob, which can be larger than memory. It returns newdata,
lazily and in its own order, with a prediction column added: the covariates
times the coefficients plus the row’s fixed effects, looked up by their levels.
Without newdata, it predicts for every row of the fit’s own source, and on the
estimation sample that reproduces the fitted values.
panel = pl.scan_parquet(data)
fit = feols_stream("log_earn ~ age_squared + age_cubed | worker_id + firm_id",
panel.filter(pl.col("year") < 2012), workdir="scratch")
later = fit.predict(panel.filter(pl.col("year") >= 2012)).collect()
later["prediction"].null_count() # null for a worker or firm the fit never saw (0 here)
A row with a fixed-effect level that has no estimated effect, because the fit
never saw it or dropped it (as a singleton, say), gets a null prediction, where
pyfixest gives NaN. For Poisson, logit and probit, the prediction includes the
offset, and type="response" gives the mean instead of the linear predictor.
Unlike pyfixest’s predict, this one works with i() covariates, interacted
fixed effects (firm_id^year) and varying slopes. pyfixest’s se_fit and
prediction intervals are not available; pyfixest computes them only for models
without fixed effects.
The fixed effects are identified only within a connected set of workers and firms, so a prediction that pairs a worker from one connected set with a firm from another depends on how each set’s effects were normalized.
Degrees of freedom¶
The standard errors count the fixed-effect parameters, net of the redundant
ones. The correction either removes the redundant levels it can identify
(fe_dof="exact", the default) or follows pyfixest’s convention
(fe_dof="pyfixest", one per fixed effect beyond the first). "exact" counts
the connected components of each pair of fixed-effect dimensions and uses the
pair with the most, plus one redundant level for each further dimension. With
two fixed effects this is the exact number of identified parameters. With more
it is an upper bound, so standard errors are conservative. For clustered
standard errors, fixed effects nested within the clusters are dropped from the
count as in pyfixest, and a pair of two nested fixed effects is not used.
On a connected panel the two agree exactly. On a panel whose network falls apart, pyfixest’s count includes a parameter for every component past the first that cannot be estimated, so its standard errors are a little too large. On the example’s thin panel, with 381 components:
panel fe_dof components FE params df se(age_squared)
connected exact 1 21,332 148,500 0.0003078
connected pyfixest 1 21,332 148,500 0.0003078
thin exact 381 21,405 78,439 0.0004183
thin pyfixest 381 21,785 78,059 0.0004193
Collinear terms¶
Terms spanned by the fixed effects are dropped exactly as pyfixest drops them,
with a warning. Here i(year) duplicates the year effect, and treat is
constant within worker, so C(treat) duplicates the worker effect:
collinear = feols_stream(
"log_earn ~ i(year) + C(treat) + age_squared | worker_id + firm_id + year",
data, workdir="scratch")
collinear.collin_vars # ['year::2006', ..., 'year::2014', 'C(treat)[T.1]']
A covariate the streamed dimension absorbs entirely is recognized during the
solve rather than costing iterations, and is listed in
solver_info["fe_spanned"].
Standard errors¶
iid, heteroskedasticity-robust (hetero/HC1), and CRV1 clustered on any
column, any ^ interaction, or several dimensions at once (multi-way
Cameron–Gelbach–Miller, any number of ways). The default is iid, as in
pyfixest. Ask for several at fit time with cluster= and switch between them
afterwards with with_vcov(): they are all computed in the one residual pass.
fit = feols_stream(
"log_earn ~ age_squared + age_cubed | worker_id + firm_id + year",
data, workdir="scratch",
vcov={"CRV1": "worker_id"},
cluster=["firm_id", "state", "worker_id+firm_id", "firm_id^year"])
fit.with_vcov("CRV1:state").se
vcov se(age_squared) clusters
iid 0.013373 -
hetero 0.013304 -
CRV1:worker_id 0.013262 [20000]
CRV1:firm_id 0.013027 [1333]
CRV1:state 0.008219 [12]
CRV1:worker_id+firm_id 0.012925 [20000, 1333]
CRV1:firm_id^year 0.013115 [13296]
CRV3, the cluster jackknife (vcov={"CRV3": var}), is computed by
downdating the fit, which is exact when every fixed effect is nested within the
clusters (firm-by-year effects clustered by firm, say) or there are none. It is
one-way and OLS only. With fixed effects that are not nested, as in worker and
firm effects clustered by worker, the jackknife would have to refit once per
cluster, and the fit refuses; use CRV1, or pyfixest, which refits.
nested = feols_stream("log_earn ~ age_squared + age_cubed | firm_id^year", data,
workdir="scratch", vcov={"CRV3": "firm_id"}, cluster=["firm_id"])
Tests and confidence intervals¶
These work from the stored coefficients and the current vcov, so they cost
nothing and follow with_vcov().
confint(alpha=0.05)gives intervals from the t distribution withdf_tdegrees of freedom (the normal for Poisson, logit and probit), astidy()’s p-values. Withjoint=True, they cover all the selected coefficients at once, with a critical value simulated from the coefficients’ correlation, as pyfixest’sconfint(joint=True).wald_test(R, q)tests R β = q, andwald_test(keep="age")tests that the coefficients whose names match are all zero. It reports F withdf_tdenominator degrees of freedom (chi-squared for GLMs, or withdistribution="chi2"), so a test of one coefficient hastidy()’s p-value.bonferroni(models, param)andholm(models, param)adjust the p-values of one coefficient (or several) across models, for the familywise error rate. Holm’s step-down adjustment is never larger than Bonferroni’s.
fit.wald_test(keep="age") # age_squared = age_cubed = 0
fit.wald_test(R=[[1, -1]], q=0) # age_squared = age_cubed
fit.confint(joint=True, seed=0)
models = feols_stream("log_earn ~ x2 + csw0(age_squared, age_cubed) | worker_id + firm_id",
data, workdir="scratch", vcov={"CRV1": "worker_id"})
holm(models, "x2")
Two differences from pyfixest’s wald_test, both on purpose. pyfixest
switches to chi-squared whenever R is not the identity or q is not zero, where
this one reports the F asked for. And without clustering, pyfixest’s F
subtracts every fixed-effect level from its denominator degrees of freedom,
including the redundant ones that its own t tests do not count; this one uses
df_t, as the t tests do.
The wild cluster bootstrap¶
With few clusters (a dozen states, say), CRV1 standard errors are unreliable,
and the wild cluster bootstrap is the usual remedy. bootcluster= keeps what
it needs during the fit: for each cluster, sums over its rows of the
covariates’ cross-products and their products with the residuals, net of the
fixed effects. That is about G × k² numbers for G clusters and k
coefficients, a few kilobytes for a dozen states, collected in the residual
pass the fit makes anyway. A fit without bootcluster= does no extra work.
After the fit, wildboottest() bootstraps any coefficient from those sums,
without reading the data again, and rwolf() and wyoung() correct for
testing it in several models, on the same draws:
by_state = feols_stream("log_earn + y2 ~ x2 + age_squared + i(year) | firm_id", data,
workdir="scratch", vcov={"CRV1": "state"}, bootcluster="state")
by_state.fetch_model(0).wildboottest("age_squared", reps=9999, seed=0)
rwolf(by_state, "x2", reps=9999, seed=0) # Romano-Wolf
wyoung(by_state, "x2", reps=9999, seed=0) # Westfall-Young
The bootstrap is exact only when every fixed effect is nested within the
clusters: a draw multiplies each cluster’s residuals by one weight, and a
fixed effect whose levels span clusters would then have to be re-estimated for
every draw. Firms are nested within states if no firm is in two of them; years
never are. A fixed effect with few levels, such as year, can go in as
covariates instead, as above: i(year) in the formula, rather than + year
after the bar, gives the same estimates of the other coefficients. A fit whose
fixed effects are not nested in bootcluster stops with an error that says
so. The bootstrap is also one-way, and for OLS only, not IV or GLMs.
It is the restricted wild cluster bootstrap (WCR, or with impose_null=False
the unrestricted one) with Rademacher, Mammen, Webb or normal weights, as in
pyfixest. With the same seed it uses the same draws as pyfixest’s
wildboottest, rwolf and wyoung, so it gives the same p-values where
pyfixest can fit the model, which puts the fixed effects in as dummy
variables. A draw that gives every cluster the same weight reproduces the
sample, and so the t statistic exactly. Such draws count as at least as
extreme, where pyfixest’s comparison leaves them to rounding; with few
clusters and two-point weights they are common (about 2% of Mammen draws with
12 clusters).
The full script: examples/formulas.py
"""Formula syntax: everything you can put on either side of the bars.
Formulas are parsed by pyfixest's own parser, so the syntax and the resulting
coefficient names are pyfixest's. Every covariate term is then compiled into a
Polars expression, which is why the design matrix never has to be built in
memory.
python examples/formulas.py
"""
import warnings
import polars as pl
from hdfe_stream import feols_stream, holm, rwolf
from simulated_data import panel_path, thin_panel_path, workdir
data = panel_path()
def show(title, fit):
print(f"\n{'=' * 72}\n{title}\n{'=' * 72}")
print(fit.tidy())
# ---------------------------------------------------------------- transformations
# I() for arithmetic, log()/exp()/sqrt(), and `:` for interactions. These are
# computed as Polars expressions while the data streams past.
show("transformations and interactions", feols_stream(
"log_earn ~ I(age ** 2) + log(age) + age_squared:age_cubed"
" | worker_id + firm_id + year",
data, workdir=workdir("terms"), verbose=False))
# ------------------------------------------------------------------ categoricals
# C(x) for a categorical covariate; string columns work too. One level is
# absorbed as the reference, exactly as pyfixest chooses it.
show("categorical covariates", feols_stream(
"log_earn ~ C(occ) + cat:age_squared | worker_id + firm_id + year",
data, workdir=workdir("categorical"), verbose=False))
# ------------------------------------------------------------------ event study
# i(over, by, ref=...) gives one coefficient per level of `over`, interacted
# with `by`, dropping the reference level. The simulated data has a treatment
# effect switching on in 2010, which is what this should find.
event = feols_stream(
"log_earn ~ i(year, treat, ref=2009) + age_squared | worker_id + firm_id",
data, workdir=workdir("event"), vcov={"CRV1": "firm_id"}, verbose=False)
show("event study: i(year, treat, ref=2009)", event)
print("\n(the simulated effect turns on in 2010 and is worth 0.1)")
# ------------------------------------------------------ interacted fixed effects
# `^` combines columns into one dimension: a separate effect per firm-year cell.
show("firm-by-year effects: firm_id^year", feols_stream(
"log_earn ~ age_squared + age_cubed | worker_id + firm_id^year",
data, workdir=workdir("interacted"), vcov={"CRV1": "firm_id"}, verbose=False))
# ------------------------------------------------------------ multiple estimation
# Several outcomes (`y1 + y2 ~ ...`), cumulative or plain stepwise covariate
# sets (csw()/sw()), and stepwise fixed-effect sets. Models sharing a
# fixed-effect set share one pass over the data and one solve, which is the
# whole point of asking for them together.
multi = feols_stream(
"log_earn + y2 ~ csw(age_squared, age_cubed)"
" | sw(worker_id + firm_id, worker_id + firm_id + year)",
data, workdir=workdir("multi"), vcov="hetero", verbose=False)
print(f"\n{'=' * 72}\nmultiple estimation: 2 outcomes x 2 covariate sets x 2 FE sets"
f"\n{'=' * 72}")
print(f"{len(multi)} models fitted in one call\n")
print(multi.tidy())
# Individual models come back by position or by iteration
print("\nfirst model:", multi.fetch_model(0).fml)
# ------------------------------------------------------------- missing values
# Rows with a null or non-finite value in any variable the formula uses are
# dropped, as pyfixest drops them. `x_na` is null in about 5% of rows.
with_missing = feols_stream(
"log_earn ~ age_squared + x_na | worker_id + firm_id + year",
data, workdir=workdir("missing"), verbose=False)
total = pl.scan_parquet(data).select(pl.len()).collect().item()
print(f"\n{'=' * 72}\nmissing values\n{'=' * 72}")
print(f"rows in the file: {total:,}")
print(f"rows used: {with_missing.n_obs:,.0f}")
# sample() says which rows were used and why the others were not: the source,
# lazily, with `row_id` (each row's position in the file), `in_sample` and
# `dropped_because`. Nothing is stored per row to make this work.
rows = with_missing.sample()
print("\nwhy rows were dropped:")
print(rows.group_by("dropped_because").len().sort("dropped_because").collect())
# The residual file is in the estimator's own order, but carries `row_id`, so
# it joins back onto the source -- here to see the residuals by occupation, a
# column the model did not use.
by_occ = (rows.join(with_missing.resid().select("row_id", "resid"), on="row_id", how="left")
.group_by("occ").agg(pl.col("resid").mean().alias("mean resid"),
pl.col("resid").is_null().sum().alias("rows not used"))
.sort("occ").collect())
print("\nresiduals joined back onto the source on row_id, by occupation:")
print(by_occ)
# row_id= renames the column, for a source that already has one by that name.
# For several models, sample() returns {formula: frame}. Models fitted together
# on the same fixed effects share one sample: all rows complete in every one of
# their variables.
samples = multi.sample()
used = {f: s.filter("in_sample").select(pl.len()).collect().item() for f, s in samples.items()}
print(f"\nmulti.sample(): one frame per formula ({len(samples)}), rows used: "
+ ", ".join(f"{n:,}" for n in sorted(set(used.values()))))
# ---------------------------------------------------------------- predictions
# predict() adds a prediction column to new rows, lazily: covariates times the
# coefficients plus each row's fixed effects, looked up by their levels. A row
# with a worker or firm the fit never saw (or dropped) gets null.
panel = pl.scan_parquet(data)
early = feols_stream("log_earn ~ age_squared + age_cubed | worker_id + firm_id",
panel.filter(pl.col("year") < 2012), workdir=workdir("formulas"),
verbose=False)
later = early.predict(panel.filter(pl.col("year") >= 2012)).collect()
print(f"\npredict(): {later.height:,} later rows, "
f"{later['prediction'].null_count():,} with a worker or firm the fit never saw")
# ------------------------------------------------- fixed-effect degrees of freedom
# The standard errors count the fixed-effect parameters, net of the redundant
# ones. fe_dof="exact" (the default) finds those from the connected components:
# one redundant level per component of the worker-firm network. fe_dof="pyfixest"
# counts one per fixed effect beyond the first, as pyfixest does. On a connected
# panel, like the one above, the two agree exactly. On a panel whose network
# falls apart, pyfixest's count includes a parameter for every component past
# the first that cannot be estimated, so its standard errors are a little too
# large. (With three or more fixed effects "exact" is an upper bound, so its
# standard errors are conservative.)
thin = thin_panel_path()
# (the fits below drop a few singletons; their warnings are beside the point here)
warnings.filterwarnings("ignore", message=".*singleton observations dropped")
print(f"\n{'=' * 72}\nfe_dof: counting the fixed-effect parameters\n{'=' * 72}")
print(f"{'panel':<12}{'fe_dof':<10}{'components':>11}{'FE params':>11}{'df':>9}"
f"{'se(age_squared)':>17}")
for name, source in (("connected", data), ("thin", thin)):
for dof in ("exact", "pyfixest"):
fit = feols_stream("log_earn ~ age_squared | worker_id + firm_id", source,
workdir=workdir("fe_dof"), fe_dof=dof, verbose=False)
print(f"{name:<12}{dof:<10}{fit.n_components:>11,}{fit.k_fe:>11,}{fit.df_t:>9,}"
f"{fit.se[0]:>17.7f}")
# ---------------------------------------------------------------- collinearity
# Terms spanned by the fixed effects are dropped, exactly as pyfixest drops
# them: i(year) duplicates the year effect, and `treat` is constant within
# worker so C(treat) duplicates the worker effect.
collinear = feols_stream(
"log_earn ~ i(year) + C(treat) + age_squared | worker_id + firm_id + year",
data, workdir=workdir("collinear"), verbose=False)
print(f"\n{'=' * 72}\ncollinear terms are dropped\n{'=' * 72}")
print("dropped:", collinear.collin_vars)
print("kept: ", collinear.coefnames)
# A covariate the streamed dimension absorbs entirely is recognized during the
# solve rather than costing iterations
print("absorbed by the streamed dimension:",
collinear.solver_info.get("fe_spanned", []))
# ------------------------------------------------------------- clustering
# Cluster on anything: a fixed effect, a column that is not a fixed effect, an
# interaction, or several dimensions at once (Cameron-Gelbach-Miller).
clustered = feols_stream(
"log_earn ~ age_squared + age_cubed | worker_id + firm_id + year",
data, workdir=workdir("clusters"),
vcov={"CRV1": "worker_id"},
# ask for more in the same pass, then switch between them for free
cluster=["firm_id", "state", "worker_id+firm_id", "firm_id^year"],
verbose=False)
print(f"\n{'=' * 72}\nstandard errors under different clusterings\n{'=' * 72}")
print(f"{'vcov':<26}{'se(age_squared)':>18}{'clusters':>16}")
for key in ["iid", "hetero", "CRV1:worker_id", "CRV1:firm_id", "CRV1:state",
"CRV1:worker_id+firm_id", "CRV1:firm_id^year"]:
switched = clustered.with_vcov(key)
groups = switched.n_clusters.get(key.removeprefix("CRV1:"), "-")
print(f"{key:<26}{switched.se[0]:>18.6f}{str(groups):>16}")
# CRV3, the cluster jackknife, is computed by downdating the fit, which is exact
# when every fixed effect is nested within the clusters: here firm-by-year
# effects, clustered by firm. (OLS and one-way only.)
nested = feols_stream(
"log_earn ~ age_squared + age_cubed | firm_id^year", data,
workdir=workdir("crv3"), vcov={"CRV3": "firm_id"}, cluster=["firm_id"],
verbose=False)
print(f"\nfirm-year effects clustered by firm: se(age_squared) "
f"CRV3 {nested.se[0]:.6f}, CRV1 {nested.with_vcov({'CRV1': 'firm_id'}).se[0]:.6f}")
# With fixed effects that are not nested -- worker and firm effects clustered by
# worker -- the jackknife would have to refit once per cluster, and the fit says so.
try:
feols_stream("log_earn ~ age_squared | worker_id + firm_id", data,
workdir=workdir("crv3"), vcov={"CRV3": "worker_id"}, verbose=False)
except ValueError as err:
print(f"worker and firm effects clustered by worker: {str(err).split('.')[0]}.")
# ------------------------------------------------- tests and confidence intervals
# From the stored coefficients and vcov (the current one: switch with
# with_vcov). wald_test tests R beta = q; keep= selects coefficients to test
# jointly against zero. confint(joint=True) covers all of them at once.
print(f"\n{'=' * 72}\ntests and confidence intervals (clustered by worker)\n{'=' * 72}")
wald = clustered.wald_test(keep="age")
print(f"age_squared = age_cubed = 0: F = {wald['statistic']:.1f} "
f"on ({wald['df1']}, {wald['df2']:,}) df, p = {wald['pvalue']:.2g}")
print(clustered.confint(joint=True, seed=0))
# Bonferroni and Holm corrections for one coefficient tested in several models
models = feols_stream("log_earn ~ x2 + csw0(age_squared, age_cubed) | worker_id + firm_id",
data, workdir=workdir("multcomp"), vcov={"CRV1": "worker_id"},
verbose=False)
print(holm(models, "x2").select("fml", "Pr(>|t|)", "Holm Pr(>|t|)"))
# ------------------------------------------------------ wild cluster bootstrap
# With 12 states, CRV1 by state is unreliable; the wild cluster bootstrap is
# the usual remedy. bootcluster= keeps the per-cluster sums it needs during the
# fit. Every fixed effect must be nested within the clusters: firms are (each
# is in one state), years are not, so they go in as covariates, which leaves
# the other coefficients as they would be with year effects.
by_state = feols_stream("log_earn + y2 ~ x2 + age_squared + i(year) | firm_id", data,
workdir=workdir("wildboot"), vcov={"CRV1": "state"},
bootcluster="state", verbose=False)
earnings = by_state.fetch_model(0)
boot = earnings.wildboottest("age_squared", reps=9999, seed=0)
print(f"\n{'=' * 72}\nwild cluster bootstrap, 12 states\n{'=' * 72}")
print(f"age_squared: t = {boot['t value']:.2f}, CRV1 p = {earnings.tidy()['Pr(>|t|)'][1]:.4f}, "
f"bootstrap p = {boot['Pr(>|t|)']:.4f}")
# Romano-Wolf: x2 in both models, on the same draws
print(rwolf(by_state, "x2", reps=9999, seed=0).select("fml", "Pr(>|t|)", "RW Pr(>|t|)"))