estimatr 2.0.0 is a ground-up rewrite aimed at the DeclareDesign use
case: OLS, Lin-adjusted OLS, 2SLS IV, difference-in-means,
Horvitz-Thompson, and linear hypothesis tests with heteroskedasticity-
and cluster-robust standard errors. It fixes several long-standing
correctness bugs, improves performance on the critical path, adds
feols-style fixed effects absorption, and replaces the O(N²)
Horvitz-Thompson variance with a design-aware O(1) computation. The six
estimators keep their signatures, with one exception:
horvitz_thompson(), whose five probability arguments
consolidate into condition_prs. Removals are listed under
What is dropped. Run side by side on one machine, the numbers agree to
1e-12 wherever both versions answer.
See vignette("estimatr2.0") for a user-facing tour of
what changes and what does not.
The maintainer changes with this release, from Graeme Blair to
Alexander Coppock. Graeme Blair remains an author. Macartan Humphreys,
an author of the 1.x releases, has asked to be listed as a contributor
instead, and his role in Authors@R moves from
aut to ctb. Nothing else about the authorship
changes.
estimatr 2.0.0 was written with AI.
vignette("mathematical-notes") says so in full and, in the
same document, gives every estimator its mathematical definition and
then measures estimatr against that definition when the vignette is
built, to machine precision, immediately underneath. What that guarantee
is, and what it deliberately is not, is set out there rather than
here.
The evidence behind the release, the counts and the two checking
layers, is in that vignette and in vignette("estimatr2.0")
under “How this was checked”. Wherever one of those counts appears it
comes from data-raw/count_assertions.R, which measures it
rather than remembering it. That script exists because the counts were
four remembered numbers and drifted twice.
The weighted HC2 comparison against Stata is pinned twice, as equal
to the R reference to machine precision and as different from Stata by a
bounded amount. The same is done for weighted 2SLS root MSE. The
iv_robust HC2 leverage convention is pinned the other way
round: estimatr matches sandwich::vcovHC() on an
ivreg::ivreg() fit exactly, and differs from
AER::ivreg(), whose hatvalues() method
predates the ivreg package and returns a quantity
ivreg’s own authors decline to treat as leverage. See
vignette("estimatr2.0"). A comparison that is quietly
excluded because it disagrees is indistinguishable, on a green run, from
one that was never written.
revdepcheck, and fixed hereRunning the 35 CRAN reverse dependencies turned up six fields that
had gone missing from fitted objects, and one that was returned with a
wrong value. None of it was visible to a NAMESPACE diff, since no export
changed, nor to the estimator comparisons, which name the fields they
check, nor to a grep of the reverse dependencies’ sources, since the
calls are unchanged. It surfaced because eventstudyr reads
felevels on a fixed-effects fit and got
NULL.
Restored: felevels and the unprojected tss
on every fixed-effects fit; fixed_effects (the absorbed
group effects) and proj_fstatistic on
iv_robust() fixed-effects fits;
treatment_levels on lm_lin() fits, which holds
every level including the baseline and is not the same thing as the
treatment_vals this version added; and
nclusters, reported as 0, on an unclustered
blocked difference_in_means().
proj_fstatistic was wrong, not merely
missing. With absorbed fixed effects the intercept is already
out of the design, and the numerator degrees of freedom subtracted one
anyway, so the statistic tested a hypothesis one restriction short of
the intended one. A two-regressor fixed-effects model reported F on 1
numerator degree of freedom rather than 2, with a correspondingly wrong
value. A single-regressor fixed-effects model took the count to zero and
dropped the statistic altogether, which is how iv_robust()
came to have no proj_fstatistic at all.
felevels also came back in the wrong order. The
fixed-effects matrix is coerced to character before demeaning, which
re-derives the levels by string sort, so a factor with levels 1 to 30
reported them as 1, 10, 11. The names are now taken before that
coercion.
felevels also had to keep listing the levels
that are in the fit rather than the levels that are in the
data. The first attempt at the ordering fix above read the
level names off the model frame before rows were dropped for
missingness, which counted levels that no observation survives into.
That is not cosmetic: downstream code sizes a degrees-of-freedom
correction off length(fit$felevels[[term]]). On
eventstudyr’s example data, 1,700 complete rows of 2,000
leave 34 of 40 time periods in the estimation sample, and counting all
40 moved its standard errors in the fourth significant figure.
felevels now carries the estimation-sample levels, in the
order the data gave them.
One difference from 1.0.6 does remain, and it moves no estimate.
1.0.6 named each element after its term, ~ get(idvar)
giving felevels[["get(idvar)"]], except with a single
fixed-effect factor on a model fitted with missing data, where it fell
back to V1. 2.0 names it consistently. Code that reads
felevels$V1 will need the term name instead.
The suite compares against a fixture of answers recorded from an installed 1.0.6 rather than calling that version live, which is what lets the package carry its own name. One consequence was not obvious until CI ran: the recording is a fixed set of numbers made on one machine, so comparing it to values computed on another machine has a floor set by BLAS and LAPACK rather than by this package. Before the freeze both sides were computed in the same process, so several comparisons could reasonably ask for exact equality; they cannot any more. The first CI run after the rename failed 11 assertions on Ubuntu and Windows and none on macOS, where the fixture was recorded, and every difference was too small for waldo to render.
Recorded comparisons therefore run at REF_TOL = 1e-9,
set from the worst case rather than by taste: the tightest recorded
quantity is the weighted adj.r.squared at -7.1e-4, whose
one ulp is a relative difference of 3.1e-13, leaving about 3,200 ulps of
headroom, and nothing else in the fixture comes within 100 ulps of the
tolerance. Comparisons between two things computed in the same session
keep their tight tolerances, since those have no platform floor.
tests/testthat/test_return_surface.R now pins the whole
returned surface of sixteen fit types against answers recorded from
1.0.6, so a field cannot go missing again without a test failing. A
missing field is the failure mode worth guarding:
fit$felevels returns NULL rather than
erroring, so the reader gets a wrong answer instead of a stop.
Both were removed on the understanding that 1.x had restricted them too. It had not: 1.0.6 answered every one of these calls, and answered them correctly. The reverse-dependency run is what surfaced the mistake.
HC2, HC3 and CR2 work with fixed_effects
again. 1.0.6 built the full dummy matrix and took the hat
values off the [X | FE dummies] design whenever the
requested se_type needed them. HC2 and HC3 now reach the
same numbers without building it, at any number of factors, through the
leverage decomposition below. CR2 is the exception and still expands,
because its adjustment is built from cluster-level blocks of the hat
matrix rather than from h_ii. Every case is exact and equal
to the explicit-dummy fit, and equal to 1.0.6, which the fixture now
records. The one combination 1.0.6 also refused, weighted CR2 with
fixed_effects, is still refused.
A bare grouping vector passed to fixed_effects
warns instead of erroring. 1.x accepted a bare column name or
an already-evaluated vector. Issue #304 asked for the formula to be
enforced; a warning that names the argument and shows the expected form
does that without breaking working code, so the vector is still accepted
and still names the term the way 1.0.6 named it. Anything that is
neither a formula nor a grouping vector is an error, as before.
fixed_effects with clusters defaults to
"CR0"; 1.0.6 defaulted to "CR2". CR2 is the
one estimator here still built from cluster-level blocks of the hat
matrix rather than from h_ii, so it is the one case that
still has to expand the dummies, and a default that quietly costs O(g^3)
in the number of levels would undo the reason for absorbing fixed
effects at all. The warning fires once per session,
since absorbed fixed effects are usually fitted in a loop or a
simulation and a per-call warning would be noise. Writing
se_type = "CR0" accepts the default and removes the
warning; writing se_type = "CR2" returns the 1.0.6 number
exactly, also without warning.
Unclustered fixed_effects keeps the
"HC2" default at any number of factors, which is
what 1.0.6 returned. An earlier draft of 2.0 fell back to
"HC1" for two or more factors and warned, on the
understanding that HC2 there meant expanding the dummies. It does not;
see below.
Of estimatr’s 35 CRAN reverse dependencies, 34 check clean against
2.0.0. (hbal at first would not install on the test machine
at all, for a local gfortran path problem unrelated to this package;
corrected, it checks clean too.)
clubSandwich, RCT and statuser
were broken by the two removals above and now check clean.
One remains:
eventstudyr has three assertions that
read felevels$V1 on a one-way fixed-effects fit and want
the 1.0.6 fallback name, in test-EventStudyFHS.R and
test-EventStudyOLS.R. Nothing else in its suite changes and
no estimate moves: its one-way branches size their degrees-of-freedom
correction as 1 + rank without consulting
felevels, and its comparisons against recorded Stata output
all hold.All public functions from 1.x that are relevant to the DeclareDesign workflow are present with identical interfaces:
lm_robust(): OLS with HC0/HC1/HC2/HC3/CR0/CR2/stata
SEslm_lin(): Lin (2013) covariate-adjusted estimatoriv_robust(): two-stage least squares with the same SE
menudifference_in_means(): Neyman variance, paired,
blocked, and clustered designshorvitz_thompson(): inverse probability weighting with
Young’s inequality variancelh_robust(): linear hypothesis tests via
car::linearHypothesis with robust variancetidy, glance,
summary, print, predict,
coef, confint, vcov,
nobs, updateReturn objects are structurally compatible with estimatr 1.0.6: field names, classes, and method dispatch are unchanged. Cross-version identity tests confirm coefficient and standard error agreement across all supported SE types, to 1e-12 when both versions are run on the same machine and to 1e-9 against the recorded fixture the suite ships.
horvitz_thompson() fits now carry the same
post-estimation methods as every other estimator here.
vcov(), confint(), glance(),
summary() and nobs() were missing, so the
first three errored and the last two fell through to the base defaults
and returned something that resembled output without being the estimate.
All five now agree with estimatr 1.0.6 exactly, including
confint(level = ) at a non-default level, where the
interval is normal rather than t because the estimator has no degrees of
freedom to spend. The glance() gap was the one worth
finding: without it modelsummary() did not fail, it
silently dropped the goodness-of-fit rows and printed a coefficient
table that looked complete.
Absorbed fixed effects used to refuse se_type = "HC2"
and "HC3", on the grounds that they need hat values from
the full [dummies | X] design while absorption leaves only
the demeaned ones. The hat values in fact decompose exactly, for any
number of FE factors, and the decomposition is written out below.
Verified to machine precision against writing the dummies out, weighted and unweighted, balanced and unbalanced, from one factor to five, and against 1.x. At n = 40,000 with 2,000 groups, one-way HC2 takes about 4 ms here against 41.0 s in 1.0.6. At n = 50,000 across 1,000 x 30 groups, two-way HC2 takes 7 ms against 12.5 s before, and the R process peaks at 292 MB against 1,564 MB, of which 265 MB is an empty session with the package loaded.
The default with fixed effects therefore returns to
"HC2", at any number of factors, which is the
package default everywhere else and is what 1.x returns for the same
call. An earlier draft of 2.0 fell back to "HC1" for two or
more factors, on the understanding that HC2 there meant expanding the
dummies. It does not.
The identity generalises to any number of factors. What fails beyond one factor is the sum of each factor’s own within-group share, which ignores the cross terms and is wrong in the third decimal place; that is what an earlier draft tested and rejected. The projection itself decomposes exactly:
P_[X | D] = P_D + P_{M_D X}
so h_ii is the demeaned-X hat value plus
diag(P_D), whatever the number of factors. Writing the FE
design with its widest factor in full dummies and the rest
contrast-coded makes D'WD block-diagonal in its leading
block, so diag(P_D) costs a factorisation of order
sum_{k>1}(g_k - 1), as small as the design allows,
rather than an n-by-g dummy hat matrix. Inverting that block through its
eigendecomposition rather than a solve means a disconnected or
nested design, where D is rank deficient, gets the
pseudo-inverse and the right projection anyway.
iv_robust() gets the same treatment: its second stage
runs on fitted regressors, but those are demeaned by the same fixed
effects, so the decomposition applies unchanged. It never used the
shortcut before, even for one factor.
"CR2" is the exception. Its adjustment is built from
cluster-level blocks of the hat matrix rather than from
h_ii, and blocks do not decompose the way the diagonal
does, so CR2 with fixed_effects still expands the dummies
exactly as 1.0.6 did. It is available and exact; it simply costs what
1.0.6 charged for it.
What this is worth. On 50,000 observations across
1,000 x 30 groups, se_type = "HC2" with two-way
fixed_effects took 12.5 seconds in an R process peaking at
1,564 MB; it now takes 7 milliseconds in one peaking at 292 MB, and 265
MB of that is an empty session with the package loaded. The dummy matrix
is never built, so the memory that used to make wide fixed effects
impractical is simply not allocated. It is built in C++ rather than in
R, which is why gc() never reported it and only the
process’s resident size does.
A rank-deficiency bug, fixed. When one FE factor is
spanned by the others (a nested factor, or a disconnected design), the
FE design is rank deficient, and the nominal count
sum(levels) - K + 1 overstates its rank. 1.0.6 used that
nominal count for the residual degrees of freedom, and for HC2 and HC3
it expanded the dummies, let a pivoted QR drop the redundant columns,
and then read the hat values off the padded design. Its absorbed answer
therefore disagreed with its own explicit-dummy fit, and disagreed
differently depending on the se_type.
2.0 takes the exact rank from the same eigendecomposition that
produces the leverage, and uses it for every
se_type, so an absorbed fit and the same fit with the
dummies written out now agree exactly on standard errors, degrees of
freedom, p-values and intervals however degenerate the design is.
Absorbing fixed effects is a computational choice and should not move
the answer.
This is a deliberate departure from 1.0.6, and it only bites where
the FE design is genuinely rank deficient; on a design of full rank the
nominal and exact counts agree and nothing moves. Canvassed against the
ecosystem on a nested-factor design and a disconnected two-way design:
base lm() with explicit dummies and plm
(twoways within) both report the exact rank, and estimatr now matches
them. fixest::feols reports the nominal one and issues no
note, so it differs from its own dummy regression on both designs; that
difference is fixest’s to make, and is recorded here only
so the discrepancy is not mistaken for a bug in either package.
Fixing this exposed a latent crash: r_fe was left at
r + fe_rank when the meat falls back to the plain
XtX_inv, so X.leftCols(r_fe) overran and
aborted inside Eigen. Unreachable while HC2 was refused with fixed
effects, reachable now.
Horvitz-Thompson auxiliary arguments.
horvitz_thompson() is included, but the probability
specification is consolidated into the single condition_prs
argument. The blocks, clusters,
simple, condition_pr_mat, subset,
and return_condition_pr_mat arguments and
se_type = "constant" are dropped. An
ra_declaration already carries the block structure, cluster
structure, per-unit probabilities, and simple-versus-complete flag, so
the separate arguments restated information the declaration holds, and
gave the estimator two sources of truth that could disagree. Fully
custom designs go through
declare_ra(permutation_matrix = perm), which replaces
condition_pr_mat.
tidy(), glance(), and
augment() return tibbles, as broom’s methods do;
1.x returned plain data frames. $, [[, and row
indexing work as before. The one construction that changes is
tidy(fit)[, "estimate"], which now returns a one-column
tibble rather than a vector; write tidy(fit)$estimate.
tibble moves from Suggests to Imports.
starprep() and
commarobust(). Both are removed, but they remain
as names that error with a pointer to the replacement rather than being
deleted, so a script written against 1.x reports what happened instead
of failing with could not find function.
commarobust() recomputed robust standard errors on a fitted
lm, which is what lm_robust() does.
starprep() prepared fits for stargazer, which has not been
maintained in years; table building now goes through modelsummary, which
reads tidy() and glance() and therefore works
on every estimator here with no adapter.
The condition-probability-matrix helpers
declaration_to_condition_pr_mat(),
gen_pr_matrix_cluster(), and
permutations_to_condition_pr_mat() go with the
Horvitz-Thompson arguments above: the new variance computation does not
build that matrix.
extract.lm_robust() and
extract.iv_robust() are kept, for , which is their
only consumer. They are exported as plain functions rather than
registered as S3 methods because texreg looks them up by name.
The alo_star_men dataset.
data/alo_star_men.rda and its documentation are removed, so
data(alo_star_men) errors. It was an example dataset for
the vignettes, and those go too.
The nine 1.0.6 vignettes, including the widely cited
mathematical-notes. vignette("estimatr2.0") is
the one that ships. The 1.0.6 versions remain on the frozen 1.0.6
pkgdown site.
HAC (Newey-West) standard errors. Not implemented.
CR3 standard errors. Not implemented.
Every item below was reproduced against an installed estimatr 1.0.6, not inferred from the rewrite’s own code. Each says which direction it moves a result, because that decides whether it costs a finding or manufactures one. Anything in this section is worth re-checking in existing work; anything not in it, including everything in the sections above about the rewrite’s internals, was never in a released version.
HC3 returns a silently inflated standard error, and HC2
returns NaN, on a near-saturated design. On
Y ~ Z * factor(x) with 25 levels of x in 40
rows, 1.0.6 gives se(Z) = 39.5 against a classical standard
error of 1.42, with no warning of any kind, and roughly
39.5 on nearly every other coefficient in the table. HC2 gives
NaN for all of them. The direction is upward, so
the cost is a finding lost rather than one invented, but the
magnitude is large enough that a real effect disappears. Saturated
specifications and treatment-by-stratum interactions produce this
readily. See the #395 entry below for the mechanism.
Horvitz-Thompson with a permutation_matrix
design is anti-conservative. Enumerating all ten assignments of
a 5-cluster, m = 2 design, 1.0.6’s mean estimated variance is
0.838 of the true sampling variance, and it returns
NA on one of the ten. Pairs of units that can never appear
together contribute a term no design of that shape identifies, and 1.0.6
left them out rather than bounding them. The direction is
downward: intervals too narrow, over-rejection. 2.0 applies the
Aronow and Samii (2013) bound instead, so the standard error is larger
on purpose.
Zero weights are counted as observations.
nobs and df.residual counted rows whose weight
is 0, so classical standard errors come back too small by
an amount that grows with the share of rows zeroed:
| rows with weight 0 | 1.0.6 | correct | understated by |
|---|---|---|---|
| 5% | 0.075644 | 0.077630 | 2.6% |
| 25% | 0.070667 | 0.081737 | 13.5% |
| 50% | 0.066432 | 0.094427 | 29.6% |
| 75% | 0.072028 | 0.146289 | 50.8% |
The direction is downward. Two things limit the
blast: HC0, HC2 and HC3 are
untouched to within 0.1%, so the package default was mostly safe, and
only classical, HC1 and stata
move materially. Trimming an inverse-probability weight to zero, or
using 0/1 weights to subset, is the way into it.
The same count reached further than nobs. A cluster
whose every row has weight 0 was still a cluster: nclusters
counted it, the CR0 and stata degrees of freedom were one too many, the
stata scale factor used it, and the F statistic’s denominator degrees of
freedom did. A zero-weight row left inside a cluster’s CR2 block moved
the weighted CR2 standard errors by about 8e-4 relative to the fit
without it, where clubSandwich’s CR2 does not move. A fixed-effect group
whose every row has weight 0 was still a level, so the absorbed rank was
one too many. adj.r.squared used the row count where
df.residual used the observation count.
difference_in_means() reported nobs as the row
count under weights. 2.0 counts a zero-weight row as absent from every
one of these: the fit with zero weights equals the fit on the rows with
positive weight, field for field, for every estimator and every
se_type (test_equivalence.R). Under two or
more absorbed factors a zero-weight group’s empty sum reached the
eigendecomposition as NaN; 1.0.6 returned NA
standard errors there, and 2.0 answers.
A clusters variable with one level gives CR2
standard errors of about 1.5e-17, with no warning.
CR2’s degrees of freedom are Satterthwaite, so the
J - 1 = 0 guard that catches the other cluster-robust
estimators never fires. One level is usually the symptom of a cluster
variable that collapsed upstream, so the failure converts a
data-handling mistake into an apparently infinitely precise estimate.
2.0 errors.
offset() in a formula is silently
ignored. Not a standard error problem: the coefficient itself
is the one from the model without the offset. On a probe where the
offset model gives 0.953607, 1.0.6 returns
0.904333, which is lm() with the term deleted.
2.0 errors and names the rewrite
(y - <offset> ~ x).
glance() reports the first coefficient’s degrees
of freedom in a column named df.residual. Under
CR2 that is a Satterthwaite quantity: 4.83 on a fit whose
residual degrees of freedom are 98. No estimate moves; a reported table
is wrong.
emmeans::emmeans() on an lm_robust
fit fails unless emmeans happens to be attached, with “Perhaps
a ‘data’ or ‘params’ argument is needed”. An error rather than a wrong
number.
An underidentified iv_robust() model returns
estimates. With fewer excluded instruments than endogenous
regressors, 1.0.6 fits the second stage anyway, drops whichever
regressor the instruments cannot reproduce, and reports the rest with
standard errors. On mpg ~ hp + cyl | am it drops the
intercept and returns coefficients on hp and
cyl, under a warning that there are more regressors than
instruments. When the count is sufficient but an instrument is a copy of
an exogenous regressor, as in y ~ en + x1 | x1 + dup with
dup equal to x1, it drops en, the
endogenous regressor, and warns about nothing. The direction is
unknown, which is the problem: what comes back is not a 2SLS estimate of
anything, and it looks like one. 2.0 errors, comparing the rank
of the first-stage fitted values with the rank of the regressors, which
catches both cases and does not fire on regressors that are merely
collinear among themselves.
A clustered iv_robust() fit reported an
overidentification test that ignores the clustering. With
diagnostics = TRUE and clusters, 1.0.6 returns
Wooldridge’s heteroskedasticity-robust score statistic, the same number
the model gives unclustered. Where the instruments and the errors both
vary at the cluster level, that variance is too small and the statistic
too large: in 1,000 draws of 50 clusters of 10 with valid instruments,
the test rejects at the 5% level 36.1% of the time, and the median
statistic is 2.7 times the correct one. The direction is toward
rejection, so valid instruments are declared invalid. 2.0 sums
the score’s variance within clusters, which rejects 4.0% of the time on
the same draws, and returns NA with a warning when there
are no more clusters than restrictions. Stata’s
estat overid computes nothing after
vce(cluster). A weighted over-identified fit, where 1.0.6
returned NA for this test without saying so, now gets the
robust score test under any robust se_type, built on the
weighted fit’s sandwich variance. On mtcars, with 2SLS of
mpg on hp and gear instrumented
by wt, am, and gear and weighted
by drat / 5, that gives 1.702. Stata’s
forceweights value of 1.845 is a different statistic rather
than a competing answer: it is the frequency-weight score test, equal to
the unweighted test on the data with each row repeated as often as its
weight and rescaled to N rows, which is right only when a weight counts
identical, independent copies of a row. Under
se_type = "classical" with weights it gets Sargan’s
statistic on the weighted model, which matches Stata’s
forceweights value to its printed precision. With weights,
every diagnostic is now the test on the model with each row multiplied
by the square root of its weight. The first-stage F and Wu-Hausman tests
are Wald tests under the fit’s own se_type, so each is
valid under exactly the conditions the matching standard errors are: a
classical test when the weights are inverse error variances, a robust
test under heteroskedasticity. The robust score test uses the score’s
HC0 or CR0 sandwich under every robust se_type, as
Wooldridge (1995) and Stata define it; the HC1, HC2, HC3, CR2, and
"stata" refinements correct a coefficient covariance and
have no counterpart in a score test. Stata declines
estat endogenous and estat overid after
aweights unless forced, since it cannot tell precision weights from
sampling weights, and computes the first-stage F regardless.
lm_robust,
lm_lin, iv_robust)An early version of 2.0 “corrected” the weighted TSS from
weights_internal² to weights_internal. That
change was wrong and was reverted. estimatr 1.x’s weighted R² formula is
correct, and 2.0 reproduces it exactly. The reasoning is recorded here
so it is not re-litigated.
The standard weighted TSS is:
TSS = Σᵢ wᵢ (yᵢ - ȳ_w)²
where ȳ_w is the standard weighted mean (weights wᵢ). Inside
lm_robust_fit, raw weights are rescaled to
weights_internal = sqrt(w / μ_w), so
weights_internal² = w / μ_w. Using
weights_internal² in the TSS formula recovers the correct
standard weighted mean as the centering point. Using
weights_internal instead (with sqrt-weights) centers on
ȳ_{sqrt(w)} ≠ ȳ_w, which breaks the WLS guarantee that RSS ≤ TSS and
allows R² < 0.
2.0 uses weights_internal² throughout, matching estimatr
1.x’s formula exactly. For an intercept-only WLS model, R² = 0 exactly.
For any WLS model, R² ∈ [0, 1].
iv_robust with
diagnostics = TRUE)Two bugs in the diagnostic computations were identified and fixed:
First-stage F-test missing p-value. The returned
vector from first_stage_ftest() lacked a
p.value entry, causing glance() on
iv_robust objects with diagnostics to silently drop the
p-value or error downstream.
Wu-Hausman endogeneity test wrong numerator df.
wu_hausman_reg_ftest() computed numdf as the
number of columns in the first-stage residual matrix, which over-counted
by one. numdf is now
lm_resids$rank - lm_noresids$rank, the actual rank increase
from adding the residuals.
The auxiliary regression was also being handed a first-stage residual
for every regressor rather than for the endogenous ones only. An
exogenous regressor appears in the instrument matrix, so its first-stage
residual is zero up to rounding, and the correct count survived only
because rank detection dropped that column of noise. It is not safe to
rely on: both stats::lm() and a pivoted QR at
tol = 1e-7 keep such a column, and the scale-invariant rank
detection described below does too, which adds a spurious degree of
freedom to the numerator. iv_robust() now builds the
auxiliary design from the endogenous residuals alone, so the test no
longer depends on how rank is determined.
Which regressors are endogenous is now decided by the column
space, not by name. The fix above still took the endogenous
regressors to be the ones whose names do not appear in the instrument
formula. An exogenous regressor that the instruments span without naming
it, as in y ~ en + x1 | v1 + v2 + v3 with
v3 = x1 + inst, was therefore counted as endogenous: its
first-stage residual is rounding error, the auxiliary regression carried
that column, and the test returned 0.144 on 2 degrees of freedom where
y ~ en + x1 | inst + inst2 + x1, the same model, returns
0.109 on 1. AER::ivreg() and estimatr 1.0.6 return 0.109 on
1 for both spellings, so this was a regression in the rewrite and never
shipped. A regressor now counts as exogenous when the instruments
reproduce it, its first-stage residual having norm at most
1e-7 of its own, and the weak-instrument F and the
overidentification test are computed on a basis for the excluded
instruments rather than on whichever columns happen to be named. All
three diagnostics now depend only on the model, which
test_invariance.R checks by writing the instruments three
ways. Where the instrument formula names every exogenous regressor, no
number moves beyond rounding. In the unnamed spelling the first-stage F
now reports en alone, where AER::ivreg() also
reports a row for x1 with a statistic of 2.7e32.
With two or more fixed_effects factors the demeaning
iterates, and it stopped once a sweep changed nothing by more than
1e-8 * (1 + max|value|). The 1 made that test
absolute whenever the values are small, and the outcome is demeaned on
its own. An outcome measured in units of 1e-6 stopped after
half the sweeps a unit-scale one takes. On a two-way design the
coefficients were right, and the residuals came back wrong by
4.4e-4, the fitted values by 3.2e-3, and the
standard errors by 7.1e-7, relative to the same fit in unit
scale; in units of 1e-9 the residuals were meaningless. The
test is now relative to the values’ own magnitude, with a floor for a
column the fixed effects absorb entirely, and outcome scales from
1e-9 to 1e6 agree to 3e-14. On
the benchmark design the number of sweeps and the time are unchanged.
estimatr 1.0.6 has a milder form of the same failure, with standard
errors off by 1.4e-5 at 1e-9. Found by
test_invariance.R, which rescales the outcome of every
estimator.
Passing an ordered factor as the clusters argument
caused the error 'length = 2' in coercion to 'logical(1)'.
The class check class(x) %in% c("factor", "integer")
returns a length-2 logical vector for ordered factors (whose class is
c("ordered", "factor")), which R’s if()
rejects. Fixed by replacing the class check with
is.factor() and is.integer().
formula()
object passed to fixed_effects crashes (#348)When a pre-built formula object is stored in a variable and passed as
fixed_effects = fe_formula, the rlang quosure captures the
variable name (a symbol), not the formula value. Passing the raw quosure
to stats::model.frame.default() produced the error
invalid type (language). Fixed by calling
eval_tidy() on the quosure before handing it to
model.frame.default().
lm_robust(y ~ 1, fixed_effects = ~block) crashed with
length of 'dimnames' [2] not equal to array extent. After
FE demeaning the intercept column is dropped, leaving a zero-column
design matrix. The alternating-projections loop in
demean_fes() ran 50 iterations on the empty matrix (the
convergence check max(abs(empty_matrix)) returns
-Inf, which never satisfies the criterion), accumulating
50+ warnings before a downstream dimension error.
The fix has two parts. First, demean_fes() skips the
demeaning loop entirely when there are no non-intercept columns. Second,
lm_robust() detects the zero-column case after demeaning
and returns a well-formed result directly: empty coefficient vector,
correct df.residual = n - fe_rank, full-model R², and
fitted values equal to the within-group means.
lh_robust
CIs inconsistent with lm_robust when clusters present
(#405)The individual hypothesis CIs and p-values from
lh_robust() did not match those from
lm_robust() when clusters were specified. The root cause:
lh_robust() used lmr$df.residual (= n − k,
e.g., 97 for n = 100, k = 3) as the degrees of freedom for t-tests and
confidence intervals. For clustered models, the correct df is the
cluster-adjusted value stored per-coefficient in lmr$df
(e.g., 9 for a CR2 model with 10 clusters, or 8–9 via Satterthwaite
approximation).
Using the residual df of 97 instead of the cluster df of ~9 produces
confidence intervals that are far too narrow. The fix parses the
hypothesis expression (e.g., "x=0") to extract the
coefficient name ("x"), looks up lmr$df["x"],
and uses that as the df. For composite hypotheses involving multiple
coefficients (e.g., "x - z=0"), the conservative minimum
over all per-coefficient dfs is used.
lh_robust
joint hypothesis test absent (#320)lh_robust() returned only individual hypothesis tests
with no joint F-test. The fix adds a joint_hypothesis
element to the return object containing a Wald F-statistic:
F = (Lβ̂)ᵀ (L Vcov Lᵀ)⁻¹ (Lβ̂) / m
where m is the number of restrictions, tested against F(m, df) using
the same cluster-adjusted df as the individual tests. The vcov matrix
L Vcov Lᵀ is obtained directly from
car::linearHypothesis() via the vcov attribute
of its return value, so the cluster-robust variance is correctly
propagated.
The same work closes #390, where
lh_robust(Y ~ X1 + X2 + X3, linear_hypothesis = c("X1", "X2"))
should return one joint test and 1.x instead errors: “lh_robust
currently implements tests for hypotheses involving linear combinations
of variables but not joint hypotheses.”
Neither lm_robust() nor iv_robust() nor
lm_lin() returned residuals: residuals(fit)
was NULL, and fit$residuals did not exist,
though fitted.values did. lm_return() dropped
the field.
2.0 returns residuals on the scale of the data. Weighted
fits return the unweighted residuals, so
residuals + fitted.values recovers the outcome. Clustered
fits are returned in the original row order, undoing the internal sort
by cluster. Fixed-effects models return full-model residuals, which by
Frisch-Waugh-Lovell equal the residuals of the demeaned regression.
iv_robust() returns structural residuals, y − Xβ̂ on the
original endogenous regressors rather than their first-stage
projections. Because residuals.default() reads
object$residuals, residuals(fit) now works
with no new method.
A regressor collinear with the others is dropped and returned as an
NA coefficient, silently. The reporting user compared lm()
and lm_robust() output, found coefficients that differed,
and had no way to see that a term had been dropped and the rest were
therefore conditional on a different set of regressors.
2.0 warns once, naming the dropped terms. An under-identified
iv_robust() call no longer reaches this warning: it is
refused, for the reason given under “Errors in 1.0.6”.
augment()
for use with broom-aware packages (#377)augment() returns the model frame with
.fitted and .resid appended, which is what
downstream packages expect before they will accept a model object. It
was not implementable before this release, since no estimator returned
residuals (#345). augment(newdata = ) predicts on new data
instead. Multivariate outcomes are refused. Methods are provided for
lm_robust (covering lm_lin) and
iv_robust.
`simple` = FALSE, using complete cluster randomization and
`condition_prs` not found, estimating probability of treatment to be constant...
on every call, which in a simulation means every iteration. 2.0 requires
condition_prs, so it never guesses a probability and never
prints either message.fixest’s algorithm. 2.0 uses it: alternating projections in
C++, 4x to 10,000x faster on the designs measured above, the range
widening with the number of levels.randomizr, carData or
blkvar is guarded with
skip_if_not_installed(). The comparisons against 1.0.6 need
no guard at all, because they read recorded answers rather than calling
the older version.R CMD check --as-cran runs with 0 errors, 0 warnings and
1 NOTE, the maintainer change.
1.x handles two kinds of blocked design and refuses everything between them. If every block has at least two treated and two control units, each block carries its own Neyman variance. If every block is a matched pair, the variance comes from the variation across pairs. A design with both, or with a block holding one treated and three control units, either errors (“every block must have at least two treated and control units”) or silently applies the matched-pairs estimator to every block, big ones included, after a warning. Such designs are not exotic: coarsened exact matching, full matching, and multisite trials with one or two sites per stratum all produce them.
2.0 implements the estimators of Pashley and Miratrix (2021). Blocks are classified by how many units each arm holds, not by how large the block is, which is the substantive correction: a block of eight units with one of them treated has no more estimable within-block variance than a matched pair does.
Degrees of freedom are not treated in the paper. 2.0 combines the two
components by Welch-Satterthwaite, which reduces to n - 2K
for an all-big design and to K - 1 for an all-small one,
matching what each literature uses on its own.
The design element now reports "Blocked",
"Matched-pair", "Small blocks", or
"Hybrid blocked". Standard errors agree to 1e-10 with
blkvar::block_estimator(method = "hybrid_p"), the authors’
own implementation, across all-big, all-small, matched-pair, and hybrid
designs.
Two designs are refused, because the variance genuinely cannot be estimated rather than because the software is unwilling: exactly one block with a singleton arm, which leaves nothing to compare it against and would contribute a variance of zero, and a set of different-sized such blocks in which one holds half or more of their units, which is the condition equation 8 needs to stay defined and conservative.
Unchanged: all-big designs and matched pairs return exactly what they did before.
Pashley and Miratrix cover treatment assigned within blocks, not
blocks of clusters: clusters appear once in their paper, to be set aside
(“we are focusing on treatment assigned within cluster”, section 2). The
hybrid above therefore does not extend to cluster-randomized blocks, and
blocked designs with clusters keep the previous
estimators.
Checking that boundary turned up a separate defect, present in 1.x too. A block with one treated or one control cluster has no estimable within-block variance, because the singleton arm contributes nothing to the between-cluster variability, yet both packages returned a number for it with no warning. Exhaustive enumeration of every assignment, with cluster-level potential outcomes held fixed:
| block | ratio of mean estimated variance to true variance |
|---|---|
| 6 clusters, 3 treated | 1.00 |
| 6 clusters, 2 treated | 1.00 |
| 4 clusters, 2 treated | 1.00 |
| 4 clusters, 1 treated | 0.25 |
| 6 clusters, 1 treated | 0.17 |
| 8 clusters, 1 treated | 0.12 |
With two or more clusters per arm the estimator is exact. With one it is too small by roughly the block’s cluster count, and it worsens as the block grows. End to end, blocks of 6, 6, 6, and 4 clusters where the last has a single treated cluster gave 88% coverage of a nominal 95% interval.
2.0 now errors, naming the offending blocks and suggesting either
merging them so every block has two clusters per arm, or
lm_robust() with block fixed effects and
clusters. Matched-pair clustered designs are exempt, since
every block there has a singleton arm by construction and the variance
is estimated across blocks rather than within them.
lm() (#351, #395)lm_robust(y ~ x) with x constant returned
coefficients of order 1e11 instead of NA. The design matrix is exactly
rank deficient, but Eigen’s default ColPivHouseholderQR
threshold is roughly epsilon * ncol relative to the largest
pivot, tight enough that the collinear column survives as a pivot of
order 1e-14. stats::lm() uses LINPACK dqrdc2
with tol = 1e-7, and 2.0 sets the same threshold.
The threshold alone is not the whole of what dqrdc2
does, and setting only the threshold introduced a second failure in the
opposite direction, caught before release. Eigen compares every pivot
against the largest pivot in the matrix, while dqrdc2
compares each column’s remaining norm against its own original norm. The
second test is scale invariant and the first is not, so a single
regressor measured in large units pushed the others below
1e-7 times the largest pivot and they were dropped as
collinear on a design of full rank. Three independent normals and an
intercept, with one column multiplied by 1e9, lost three
coefficients to NA; the effect begins at a condition number
of about 1e7, and it reached lm_robust() and
iv_robust() alike, since try_cholesky defaults
to FALSE. A polynomial regression discontinuity in an
unscaled running variable is the ordinary case that hits it.
2.0 therefore normalizes the columns of the design to unit norm
before the rank-revealing QR and undoes the scaling afterwards, at both
the coefficient path and the getMeatXtX() path the hat
values are read off. The criterion is then per column, which is what
dqrdc2 does and what the 1e-7 threshold was
meant to reproduce. Exactly collinear columns are still dropped, and a
full-rank design now returns the same coefficients whatever units its
columns are measured in. test_invariance.R asserts that
property directly, sweeping a column through twelve orders of magnitude
and requiring every coefficient and standard error to come back
unchanged.
The same normalization fixed a third failure, on the
try_cholesky = TRUE path, and this one is a difference from
1.0.6 rather than a pre-release catch.
lm_robust(try_cholesky = TRUE) solves through a Cholesky
decomposition of X'X instead of the pivoted QR, and it was
returning a coefficient for every column of a rank-deficient design,
splitting arbitrarily between collinear ones, where the QR path and
stats::lm() return NA. It warned about the
resulting NaN standard error and never about the
collinearity. The guard that was supposed to prevent it tested
llt.info(), which cannot catch the case: Eigen’s LLT
reports success on a numerically singular Gram matrix. With the columns
normalized, each diagonal entry of the Cholesky factor is the norm of
that column’s residual after the earlier columns are projected out,
measured against its own original norm, which is dqrdc2’s
criterion again; the path now falls back to the QR below the same
1e-7. A design that is merely ill conditioned falls back
too and pays the QR’s cost rather than returning a bad answer.
try_cholesky is off by default, so a user reaches
this only by asking for it, and estimatr 1.0.6 has the behaviour
still. The two paths now agree to 3e-17, and the
argument no longer changes the answer, only the speed.
A near-saturated design also produces observations that the fit
reproduces exactly, whose leverage is 1. That case costs nothing in the
answer: the residual is exactly 0, HC2’s contribution is a 0/0 that
resolves to 0, and the standard error comes back finite, though built
from fewer rows than the fit used. The failure is leverage computed
marginally above 1, which rounding produces on the same designs
and which no projection diagonal can actually take. 1 - h
is then a small negative number, and the two estimators failed
differently: HC2 divided by it and took the square root of a negative
meat, so every standard error in the fit was NaN however
small the offending term, while HC3 squared the denominator, which
cancels the sign, and returned a finite number carrying a large spurious
term without warning. The silent one was the worse failure.
Both failures are 1.0.6’s as well as the rewrite’s, confirmed by
running it: on the probe design 1.0.6 returns NaN for HC2
and 39.5 for HC3 against a classical 1.42,
silently. 2.0 sets the denominator to 0 wherever
1 - h <= 0, so both estimators drop those observations
from the variance instead, and warns with a count of how many sat at or
near leverage 1 and which se_type read it. The count is a
tolerance, h > 1 - sqrt(.Machine$double.eps), which is
sandwich::vcovHC()’s criterion on the same quantity, rather
than a test on the sign of 1 - h. The reason is that on an
exactly saturated design the standard error is the same whichever side
of 1 the rounding puts the hat value, so a sign test would have left the
warning to a single unit in the last place: the solver used here returns
1 + 2.2e-16 on such a design where qr() and
stats::hatvalues() return exactly 1. A hat value is
dimensionless and bounded by 1, so a fixed tolerance on it means the
same thing in every design. CR2 never forms
1 - h and needs no guard: it eigendecomposes a per-cluster
matrix and keeps only directions above 1e-12, so a cluster
that is fitted exactly already contributes 0. One consequence is worth
naming: on a design with a singleton fixed-effect group, the absorbed
fit and the same model with explicit dummies used to disagree, the first
finite and the second NaN. They now agree, in the warning
as well as in the number.
predict() with
fixed effects (#403, #404)Three separate failures. predict() with no
newdata errored instead of returning the in-sample fit.
predict() on a fixed-effects model with a factor regressor
failed with “non-conformable arguments”, because the terms object still
carries the intercept that fixed-effects absorption removed from the
coefficients. And 2.0 did not store the absorbed group effects at all,
so no fixed-effects prediction was possible.
lm_robust() now stores the absorbed group effects for a
single-outcome model with one set of fixed effects, and
predict() adds them back, reproducing the dummy-variable
regression exactly. New levels in newdata and multi-way
fixed effects both error with an explanation: with several sets of fixed
effects the absorbed effects are identified in sum but not separately,
so a new observation cannot be assigned its share.
glance()
on iv_robust with several endogenous regressors (#389)The first-stage F test carries one entry per endogenous regressor,
named "<var>:value" once there is more than one, so
indexing it by "value" produced an NA with an NA name and
the data.frame() call failed on the row name.
glance() must return one row, so it now reports the weakest
of the per-regressor first stages, which is the quantity a
weak-instrument diagnostic asks about. The per-regressor tests remain in
diagnostic_first_stage_fstatistic.
model.frame() on
iv_robust (#397)model.frame() returned a two-column frame whose second
column was named "x + hp | am + hp", because
model.frame.default() reads the | of a
two-part formula as a logical operator. A
model.frame.iv_robust() method now rebuilds a one-part
formula naming every variable and defers to the default method.
lm_robust_fit() with an unnamed or integer
matrix (#269). The exported fitting function failed on an
integer design matrix (“Wrong R type for mapped matrix”) and produced an
object tidy() could not handle when y had no
column names. Both inputs are now coerced and named.variable.names() (#123). Added for
lm_robust and iv_robust.fixed_effects given a bare column name
(#304). It produced “invalid formula” from deep inside
model.frame. It now warns, names the argument, shows the
expected form, and goes on to fit the model the way 1.x did.iv_robust() with multiple outcomes and
diagnostics = TRUE died in the score test with
“dims do not match the length of object”, in 1.0.6 as well. The
diagnostic fields hold one statistic each, so the fit now warns that
diagnostics are not available with multiple outcomes and skips them, as
it does under fixed_effects.horvitz_thompson() with a cbind()
outcome answered for the first outcome alone, silently: the
matrix response was flattened and the first n values read as the
outcome. 1.0.6 errored on the length mismatch. It now refuses and says
to fit one model per outcome.lh_robust() with multiple outcomes
(#297). Coefficients of a multivariate fit are named
"<outcome>:<term>", which a hypothesis such as
"cyl = 2" cannot refer to, and
car::linearHypothesis() reported it as malformed. 2.0
errors with that explanation and tells the user to fit one outcome at a
time.subset leave no rows, 1.0.6’s lm_robust(),
iv_robust() and lm_lin() returned every
coefficient as NA, and an earlier 2.0 added a warning
calling them collinear, which was false;
difference_in_means() said both treatment conditions were
needed “within each block”. All four now say that no observations are
left, as lm() does.tidy(conf.level =) on a multivariate fit
ignored the level. Intervals were matched to rows by name, and
a multivariate fit’s are named
"<outcome>:<term>", so nothing matched and the
fit’s own 95% intervals came back under a request for 90%, silently, in
1.0.6 as well. They are now matched by position, which is how
confint() builds them.confint() on an lh_robust fit
always errored, “numbers of columns of arguments do not match”,
in 1.0.6 as well, and nobs() on one returned
NULL. Both now answer, and parm can
name the hypothesis.predict() on an iv_robust() fit
with fixed_effects failed with “non-conformable
arguments”, where 1.0.6 returned predictions. It now drops the absorbed
intercept and adds the group effects back, as predict() on
lm_robust() does, and reproduces the fitted values in
sample.lh_robust() under CR2 gave a combination of
coefficients the wrong degrees of freedom. 1.0.6 refused CR2 in
lh_robust(). The rewrite allowed it, and gave any
hypothesis that was not a single coefficient the smallest of the
coefficients’ Satterthwaite degrees of freedom, on the stated grounds
that this was conservative. It is not a bound: on the test data it was
16.10 where the combination’s own is 15.75, which narrows the interval.
Each hypothesis now gets its own Satterthwaite degrees of freedom,
computed from the same CR2 components as the coefficients’, and matches
clubSandwich::linear_contrast() unweighted, weighted, and
with absorbed fixed effects. The joint test’s denominator takes the
smallest of them.horvitz_thompson() accepted
probabilities outside [0, 1] and returned an ordinary-looking estimate
and standard error from them, and a probability of 0 for a condition a
unit was observed in returned NaN or Inf
without a word. Both are now refused, the second naming the condition
and how many units it affects; the check runs after every
condition_prs format has been turned into per-unit
probabilities, so it covers them all.NA under a warning
calling the regressors collinear, in 1.0.6 as well, where
lm() refuses an infinite weight. Both are now refused with
a message saying which. A missing weight is still a dropped row.difference_in_means() given a condition1 and
condition2 that are not values of the treatment filtered
the data down to nothing and then said both conditions were needed
“within each block”; it now says the conditions must be values found in
the treatment. horvitz_thompson() given a named probability
vector whose names miss a condition called it an “Unrecognised
condition_prs format”; it now names the vector’s names and
the conditions it was looking for.difference_in_means(). The refusal is right, since
that arm’s variance cannot be estimated, but its message said every
block needed two treated and two control units, on a design with no
blocks, in 1.0.6 as well. It now says what is needed without assuming
blocks.With one factor in fixed_effects and any observation
dropped for missingness, the model frame’s one-column matrix of factor
codes degrades to a vector, and rebuilding it as a matrix leaves it
without a column name. Three things read that name, and all three
failed. se_type = "CR2" errored outright with “‘.’ in
formula and no ‘data’ argument”, because CR2 is the one estimator that
still expands the fixed effects and it does so through
model.matrix(~ 0 + .) on a data frame that had no names to
expand. The absorbed group effects in fixed_effects came
back labelled 1, 2, 3 instead of
g1, g2, g3. And
predict() rejected its own data as containing new levels,
since the levels it was matching against had lost the term.
estimatr 1.0.6 lost the name in the same place and fell back to
V1, so it returned V11, V12,
V13 and its predict() failed too, but it did
answer the CR2 call. The name is now taken from the level names captured
before the model frame is built, which are the same ones
felevels reports, so the two cannot disagree and complete
and incomplete data give the same labels. The restored CR2 fit agrees
with 1.0.6 and with the explicit-dummy fit to twelve digits.
Every fixed-effects test until now used complete data, which is why nothing reported this.
res_var
and weights were named by accident on weighted
fixed-effects fitsBoth fields took whatever dimnames survived
data[["y"]] - fitted.values, and R resolves that in favour
of the first operand carrying any. The outcome was carrying the model
frame’s row names, except on the fixed-effects path, where it has been
demeaned into a fresh matrix. So a weighted fixed-effects fit disagreed
with a weighted fit without fixed effects, and with 1.0.6, in two fields
at once: res_var (and the proj_ R-squareds
derived from it) came back named after the outcome column when 1.0.6
leaves it unnamed, and weights came back unnamed when 1.0.6
names it. Now that the row names are carried explicitly rather than
inherited, all three cases agree with 1.0.6. Verified against an
installed 1.0.6 and pinned in test_return_surface.R.
lm_robust(X ~ 1, weights = w, data = df) where
X is entirely NA errors in estimatr 1.x with
“‘x’ must be an array of at least two dimensions”, while the same model
without weights returns an NA coefficient. The asymmetry matters when
one model is fitted across many subgroups and some subgroup has no
observed outcome. 2.0 returns the NA coefficient in both cases.
CR2 standard errors require, for each cluster j, a scalar adjustment
derived from the hat matrix block H_jj. The original 1.x implementation
formed a J×J matrix P_array and computed a trace and
Frobenius norm over it, which is O(J²) memory and compute when J is
large.
2.0 uses an algebraic identity to reduce this to five r×r Gram matrix products, where r is the number of model parameters (typically ≪ J). For a model with 3 parameters and 500 clusters the allocation drops from a 500×500 matrix to five 3×3 matrices. Measured speedup on a 1,000-observation / 100-cluster CR2 model: approximately 2× end-to-end.
iv_robust() requires Formula::as.Formula()
to parse the two-sided formula y ~ x | z.
lm_robust() and lm_lin() do not, but the
original code called as.Formula() unconditionally in
clean_model_data(). This path is now skipped for non-IV
calls, saving approximately 80 µs per lm_robust()
invocation, which is meaningful in simulations that call it thousands of
times.
1.x constructs the full 2N×2N joint inclusion probability matrix for
every Horvitz-Thompson call. The Young’s inequality variance for any
complete-randomization design (including within blocks, and at the
cluster level for clustered designs) reduces to six scalar sufficient
statistics per block, so the matrix is never needed. The custom-design
pathway, where an explicit permutation matrix is the only description of
the design, is the one case that still forms a matrix, and does so in
one tcrossprod() call in place of 1.x’s 538-line
helper_condition_pr_matrix.R.
Measured against 1.x on complete randomization: 10× at N = 200, 73× at N = 1,000, 837× at N = 3,000. Standard errors are identical to floating-point precision for simple, complete, blocked, clustered, and blocked-and-clustered designs.
estimatr 1.x refuses an ra_declaration with more than
two arms. 2.0 supports the contrast between any two arms of a multi-arm
design: condition1 and condition2 pick the
contrast, the estimand stays the average treatment effect over all N
units, and the variance uses the joint assignment probabilities implied
by the arm sizes.
Two generalizations were needed. The denominator of the estimator is
N, the size of the design, and not the number of units landing in the
two selected conditions; those coincide only when the design has two
arms. The Young’s inequality coefficients come from the pairwise joint
probabilities m2(m2-1)/(n(n-1)),
m1(m1-1)/(n(n-1)), and m2 m1/(n(n-1)), which
do not assume m1 = n - m2. The cross coefficient collapses
to 1/n for every complete design. Two-arm contrasts
continue to run through 1.x’s gen_joint_pr_complete path,
including its floor/ceiling mixture for non-integer implied counts, so
every two-arm result is numerically unchanged.
Tested by exhaustive enumeration: over all 90 assignments of six units to three arms of two, the average estimate equals the true ATE exactly, and the closed-form variance equals the one computed from the exact joint probability matrix of the same 90 permutations.
Because probabilities are matched to units by row position,
data must contain one row per unit of the declaration.
Passing a declaration whose size does not match nrow(data)
is now an error rather than a silent misalignment.
The total sum of squares calculation replaced an
apply(y, 1, '-', colMeans(y)) column-mean sweep with direct
centring and colSums(). 1.x ran an R-level loop over rows;
an intermediate version of 2.0 used sweep(), which builds
its subtrahend with array() and then transposes it with
aperm(), so it materialised two extra n-by-k copies to do
what recycling does in one. Speedup on a 1,000-observation multivariate
model: approximately 280 µs, and two fewer allocations the size of the
outcome on every fit.
At n = 100,000 the numerical core of a fit is a small fraction of the call. Most of the rest was the model frame’s row names.
stats::model.frame() turns a data frame’s compact
automatic row names into a character vector as long as the data, and
from there every object derived from the design matrix carries them: the
fitted values from X %*% beta, the residuals formed from
those, the row subset the cluster sort takes, and the
drop() that turns each n-by-1 matrix into a vector. Only
fitted.values keeps them in the returned object; estimatr
returns residuals unnamed, as 1.0.6 did, so on that branch they were
built only to be discarded.
They are now taken off the design matrix and the outcome once, in
clean_model_data(), where both are still unshared and
stripping them is free, and put back on fitted.values once
in lm_return(). Nothing in between has to carry them.
weights is named to match, as before.
The rest are local. Gaps in fixed-effect codes are detected with
tabulate() rather than by hashing every observation with
sort(unique()). Unweighted group sizes in the leverage
computation are tabulate() rather than
rowsum(), which rediscovers the groups it was already
given, and the unit weight vector is no longer materialised on the
one-way path that does not index it. The sums of squares behind the
R-squared of a fixed-effects fit go through crossprod()
instead of squaring the vector first. The absorbed group effects find a
representative row per group by scatter assignment over the codes rather
than by hashing all n of them. sweep() is gone from the
total sum of squares, and the design matrix is no longer copied to
itself when no column was dropped for collinearity.
Two things dominated a multi-way fixed-effects fit, and both were doing work they already had the answer to.
fe_leverage() cross-tabulates each pair of factors. It
built a composite index and handed it to rowsum(), which
hashes all n observations to rediscover groups that are known to run
1..g, and then labels its result rows with those group values as
strings, so the caller converted them back with
as.integer(rownames(.)). At n = 100,000 over 1,000 by 30
groups that pair cost 7.3 ms, against 0.04 ms for a single counting
pass, and there is one per factor pair. A dense weighted
cross-tabulation in C++ (xtab_cpp) replaces it and serves
the weighted case too, so the unit weight vector is no longer built for
an unweighted fit. The masks and column offsets the leverage vector
needs are now built only when a leverage vector is wanted, which is HC2
and HC3; every other se_type returns the rank without
them.
demean_cpp() rebuilt the group weight sums on every
sweep of the alternating projections, though they depend only on the
codes and the weights and are identical each time, and then made a
second full pass over the matrix to find its largest absolute value for
the convergence test. The sums are now computed once, the maximum is
accumulated by the subtraction pass that already touches every cell, and
an unweighted fit no longer multiplies each element by a 1.0 it had just
read out of a vector of ones. Every result is bit-identical; there is
simply less of it.
Measured at n = 100,000 with two regressors, against 2.0.0 before this work: a plain fit 15.8 to 3.2 ms, a clustered fit 18.5 to 5.1 ms, a one-way fixed-effects fit 14.7 to 5.8 ms, a two-way fixed-effects fit 32.7 to 9.3 ms. On the cases that carry the most fixed-effects machinery: two-way HC2 36.7 to 12.8 ms, two-way weighted 35.0 to 12.6 ms.
All of it is in clean_model_data(),
lm_robust_fit(), lm_return() and the
fixed-effects helpers, so iv_robust() and
lm_lin() get it for nothing: 2SLS 20.0 to 5.5 ms, clustered
2SLS 30.0 to 7.6 ms, 2SLS with two-way fixed effects 37.8 to 14.4 ms,
lm_lin() 26.0 to 6.1 ms.
Every returned value is unchanged. Across 56 specifications covering
each se_type, weights, clusters, multivariate outcomes, IV,
and one to two fixed-effect factors, the largest relative difference
from the previous version is 9e-15, and the names on every field are
identical apart from the three naming bugs fixed below.
Fixed effects absorption is implemented using the Frisch-Waugh-Lovell
theorem with alternating projections (the algorithm used by
fixest::feols). All FE variables are demeaned out of both
the outcome and the design matrix before estimation; the intercept is
absorbed into the group means and dropped.
SE types with fixed effects. With a
single FE factor there is no restriction and no cost:
the hat value of the full [dummies | X] design splits as
h_ii = h_ii(demeaned X) + w_i / sum(w over i's group), so
HC2 and HC3 are exact from the demeaned fit plus one vector. The default
is therefore "HC2", the same as with no FE, and the same as
1.x returns.
lm_robust(y ~ z, data = dat, fixed_effects = ~block)
# se_type = "HC2", bit-identical to 1.x, 4.1 ms where 1.0.6 takes 41.0 s
# at n = 40,000 with 2,000 blocks
lm_robust(y ~ z, data = dat, fixed_effects = ~block, clusters = cl)
# se_type = "CR0", with a warning: 1.0.6 defaulted to "CR2" here
lm_robust(y ~ z, data = dat, fixed_effects = ~ block + year)
# se_type = "HC2", as in 1.0.6, and no warning: the identity covers any
# number of factors, so there is nothing to trade away
lm_robust(y ~ z, data = dat, fixed_effects = ~ block + year, se_type = "CR2",
clusters = cl)
# se_type = "CR2", exact, by expanding the dummies. No warning: you asked.With two or more FE factors the naive version of the
identity fails, by roughly 3e-3 rather than by rounding: summing each
factor’s own within-group share drops the cross terms. The projection
decomposition P_[X | D] = P_D + P_{M_D X} is exact for any
number of factors, and that is what is used. Only "CR2"
with fixed_effects still expands the dummies, its
adjustment being built from cluster-level blocks of the hat matrix
rather than from h_ii. Every answer equals the
explicit-dummy fit and equals 1.0.6; that equivalence is tested and
recorded in the fixture.
What changes is only what a default will reach for,
and only in the clustered case. Expanding the dummies is roughly O(g^3)
in the number of levels, so no default expands them; "CR2"
is the only estimator that still needs the expansion, so
fixed_effects with clusters defaults to
"CR0" with a warning, shown once per session, that names
what 1.0.6 returned and names the se_type that accepts the
new default. Writing se_type = "CR0" removes it.
Unclustered fits keep the "HC2" default at any number of
factors, as in 1.0.6.
Multi-way FE. Two-way (and higher) FE are supported via alternating projections that iterate to convergence (tolerance 1e-8, maximum 100 sweeps, after which it warns).
Return object additions. FE models add: -
fes: logical flag indicating whether fixed effects were
used - proj_r.squared: within-group R² from the demeaned
regression - r.squared: full-model R² using the original
(un-demeaned) outcome - fitted.values: reconstructed to the
original scale via FWL (projected residuals equal full residuals)
Degrees of freedom. For a model with one FE variable
having B levels, fe_rank = B degrees of freedom are
consumed (B − 1 dummy contrasts plus the absorbed intercept), so
df.residual = n − k − B where k is the number of non-FE
regressors.