Skip to content

Commit c4f209b

Browse files
matthieugomezclaude
andcommitted
Fix degrees-of-freedom reporting and collinearity edge cases
In order of importance: - fit.jl: residual dof no longer subtracts the intercept twice. The reported dof_residual (and the t-stat p-values, confidence intervals, and F-stat p-value derived from it) was off by one: N-K-1 instead of N-K for simple/robust, and G-2 instead of G-1 for cluster-robust SEs. Standard errors themselves were already correct. Now matches Stata. - basecol.jl: make rank detection in invsym! scale-invariant. The absolute tolerance floor of 1 dropped genuinely independent regressors whose total sum of squares fell below ~sqrt(eps); the new sweep_tolerances helper floors each column's tolerance by a relative max(diag)*eps and still drops all-zero columns. Bit-identical on well-scaled designs. - fixedeffects.jl: nunique no longer counts continuous-interaction FE groups (fe(id)&x) whose interaction is identically zero, so dof_fes is not overstated for those groups. - FixedEffectModel.jl: coeftable labels the confidence-interval columns with the requested level instead of a hard-coded 95%. - basecol.jl: simplify reinsert_omitted! (reinsertion of collinear-dropped and IV-reclassified coefficients). - tests: add regression tests for all of the above (residual dof, CI labels, continuous-slope FE dof, small-scale collinearity, reinsert_omitted!). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b0aabc3 commit c4f209b

6 files changed

Lines changed: 117 additions & 26 deletions

File tree

src/FixedEffectModel.jl

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,9 +256,12 @@ function StatsAPI.coeftable(m::FixedEffectModel; level = 0.95)
256256
coefnms = coefnms[newindex]
257257
end
258258
tt = cc ./ se
259+
# Label the confidence-interval columns with the requested level, not a hard-coded 95%.
260+
pct = 100 * level
261+
pctstr = isinteger(pct) ? string(Int(pct)) : string(pct)
259262
CoefTable(
260263
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%" ],
264+
["Estimate","Std. Error","t-stat", "Pr(>|t|)", "Lower $(pctstr)%", "Upper $(pctstr)%" ],
262265
["$(coefnms[i])" for i = 1:length(cc)], 4)
263266
end
264267

src/fit.jl

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -325,9 +325,14 @@ function StatsAPI.fit(::Type{FixedEffectModel},
325325
# Compute Fstat
326326
F = Fstat(coef, matrix_vcov, has_intercept)
327327

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

333338
# Compute Fstat of First Stage

src/utils/basecol.jl

Lines changed: 26 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
@@ -197,28 +212,19 @@ function reinsert_omitted!(
197212
perm::Union{Nothing, Vector{Int}} # permutation from IV reclassification, or nothing
198213
)
199214
if !all(basis_coef)
215+
kept = findall(basis_coef)
200216
newcoef = zeros(length(basis_coef))
217+
newcoef[kept] = coef
218+
201219
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
220+
newmatrix_vcov[kept, kept] = Matrix(matrix_vcov)
209221
coef = newcoef
210222
matrix_vcov = Symmetric(newmatrix_vcov)
211223
end
212224
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)
225+
invp = invperm(perm)
226+
coef = coef[invp]
227+
matrix_vcov = Symmetric(Matrix(matrix_vcov)[invp, invp])
222228
end
223229
return coef, matrix_vcov
224230
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

test/collinearity.jl

Lines changed: 36 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,37 @@ 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

test/fit.jl

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,3 +778,33 @@ end
778778
df_ivinf = DataFrame(y = [1.0, 2.0, 3.0], x = [0.0, 1.0, 2.0], z = [1.0, Inf, 3.0])
779779
@test_throws ArgumentError reg(df_ivinf, @formula(y ~ (x ~ z)))
780780
end
781+
782+
@testset "residual degrees of freedom and CI labels" begin
783+
df = DataFrame(CSV.File(joinpath(dirname(pathof(FixedEffectModels)), "../dataset/Cigar.csv")))
784+
785+
# The intercept is counted exactly once: residual dof is N - K, not N - K - 1.
786+
@test dof_residual(reg(df, @formula(Sales ~ Price))) == 1378
787+
@test dof_residual(reg(df, @formula(Sales ~ 0 + Price))) == 1379
788+
@test dof_residual(reg(df, @formula(Sales ~ CPI + (Price ~ Pimin)))) == 1377
789+
# A fixed effect absorbs the constant once (dof = N - slopes - #levels).
790+
@test dof_residual(reg(df, @formula(Sales ~ Price + fe(State)))) == 1380 - 1 - length(unique(df.State))
791+
# Cluster-robust dof is G - 1 (clusters minus one), not G - 2.
792+
@test dof_residual(reg(df, @formula(Sales ~ Price), Vcov.cluster(:State))) == length(unique(df.State)) - 1
793+
794+
# coeftable labels the confidence-interval columns with the requested level.
795+
m = reg(df, @formula(Sales ~ Price))
796+
@test coeftable(m).colnms[end-1:end] == ["Lower 95%", "Upper 95%"]
797+
@test coeftable(m; level = 0.90).colnms[end-1:end] == ["Lower 90%", "Upper 90%"]
798+
@test coeftable(m; level = 0.99).colnms[end-1:end] == ["Lower 99%", "Upper 99%"]
799+
end
800+
801+
@testset "continuous-slope FE degrees of freedom" begin
802+
# A group whose continuous interaction is identically zero contributes no column
803+
# to the design and must not be counted in dof_fes.
804+
df = DataFrame(id = repeat(1:3, inner = 4),
805+
z = Float64[3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 8],
806+
y = Float64[5, 2, 7, 3, 1, 8, 4, 9, 6, 2, 5, 3])
807+
df.x = Float64.(df.id .!= 1) # interaction identically zero for id == 1
808+
m = reg(df, @formula(y ~ z + fe(id)&x))
809+
@test FixedEffectModels.dof_fes(m) == 2
810+
end

0 commit comments

Comments
 (0)