Skip to content

Commit dc5db0c

Browse files
committed
Handle omitted event-time terms via reduced-system IW fit
1 parent a9c84fd commit dc5db0c

3 files changed

Lines changed: 167 additions & 62 deletions

File tree

src/avar.jl

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,28 @@ end
1919

2020
_ginvsym(X::AbstractMatrix) = Matrix(-FixedEffectModels.invsym!(Symmetric(copy(X))))
2121

22+
function _share_vcov_expand(reduced::AbstractMatrix, keep_cols::AbstractVector{Bool}, nresid::Integer)
23+
full_cols = length(keep_cols)
24+
keep_idx = findall(keep_cols)
25+
expanded_idx = Int[]
26+
27+
for resid_idx in 1:nresid
28+
append!(expanded_idx, (resid_idx - 1) * full_cols .+ keep_idx)
29+
end
30+
31+
full = zeros(Float64, full_cols * nresid, full_cols * nresid)
32+
full[expanded_idx, expanded_idx] .= Matrix(reduced)
33+
return Symmetric(full)
34+
end
35+
36+
function _share_vcov_dispatch(sample::DataFrame, emat::Matrix{Float64}, Xmat::Matrix{Float64}, vcov::CovarianceEstimator)
37+
vcov isa Vcov.SimpleCovariance && return _share_vcov_simple(emat, Xmat)
38+
vcov isa Vcov.RobustCovariance && return _share_vcov_robust(emat, Xmat)
39+
vcov isa Vcov.ClusterCovariance && return _share_vcov_cluster(sample, emat, Xmat, vcov)
40+
41+
throw(ArgumentError("Unsupported covariance estimator $(typeof(vcov)) for the cohort-share step."))
42+
end
43+
2244
function _share_vcov(table, e::AbstractMatrix, X::AbstractMatrix, vcov::CovarianceEstimator)
2345
sample = DataFrame(table; copycols = false)
2446
Xmat = Matrix{Float64}(X)
@@ -31,11 +53,13 @@ function _share_vcov(table, e::AbstractMatrix, X::AbstractMatrix, vcov::Covarian
3153
emat = emat[keep, :]
3254
end
3355

34-
vcov isa Vcov.SimpleCovariance && return _share_vcov_simple(emat, Xmat)
35-
vcov isa Vcov.RobustCovariance && return _share_vcov_robust(emat, Xmat)
36-
vcov isa Vcov.ClusterCovariance && return _share_vcov_cluster(sample, emat, Xmat, vcov)
56+
keep_cols = vec(any(.!iszero.(Xmat), dims = 1))
57+
if all(keep_cols)
58+
return _share_vcov_dispatch(sample, emat, Xmat, vcov)
59+
end
3760

38-
throw(ArgumentError("Unsupported covariance estimator $(typeof(vcov)) for the cohort-share step."))
61+
reduced = _share_vcov_dispatch(sample, emat, Xmat[:, keep_cols], vcov)
62+
return _share_vcov_expand(reduced, keep_cols, size(emat, 2))
3963
end
4064

4165
function _share_vcov_simple(e::Matrix{Float64}, X::Matrix{Float64})

src/eventfit.jl

Lines changed: 106 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,52 @@
1-
function eventreg(@nospecialize(df),
2-
@nospecialize(formula::FormulaTerm),
3-
@nospecialize(rel_varlist::Vector{Symbol}),
1+
function _nonzero_relmask(df::DataFrame, nvarlist::Vector{Symbol})
2+
isempty(nvarlist) && return Bool[]
3+
X = Matrix{Float64}(df[:, nvarlist])
4+
return vec(any(.!iszero.(X), dims = 1))
5+
end
6+
7+
function _estimable_relmask(vcov_mat::AbstractMatrix, nrel_times::Integer, ncohort::Integer)
8+
nrel_times == 0 && return Bool[]
9+
interaction_dim = nrel_times * ncohort
10+
diag_v = diag(Matrix(vcov_mat)[1:interaction_dim, 1:interaction_dim])
11+
keep_rel = falses(nrel_times)
12+
for i in 1:nrel_times
13+
block = ((i - 1) * ncohort + 1):(i * ncohort)
14+
keep_rel[i] = any(.!isnan.(diag_v[block]))
15+
end
16+
return keep_rel
17+
end
18+
19+
function _zero_omitted_rowscols(vcov_mat::AbstractMatrix)
20+
VV = Matrix{Float64}(vcov_mat)
21+
omitted = isnan.(diag(VV))
22+
kept = .!omitted
23+
24+
if any(kept) && any(isnan.(VV[kept, kept]))
25+
throw(ArgumentError("Non-omitted entries of the interacted-regression covariance matrix contain NaN values."))
26+
end
27+
28+
if any(omitted)
29+
VV[omitted, :] .= 0.0
30+
VV[:, omitted] .= 0.0
31+
end
32+
33+
return VV
34+
end
35+
36+
function _reinsert_rel_outputs(coef_reduced::AbstractVector, vcov_reduced::AbstractMatrix, keep_rel::AbstractVector{Bool}, full_size::Integer)
37+
coef_full = zeros(Float64, full_size)
38+
vcov_full = fill(NaN, full_size, full_size)
39+
40+
keep_idx = findall(keep_rel)
41+
coef_full[keep_idx] .= coef_reduced
42+
vcov_full[keep_idx, keep_idx] .= Matrix(vcov_reduced)
43+
44+
return coef_full, Symmetric(vcov_full)
45+
end
46+
47+
function eventreg(@nospecialize(df),
48+
@nospecialize(formula::FormulaTerm),
49+
@nospecialize(rel_varlist::Vector{Symbol}),
450
@nospecialize(control_cohort::Symbol),
551
@nospecialize(cohort::Symbol),
652
@nospecialize(vcov::CovarianceEstimator = Vcov.simple());
@@ -43,21 +89,29 @@ function StatsAPI.fit(::Type{EventStudyInteract},
4389
if dof_add != 0
4490
@warn "The dof_add keyword is no longer forwarded to FixedEffectModels.reg on version 1.13 and is currently ignored."
4591
end
46-
# Prepare the varlists
47-
nvarlist = Symbol[]
48-
dvarlist = Symbol[]
49-
for l in rel_varlist
92+
# Prepare the varlists
93+
nvarlist = Symbol[]
94+
dvarlist = Symbol[]
95+
for l in rel_varlist
5096
push!(dvarlist,l)
5197
n_l = Symbol("n",l)
5298
df[!,n_l] = df[!,l]
53-
df[!,n_l][df[!,control_cohort].==1] .= 0
54-
push!(nvarlist,n_l)
55-
end
99+
df[!,n_l][df[!,control_cohort].==1] .= 0
100+
push!(nvarlist,n_l)
101+
end
102+
full_dvarlist = copy(dvarlist)
56103

57-
cohort_list = unique(df[!,cohort][df[!,control_cohort].==0])
58-
cohort_list = sort(cohort_list)
59-
cohort_list = Int.(cohort_list)
60-
nrel_times = length(nvarlist)
104+
prekeep_rel = _nonzero_relmask(df, nvarlist)
105+
if !all(prekeep_rel)
106+
nvarlist = nvarlist[prekeep_rel]
107+
dvarlist = dvarlist[prekeep_rel]
108+
end
109+
isempty(nvarlist) && error("No estimable relative-time indicators remain after removing unsupported columns.")
110+
111+
cohort_list = unique(df[!,cohort][df[!,control_cohort].==0])
112+
cohort_list = sort(cohort_list)
113+
cohort_list = Int.(cohort_list)
114+
nrel_times = length(nvarlist)
61115
ncohort = length(cohort_list)
62116

63117
# Prepare interaction terms for the interacted regression
@@ -97,11 +151,21 @@ function StatsAPI.fit(::Type{EventStudyInteract},
97151
progress_bar = progress_bar,
98152
subset = subset)
99153

100-
#Caculate the weights.
101-
102-
df = df[result.esample .== 1, :]
103-
104-
154+
postkeep_rel = _estimable_relmask(result.vcov, nrel_times, ncohort)
155+
if !all(postkeep_rel)
156+
nvarlist = nvarlist[postkeep_rel]
157+
dvarlist = dvarlist[postkeep_rel]
158+
end
159+
isempty(nvarlist) && error("No estimable relative-time indicators remain after fitting the interacted regression.")
160+
161+
nrel_times = length(nvarlist)
162+
rel_keep_mask = falses(length(rel_varlist))
163+
rel_keep_mask[findall(prekeep_rel)] .= postkeep_rel
164+
165+
#Caculate the weights.
166+
167+
df = df[result.esample .== 1, :]
168+
105169
if ncohort == 1
106170
ff_w = ones(1, nrel_times)
107171
Sigma_ff = zeros(nrel_times, nrel_times)
@@ -127,40 +191,25 @@ function StatsAPI.fit(::Type{EventStudyInteract},
127191
Sigma_ff = _share_vcov(stage2df, e, X, vcov)
128192
end
129193

130-
b = result.coef
131-
132-
V = diag(result.vcov)
133-
134-
replace!(V, NaN => 0)
135-
136-
# Convert the delta estimate vector to a matrix where each column is a relative time
137-
end_ = 0
138-
evt_bb = zeros(ncohort,0)
139-
evt_VV = zeros(ncohort,0)
140-
141-
for i in 1:nrel_times
142-
start = end_ + 1
143-
end_ = start + ncohort - 1
144-
b_i = b[start:end_]
145-
evt_bb = hcat(evt_bb, b_i)
146-
V_i = V[start:end_]
147-
evt_VV = hcat(evt_VV, V_i)
148-
end
149-
150-
# Take weighted average for IW estimators
151-
w = ff_w
152-
delta = evt_bb
153-
b_iw = sum(w .* delta, dims=1)
154-
nc, nr = size(w)
155-
156-
# Ptwise variance from cohort share estimation and interacted regression
157-
VV = result.vcov # VCV from the interacted regression
158-
replace!(VV, NaN => 0)
159-
160-
VV = VV[1:nr*nc, 1:nr*nc] # in case reports _cons
161-
162-
wlong = w'.*repeat(ee(1, nr)', 1, nc)
163-
194+
interaction_keep = repeat(postkeep_rel, inner = ncohort)
195+
interaction_idx = findall(interaction_keep)
196+
b = result.coef[interaction_idx]
197+
198+
# Convert the interacted coefficients to a matrix where each column is a relative time
199+
evt_bb = reshape(b, ncohort, nrel_times)
200+
201+
# Take weighted average for IW estimators
202+
w = ff_w
203+
delta = evt_bb
204+
b_iw = sum(w .* delta, dims=1)
205+
nc, nr = size(w)
206+
207+
# Ptwise variance from cohort share estimation and interacted regression
208+
VV_full = _zero_omitted_rowscols(result.vcov) # VCV from the interacted regression
209+
VV = VV_full[interaction_idx, interaction_idx]
210+
211+
wlong = w'.*repeat(ee(1, nr)', 1, nc)
212+
164213
for i in 2:nrel_times # loop from 2 to nrel_times
165214
wlong = [wlong (w' .* repeat(ee(i, nr)', 1, nc))] # concatenate
166215
end
@@ -179,10 +228,9 @@ function StatsAPI.fit(::Type{EventStudyInteract},
179228
end
180229
end
181230

182-
coef_iw = vec(b_iw')
183-
184-
vcov_iw = Symmetric(V_iw)
185-
vcov_iw_diag = diag(vcov_iw)
231+
coef_iw_reduced = vec(b_iw')
232+
vcov_iw_reduced = Symmetric(V_iw)
233+
coef_iw, vcov_iw = _reinsert_rel_outputs(coef_iw_reduced, vcov_iw_reduced, rel_keep_mask, length(rel_varlist))
186234

187235

188236

@@ -213,7 +261,7 @@ function StatsAPI.fit(::Type{EventStudyInteract},
213261
nclusters = result.nclusters
214262
esample = result.esample
215263
fekeys = result.fekeys
216-
coef_names = [string(x) for x in dvarlist]
264+
coef_names = [string(x) for x in full_dvarlist]
217265
# coef_names = coefnames
218266
response_name = responsename(result)
219267
# response_name, coef_names = coefnames(formula_schema)

test/runtests.jl

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,39 @@ try
351351
@test all(isfinite, diag(vcov(model)))
352352
end
353353

354+
@testset "Zero relative-time columns do not poison clustered share vcov" begin
355+
df = make_test_panel()
356+
df.g_3 = zeros(Int, nrow(df))
357+
358+
formula = term(:Y) ~ sum(fe.(term.([:id, :t])))
359+
baseline = eventreg(
360+
df,
361+
formula,
362+
[:g_2, :g0, :g1, :g2],
363+
:never_treat,
364+
:first_treat,
365+
Vcov.cluster(:id);
366+
progress_bar = false,
367+
)
368+
with_zero = eventreg(
369+
df,
370+
formula,
371+
[:g_3, :g_2, :g0, :g1, :g2],
372+
:never_treat,
373+
:first_treat,
374+
Vcov.cluster(:id);
375+
progress_bar = false,
376+
)
377+
378+
@test coefnames(with_zero) == string.([:g_3, :g_2, :g0, :g1, :g2])
379+
@test coef(with_zero)[1] == 0.0
380+
@test isnan(diag(vcov(with_zero))[1])
381+
@test all(isnan, vcov(with_zero)[1, :])
382+
@test all(isnan, vcov(with_zero)[:, 1])
383+
@test coef(with_zero)[2:end] coef(baseline) atol = 1.0e-10 rtol = 1.0e-10
384+
@test diag(vcov(with_zero))[2:end] diag(vcov(baseline)) atol = 1.0e-10 rtol = 1.0e-10
385+
end
386+
354387
@testset "Single-cohort equivalence to direct FE regression" begin
355388
report = run_single_cohort_equivalence()
356389

0 commit comments

Comments
 (0)