Skip to content

Commit 130b905

Browse files
Fix dof + improve collinearity detection (#279)
* Fix degrees-of-freedom reporting and improve API/docs Main fix: residual dof no longer subtracts the intercept twice — it is now N-K (and G-1 for clustered standard errors), matching Stata. The standard errors themselves were already correct. Also: - Collinearity detection is scale-invariant, so small-scale independent regressors are no longer wrongly dropped. - dof_fes ignores continuous-FE groups (fe(id)&x) whose interaction is all zero. - coeftable labels the confidence-interval columns with the requested level. - partial_out: deprecate method=:gpu and default tol to 1e-6 (so its residuals match reg). - Clearer errors (malformed formulas), new docstrings (fe, has_iv, has_fe, dof_fes, FixedEffectModel, collinear-omitted NaN convention), and small README fixes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Guard HC2/HC3 standard errors against fixed effects HC2/HC3 compute leverage from the partialled-out (demeaned) regressors only, omitting the absorbed fixed-effect contribution to leverage. This silently understates leverage and yields anti-conservative standard errors whenever fe() is used. Throw an informative ArgumentError for the combination, mirroring the existing IV guard (HC2/HC3 are likewise rejected for IV), and point users to Vcov.robust() (HC1) or Vcov.cluster(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b0aabc3 commit 130b905

10 files changed

Lines changed: 203 additions & 33 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,12 +139,14 @@ df = dataset("plm", "Cigar")
139139
reg(df, @formula(Sales ~ NDI + fe(State) + fe(Year)), method = :Metal)
140140
```
141141

142+
GPU methods default to single precision (`Float32`) for the demeaning step. On CUDA you can pass `double_precision = true` to use `Float64`; Metal supports only `Float32`.
143+
142144
## Solution method
143145
Denote the model `y = X β + D θ + e` where X is a matrix with few columns and D is the design matrix from categorical variables. Estimates for `β`, along with their standard errors, are obtained in two steps:
144146

145147
1. `y, X` are regressed on `D` using the package [FixedEffects.jl](https://github.com/FixedEffects/FixedEffects.jl)
146148
2. Estimates for `β`, along with their standard errors, are obtained by regressing the projected `y` on the projected `X` (an application of the Frisch Waugh-Lovell Theorem)
147-
3. With the option `save = true`, estimates for the high dimensional fixed effects are obtained after regressing the residuals of the full model minus the residuals of the partialed out models on `D` using the package [FixedEffects.jl](https://github.com/FixedEffects/FixedEffects.jl)
149+
3. With the option `save = :fe` (or `save = :all`), estimates for the high dimensional fixed effects are obtained after regressing the residuals of the full model minus the residuals of the partialed out models on `D` using the package [FixedEffects.jl](https://github.com/FixedEffects/FixedEffects.jl)
148150

149151
Here are some references for the solution method:
150152

src/FixedEffectModel.jl

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@
55
##
66
##############################################################################
77

8+
"""
9+
FixedEffectModel <: RegressionModel
10+
11+
The fitted model returned by [`reg`](@ref). It is lightweight: it stores the coefficients,
12+
variance-covariance matrix, fit statistics, and (optionally) saved residuals and fixed
13+
effects, but not the original data. Inspect it with the `StatsAPI` accessors
14+
(`coef`, `vcov`, `stderror`, `confint`, `coeftable`, `nobs`, `dof_residual`, `r2`, …),
15+
and with [`fe`](@ref) for saved fixed effects. `predict`/`residuals` take the data table as
16+
a second argument since it is not retained.
17+
"""
818
struct FixedEffectModel <: RegressionModel
919
coef::Vector{Float64} # Vector of coefficients
1020
vcov::Matrix{Float64} # Covariance matrix
@@ -43,7 +53,18 @@ struct FixedEffectModel <: RegressionModel
4353
p_kp::Float64 # First Stage p value KP
4454
end
4555

56+
"""
57+
has_iv(m::FixedEffectModel)
58+
59+
Return `true` if the model was estimated with instrumental variables (2SLS).
60+
"""
4661
has_iv(m::FixedEffectModel) = has_iv(m.formula)
62+
63+
"""
64+
has_fe(m::FixedEffectModel)
65+
66+
Return `true` if the model includes high-dimensional fixed effects (`fe(...)` terms).
67+
"""
4768
has_fe(m::FixedEffectModel) = has_fe(m.formula)
4869

4970

@@ -62,6 +83,12 @@ StatsAPI.nulldeviance(m::FixedEffectModel) = m.tss
6283
StatsAPI.rss(m::FixedEffectModel) = m.rss
6384
StatsAPI.mss(m::FixedEffectModel) = nulldeviance(m) - rss(m)
6485
StatsModels.formula(m::FixedEffectModel) = m.formula_schema
86+
"""
87+
dof_fes(m::FixedEffectModel)
88+
89+
Return the number of degrees of freedom absorbed by the fixed effects (the total number of
90+
fixed-effect levels across all `fe(...)` dimensions).
91+
"""
6592
dof_fes(m::FixedEffectModel) = m.dof_fes
6693

6794
function StatsAPI.loglikelihood(m::FixedEffectModel)
@@ -256,9 +283,12 @@ function StatsAPI.coeftable(m::FixedEffectModel; level = 0.95)
256283
coefnms = coefnms[newindex]
257284
end
258285
tt = cc ./ se
286+
# Label the confidence-interval columns with the requested level, not a hard-coded 95%.
287+
pct = 100 * level
288+
pctstr = isinteger(pct) ? string(Int(pct)) : string(pct)
259289
CoefTable(
260290
hcat(cc, se, tt, fdistccdf.(Ref(1), Ref(StatsAPI.dof_residual(m)), abs2.(tt)), conf_int[:, 1:2]),
261-
["Estimate","Std. Error","t-stat", "Pr(>|t|)", "Lower 95%", "Upper 95%" ],
291+
["Estimate","Std. Error","t-stat", "Pr(>|t|)", "Lower $(pctstr)%", "Upper $(pctstr)%" ],
262292
["$(coefnms[i])" for i = 1:length(cc)], 4)
263293
end
264294

src/fit.jl

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ Estimate a linear model with high dimensional categorical variables / instrument
2323
### Details
2424
Models with instruments variables are estimated using 2SLS. `reg` tests for weak instruments by computing the Kleibergen-Paap rk Wald F statistic, a generalization of the Cragg-Donald Wald F statistic for non i.i.d. errors. The statistic is similar to the one returned by the Stata command `ivreg2`.
2525
26+
Regressors that are collinear with other regressors (or with the fixed effects) are dropped from the estimation. A dropped coefficient is reported as `0` with a `NaN` standard error (and `NaN` t-statistic, p-value, and confidence interval).
27+
2628
### Examples
2729
```julia
2830
using RDatasets, FixedEffectModels
@@ -116,6 +118,13 @@ function StatsAPI.fit(::Type{FixedEffectModel},
116118
has_iv = formula_iv != FormulaTerm(ConstantTerm(0), ConstantTerm(0))
117119
formula, formula_fes = parse_fe(formula)
118120
has_fes = formula_fes != FormulaTerm(ConstantTerm(0), ConstantTerm(0))
121+
# HC2/HC3 compute leverage from the partialled-out (demeaned) regressors only, which
122+
# omits the absorbed fixed-effect contribution to leverage. The resulting standard
123+
# errors are silently anti-conservative, so guard against the combination (mirroring
124+
# the IV guard, where HC2/HC3 are likewise rejected).
125+
if has_fes && vcov isa Vcov.RobustCovariance && vcov.correction (:hc2, :hc3)
126+
throw(ArgumentError("$(uppercase(string(vcov.correction))) standard errors are not supported with fixed effects, because the leverage correction does not account for the absorbed fixed effects. Use Vcov.robust() (HC1) or Vcov.cluster(...) instead."))
127+
end
119128
# when save = :fe but there are no fixed effects in the formula, don't save fixed effects
120129
save_fes = save (:fe, :all) && has_fes
121130
has_weights = weights !== nothing
@@ -325,9 +334,14 @@ function StatsAPI.fit(::Type{FixedEffectModel},
325334
# Compute Fstat
326335
F = Fstat(coef, matrix_vcov, has_intercept)
327336

328-
# dof_ is the number of estimated coefficients beyond the intercept.
337+
# dof_ is the number of estimated coefficients beyond the intercept (F-stat numerator).
329338
dof_ = size(X, 2) - has_intercept
330-
dof_tstat_ = max(1, Vcov.dof_residual(vcov_data, vcov_method) - (has_intercept || has_fe_intercept))
339+
# Residual dof for t-tests/p-values/CIs. Vcov.dof_residual already returns the final
340+
# value: nobs - size(X,2) - dof_fes for simple/robust (size(X,2) already counts the
341+
# intercept column, or the constant is inside dof_fes when absorbed by a fixed effect),
342+
# and minimum(nclusters)-1 = G-1 for clustering. It must NOT be decremented again for
343+
# the intercept (doing so reported N-K-1 instead of N-K, and G-2 instead of G-1).
344+
dof_tstat_ = max(1, Vcov.dof_residual(vcov_data, vcov_method))
331345
p = fdistccdf(dof_, dof_tstat_, F)
332346

333347
# Compute Fstat of First Stage

src/partial_out.jl

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,19 @@ Partial out variables in a Dataframe
55
* `df`: A table
66
* `formula::FormulaTerm`: A formula created using `@formula`
77
* `add_mean::Bool`: Should the initial mean added to the returned variable?
8-
* `method::Symbol`: A symbol for the method. Default is :cpu. Alternatively, :gpu requires `CuArrays`. In this case, use the option `double_precision = false` to use `Float32`.
8+
* `method::Symbol`: A symbol for the method. Default is `:cpu`. Alternatively, use `:CUDA` or `:Metal` (load the respective package — CUDA.jl or Metal.jl — before `using FixedEffectModels`).
99
* `maxiter::Integer`: Maximum number of iterations
1010
* `double_precision::Bool`: Should the demeaning operation use Float64 rather than Float32? Default to true.
11-
* `tol::Real`: Tolerance
11+
* `tol::Real`: Tolerance for the iterative demeaning. Default `1e-6`, matching `reg` (so that `partial_out` and `reg(...; save = :residuals)` return the same residuals).
1212
* `align::Bool`: Should the returned DataFrame align with the original DataFrame in case of missing values? Default to true.
1313
* `drop_singletons::Bool=false`: Should singletons be dropped?
1414
1515
### Returns
16-
* `::DataFrame`: a dataframe with as many columns as there are dependent variables and as many rows as the original dataframe.
17-
* `::Vector{Int}`: a vector of iterations for each column
18-
* `::Vector{Bool}`: a vector of success for each column
16+
* `::DataFrame`: residuals, one column per dependent variable, aligned with the original dataframe.
17+
* `::BitVector`: the estimation-sample mask (the rows actually used).
18+
* `::Vector{Int}`: number of demeaning iterations for each column.
19+
* `::Vector{Bool}`: convergence flag for each column.
20+
* `::Int`: degrees of freedom absorbed by the fixed effects.
1921
2022
### Details
2123
`partial_out` returns the residuals of a set of variables after regressing them on a set of regressors. The syntax is similar to `reg` - but it accepts multiple dependent variables. It returns a dataframe with as many columns as there are dependent variables and as many rows as the original dataframe.
@@ -40,14 +42,19 @@ function partial_out(
4042
@nospecialize(contrasts::Dict = Dict{Symbol, Any}()),
4143
@nospecialize(method::Symbol = :cpu),
4244
@nospecialize(double_precision::Bool = true),
43-
@nospecialize(tol::Real = double_precision ? 1e-8 : 1e-6),
45+
@nospecialize(tol::Real = 1e-6),
4446
@nospecialize(align = true),
4547
@nospecialize(drop_singletons = false))
4648

4749
# Normalize generic Tables.jl input once; the rest of the function uses
4850
# DataFrame indexing/views, and copycols = false avoids copies when possible.
4951
df = DataFrame(df; copycols = false)
5052

53+
if method == :gpu
54+
@info "method = :gpu is deprecated. Use method = :CUDA or method = :Metal"
55+
method = :CUDA
56+
end
57+
5158
if (ConstantTerm(0) eachterm(f.rhs)) && (ConstantTerm(1) eachterm(f.rhs))
5259
f = FormulaTerm(f.lhs, tuple(ConstantTerm(1), eachterm(f.rhs)...))
5360
end

src/utils/basecol.jl

Lines changed: 31 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ Generalized inverse via sweep operator.
55
Returns minus the symmetric inverse (negated so callers negate back).
66
"""
77
function invsym!(X::Symmetric; has_intercept = false, setzeros = false, diagonal = 1:size(X, 2))
8-
tols = max.(diag(X), 1)
8+
# Per-column reference scale for the numerically-zero pivot test below.
9+
tols = sweep_tolerances(X)
910
buffer = zeros(size(X, 1))
1011
for j in diagonal
1112
d = X[j,j]
@@ -17,17 +18,31 @@ function invsym!(X::Symmetric; has_intercept = false, setzeros = false, diagonal
1718
copy!(buffer, view(X, :, j))
1819
Symmetric(BLAS.syrk!('U', 'N', -1/d, buffer, one(eltype(X)), X.data))
1920
rmul!(buffer, 1 / d)
20-
@views copy!(X.data[1:j-1,j], buffer[1:j-1])
21-
@views copy!(X.data[j, j+1:end], buffer[j+1:end])
21+
@views copy!(X.data[1:j-1,j], buffer[1:j-1])
22+
@views copy!(X.data[j, j+1:end], buffer[j+1:end])
2223
X[j,j] = - 1 / d
2324
end
2425
if setzeros && has_intercept && j == 1
25-
tols = max.(diag(X), 1)
26+
tols = sweep_tolerances(X)
2627
end
2728
end
2829
return X
2930
end
3031

32+
# Reference scale per column used by invsym! to flag a (post-sweep) pivot as numerically
33+
# zero: a column is dropped when |pivot| < scale_j * sqrt(eps). The scale is the column's
34+
# own diagonal, floored by a tiny multiple of the largest diagonal so the test is
35+
# scale-invariant. The previous absolute floor of 1 spuriously dropped genuinely
36+
# independent regressors whose total sum of squares was below ~sqrt(eps), while the
37+
# relative floor still drops all-zero columns (diag 0 -> floored to ref*eps > 0). On
38+
# well-scaled designs (all diagonals >= 1) this reproduces the old tolerances exactly.
39+
function sweep_tolerances(X::Symmetric)
40+
d = diag(X)
41+
ref = maximum(d; init = zero(eltype(d)))
42+
floor_ = ref > 0 ? ref * eps(eltype(d)) : eps(eltype(d))
43+
return max.(d, floor_)
44+
end
45+
3146
"""
3247
basis!(XX::Symmetric; has_intercept=false)
3348
@@ -189,6 +204,11 @@ end
189204
190205
Expand coefficient vector and vcov matrix to account for omitted (collinear)
191206
variables and IV-reclassified variable permutations.
207+
208+
Coefficients dropped for collinearity are reinserted with a value of `0`, and the
209+
corresponding rows and columns of the variance-covariance matrix are filled with `NaN`.
210+
This is the convention used to flag an omitted regressor: its standard error, t-statistic,
211+
p-value, and confidence interval are therefore reported as `NaN`.
192212
"""
193213
function reinsert_omitted!(
194214
coef::Vector{Float64}, # estimated coefficients (excluding omitted)
@@ -197,28 +217,19 @@ function reinsert_omitted!(
197217
perm::Union{Nothing, Vector{Int}} # permutation from IV reclassification, or nothing
198218
)
199219
if !all(basis_coef)
220+
kept = findall(basis_coef)
200221
newcoef = zeros(length(basis_coef))
222+
newcoef[kept] = coef
223+
201224
newmatrix_vcov = fill(NaN, (length(basis_coef), length(basis_coef)))
202-
newindex = [searchsortedfirst(cumsum(basis_coef), i) for i in 1:length(coef)]
203-
for i in eachindex(newindex)
204-
newcoef[newindex[i]] = coef[i]
205-
for j in eachindex(newindex)
206-
newmatrix_vcov[newindex[i], newindex[j]] = matrix_vcov[i, j]
207-
end
208-
end
225+
newmatrix_vcov[kept, kept] = Matrix(matrix_vcov)
209226
coef = newcoef
210227
matrix_vcov = Symmetric(newmatrix_vcov)
211228
end
212229
if perm !== nothing
213-
_invperm = invperm(perm)
214-
coef = coef[_invperm]
215-
newmatrix_vcov = zeros(size(matrix_vcov))
216-
for i in 1:size(newmatrix_vcov, 1)
217-
for j in 1:size(newmatrix_vcov, 1)
218-
newmatrix_vcov[i, j] = matrix_vcov[_invperm[i], _invperm[j]]
219-
end
220-
end
221-
matrix_vcov = Symmetric(newmatrix_vcov)
230+
invp = invperm(perm)
231+
coef = coef[invp]
232+
matrix_vcov = Symmetric(Matrix(matrix_vcov)[invp, invp])
222233
end
223234
return coef, matrix_vcov
224235
end

src/utils/fixedeffects.jl

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,20 @@ end
5252

5353
function nunique(fe::FixedEffect)
5454
seen = falses(fe.n)
55-
@inbounds for ref in fe.refs
56-
seen[ref] = true
55+
if fe.interaction isa UnitWeights
56+
@inbounds for ref in fe.refs
57+
seen[ref] = true
58+
end
59+
else
60+
# Continuous-slope fixed effect (e.g. fe(id)&x): a group whose interaction is
61+
# identically zero contributes no column to the design and absorbs no degree of
62+
# freedom (the demeaning sets its scale to 0, SolverCPU.scale!). Count only groups
63+
# with at least one nonzero interaction value, so dof_fes is not overstated.
64+
@inbounds for i in eachindex(fe.refs, fe.interaction)
65+
if !iszero(fe.interaction[i])
66+
seen[fe.refs[i]] = true
67+
end
68+
end
5769
end
5870
count(seen)
5971
end

src/utils/formula.jl

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ eachterm(@nospecialize(x::NTuple{N, AbstractTerm})) where {N} = x
1414
##############################################################################
1515
has_iv(@nospecialize(f::FormulaTerm)) = any(x -> x isa FormulaTerm, eachterm(f.rhs))
1616
function parse_iv(@nospecialize(f::FormulaTerm))
17+
f.lhs isa FormulaTerm && throw(ArgumentError("Malformed formula: the left-hand side cannot contain `~`. The instrumental-variable syntax is `y ~ exogenous + (endogenous ~ instruments)`."))
1718
if has_iv(f)
1819
i = findfirst(x -> x isa FormulaTerm, eachterm(f.rhs))
1920
term = eachterm(f.rhs)[i]
@@ -41,6 +42,18 @@ struct FixedEffectTerm <: AbstractTerm
4142
x::Symbol
4243
end
4344
StatsModels.termvars(t::FixedEffectTerm) = [t.x]
45+
46+
"""
47+
fe(x)
48+
49+
Mark a variable as a high-dimensional fixed effect (a categorical variable to be absorbed)
50+
inside a `@formula` passed to [`reg`](@ref) or [`partial_out`](@ref), e.g.
51+
`@formula(y ~ x + fe(id))`. Several fixed effects are added with `+`, and they can be
52+
interacted with `&`/`*`: `fe(id)&fe(year)` for interacted fixed effects, `fe(id)&x` for
53+
group-specific slopes on a continuous variable `x`.
54+
55+
When building a formula programmatically, `fe` also accepts a `Symbol`: `fe(:id)`.
56+
"""
4457
fe(x::Term) = fe(Symbol(x))
4558
fe(s::Symbol) = FixedEffectTerm(s)
4659

test/collinearity.jl

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using DataFrames, CSV, FixedEffectModels, Random, Statistics, Test
1+
using DataFrames, CSV, FixedEffectModels, Random, Statistics, Test, LinearAlgebra
2+
using FixedEffectModels: reinsert_omitted!
23

34
@testset "collinearity_with_fixedeffects" begin
45

@@ -44,3 +45,47 @@ using DataFrames, CSV, FixedEffectModels, Random, Statistics, Test
4445
rr = reg(df, @formula(x1 ~ x2))
4546
@test all(!isnan, stderror(rr))
4647
end
48+
49+
@testset "reinsert omitted" begin
50+
coef = [10.0, 20.0]
51+
vcov = Symmetric([1.0 0.2; 0.2 4.0])
52+
basis_coef = BitVector([true, false, true])
53+
perm = [2, 1, 3]
54+
55+
newcoef, newvcov = reinsert_omitted!(coef, vcov, basis_coef, perm)
56+
newvcov_matrix = Matrix(newvcov)
57+
58+
@test newcoef == [0.0, 10.0, 20.0]
59+
@test isnan(newvcov_matrix[1, 1])
60+
@test isnan(newvcov_matrix[1, 2])
61+
@test isnan(newvcov_matrix[1, 3])
62+
@test isnan(newvcov_matrix[2, 1])
63+
@test newvcov_matrix[2, 2] == 1.0
64+
@test newvcov_matrix[2, 3] == 0.2
65+
@test isnan(newvcov_matrix[3, 1])
66+
@test newvcov_matrix[3, 2] == 0.2
67+
@test newvcov_matrix[3, 3] == 4.0
68+
end
69+
70+
@testset "small-scale independent regressor is kept" begin
71+
# The rank test is scale-invariant: a genuinely independent regressor whose total
72+
# sum of squares is below sqrt(eps) used to be dropped as collinear (NaN stderror).
73+
df = DataFrame(CSV.File(joinpath(dirname(pathof(FixedEffectModels)), "../dataset/Cigar.csv")))
74+
df.tiny = df.Price .* 3e-8
75+
m = reg(df, @formula(Sales ~ tiny))
76+
mP = reg(df, @formula(Sales ~ Price))
77+
@test !isnan(stderror(m)[2])
78+
# scaling a regressor by c scales its coefficient by 1/c and leaves the t-stat invariant
79+
@test coef(m)[2] * 3e-8 coef(mP)[2] rtol = 1e-4
80+
@test coef(m)[2] / stderror(m)[2] coef(mP)[2] / stderror(mP)[2] rtol = 1e-4
81+
end
82+
83+
@testset "collinear regressor reported as zero coef and NaN stderror" begin
84+
df = DataFrame(y = [1.0, 3.0, 2.0, 5.0, 4.0, 6.0], x = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0])
85+
df.x2 = 2 .* df.x # perfectly collinear with x
86+
m = reg(df, @formula(y ~ x + x2))
87+
# coefnames are [(Intercept), x, x2]; the later collinear column x2 is dropped
88+
@test coef(m)[3] == 0
89+
@test isnan(stderror(m)[3])
90+
@test !isnan(stderror(m)[2])
91+
end

0 commit comments

Comments
 (0)