18  IV in Poisson with Fixed Effects

using Panelest
using Distributions
using DataFrames, DataFramesMeta
using StatsModels
import StatsAPI: coeftable
using Random, StatsBase, LinearAlgebra
using Printf

Poisson regression specifies the conditional mean as an exponential function. As a quasi-MLE, it needs the mean to be correct, not the full count distribution. If a regressor is endogenous, the IV strategy has to respect that nonlinear mean. I consider two cases: a continuous endogenous variable and a binary endogenous variable.

18.1 The Control Function Framework

18.1.1 Linear second stage — CF equals 2SLS

In the previous chapter we saw that adding the first-stage residual \(\hat{v}_2\) to an OLS second stage recovers the same coefficient as 2SLS. The algebra is:

  1. First stage: \(y_2 = \mathbf{z}\boldsymbol{\pi} + v_2\), estimated by OLS. Residual \(\hat{v}_2 = y_2 - \hat{y}_2\) satisfies \(E(\mathbf{z}'\hat{v}_2) = 0\).

  2. Structural error decomposition: write \(\varepsilon_1 = \rho v_2 + \eta_1\), where \(E(\mathbf{z}'\eta_1) = 0\) and \(E(v_2\,\eta_1) = 0\) by construction.

  3. Augmented second stage: substitute to get \(y_1 = \mathbf{z}_1\boldsymbol{\delta}_1 + \alpha_1 y_2 + \rho\,\hat{v}_2 + \eta_1.\)

OLS on this equation is the control function estimator. The Frisch–Waugh–Lovell (FWL) theorem guarantees it is numerically identical to 2SLS — no extra assumption beyond \(E(\mathbf{z}'\varepsilon_1) = 0\) is required.

18.1.2 Nonlinear second stage — CF diverges from 2SLS

When the conditional mean is \(\exp(\mathbf{x}\boldsymbol{\beta})\) (Poisson), a direct IV/GMM approach solves moment conditions in \(\mathbf{z}\) without modifying the exponential mean. Two moment forms exist, and the choice matters:

  • Additive moments \(E[\mathbf{z}'(y_1 - \exp(\mathbf{x}\boldsymbol{\beta}))] = 0\) assume an additive error orthogonal to \(\mathbf{z}\).
  • Multiplicative moments \(E[\mathbf{z}'(y_1 e^{-\mathbf{x}\boldsymbol{\beta}} - 1)] = 0\) (Mullahy 1997) assume a multiplicative error with mean independent of \(\mathbf{z}\).

When the unobservable enters inside the exponential — \(y_1\) has mean \(\exp(\mathbf{x}\boldsymbol{\beta} + \rho v)\), as in both DGPs of this chapter — the error is multiplicative, the additive moments are misspecified, and additive-moment IV is inconsistent. The multiplicative form is the robust default. The control function approach instead imposes the stronger conditional mean assumption (Wooldridge 2002, 2014):

\[\boxed{E[y_1 \mid \mathbf{z}_1,\, y_2,\, v_2] = \exp\!\bigl(\mathbf{z}_1\boldsymbol{\delta}_1 + \alpha_1 y_2 + \rho v_2\bigr).}\]

This says the unobservable enters the conditional mean linearly inside \(\exp(\cdot)\). Under this assumption, substituting \(\hat{v}_2\) for \(v_2\) and running Poisson QMLE gives a consistent estimate of \(\alpha_1\).

Three practical consequences:

Property Linear (OLS/2SLS) Nonlinear (Poisson CF)
Extra assumption beyond IV exogeneity None — FWL applies CF mean assumption required
CF vs. 2SLS/GMM numerically Identical Different — FWL fails
SE from second stage alone Correct Understated — bootstrap needed
Test of endogeneity \(t\)-stat on \(\hat{v}_2\) (DWH) Same — \(t\)-stat on \(\hat{v}_2\)
Note

Intuition for the CF assumption. The decomposition \(\varepsilon_1 = \rho v_2 + \eta_1\) is exactly the same as in the linear case. What changes is where the decomposition gets applied: in the linear model it enters additively outside any transformation; in Poisson it must enter inside \(\exp(\cdot)\) to be absorbed by the quasi-MLE score. If the true \(\rho \neq 0\), omitting \(\hat{v}_2\) from the exponential mean leaves a multiplicative endogeneity term \(e^{\rho v_2}\) correlated with \(y_2\), biasing \(\hat\alpha_1\) upward or downward depending on the sign of \(\rho\).

18.2 Part 1 — Continuous endogenous variable

18.2.1 Data generating process

We simulate a panel where time (continuous, e.g. minutes on site) is endogenous: it is correlated with an unobserved factor e that also affects visits. The excluded instrument is phone (binary, e.g. phone-access indicator). Fixed effects are ad (20 groups) and female.

Random.seed!(42)
n_ad = 20; obs_per = 250; n = n_ad * obs_per

fe_ad  = randn(n_ad) .* 0.5
fe_grp = repeat(1:n_ad, inner = obs_per)
female = Int.(rand(n) .< 0.5)
phone  = Int.(rand(n) .< 0.4)
frfam  = rand(n)

# Common error drives endogeneity
e = randn(n)

# Continuous endogenous variable (linear first stage holds exactly)
time = 1.5 .* phone .+ 0.5 .* frfam .+ fe_ad[fe_grp] .+ e

# Poisson outcome — true coefficient on time = 0.8
true_α = 0.8
λ = exp.(0.5 .+ true_α .* time .+ 0.4 .* frfam .+
         fe_ad[fe_grp] .+ 0.3 .* female .+ 0.5 .* e)
visits = [rand(Poisson(λi)) for λi in λ]

df1 = DataFrame(visits=visits, time=time, phone=phone,
                frfam=frfam, female=female, ad=fe_grp)

println("n = $n   groups = $n_ad   visits mean = $(round(mean(visits),digits=2))")
n = 5000   groups = 20   visits mean = 15.93

18.2.2 Naive Poisson — endogeneity bias

Ignoring the endogeneity of time gives a biased estimate of α₁:

naive1 = fepois(df1, @formula(visits ~ time + frfam + fe(ad) + fe(female)))
println("Naive (biased):")
show_regression_html(naive1; title="Naive Poisson — time is endogenous")
Naive (biased):

Naive Poisson — time is endogenous

Estimate Std. Error t value Pr(>|t|)
time 1.150 0.003 392.827 < 2e-16 ***
frfam 0.291 0.013 22.698 < 2e-16 ***
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

18.2.3 Control function approach

Applying the framework above, the two-step CF procedure is:

  1. First stage — linear projection of time on phone, frfam, and fixed effects via feols. The residual \(\hat{v}_2 = \text{time} - \hat{\text{time}}\) satisfies \(E(\mathbf{z}'\hat{v}_2)=0\) by construction. A linear first stage is valid even when \(y_2\) is continuous or binary (Part 2 shows both).

  2. Second stage — Poisson QMLE with \(\hat{v}_2\) added as a regressor inside the exponential mean. The CF assumption \(E[y_1 \mid \mathbf{z}_1, y_2, v_2] = \exp(\mathbf{z}_1\boldsymbol{\delta}_1 + \alpha_1 y_2 + \rho v_2)\) holds exactly here because the DGP specifies \(0.5\,e\) (linear in the unobservable) inside \(\exp(\cdot)\).

Note that unlike 2SLS in a linear model, \(\hat{v}_2\) does not replace \(y_2\) — both enter together. This is what makes the CF estimator different from IV/GMM Poisson (confirmed numerically in Part 2).

# Step 1: linear projection
fs1 = feols(df1, @formula(time ~ phone + frfam + fe(ad) + fe(female)))
df1.v2hat = fs1.residuals

# Step 2: Poisson CF
m1_cf = fepois(df1, @formula(visits ~ time + v2hat + frfam + fe(ad) + fe(female)))
println("CF estimate (should recover α₁ = $(true_α)):")
show_regression_html(m1_cf; title="CF: linear projection first stage")
CF estimate (should recover α₁ = 0.8):

CF: linear projection first stage

Estimate Std. Error t value Pr(>|t|)
time 0.810 0.005 158.862 < 2e-16 ***
v2hat 0.489 0.006 76.972 < 2e-16 ***
frfam 0.405 0.013 31.664 < 2e-16 ***
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

The coefficient on time recovers the true \(\alpha_1 = 0.8\). The v2hat coefficient tests exogeneity — its t-statistic is the Hausman-type test.

18.2.4 Bootstrap standard errors

The second-stage standard errors are incorrect because the first-stage estimation error propagates. Julia’s Threads.@threads parallelises the cluster bootstrap across all CPU cores:

function bootstrap_resample(df::DataFrame, B::Int; seed::Int=42)
    Random.seed!(seed)
    clusters = unique(df.ad)
    K = length(clusters)
    draws = [sample(clusters, K, replace=true) for _ in 1:B]
    return draws
end

function build_boot_df(df::DataFrame, drawn_clusters::Vector)
    frames = DataFrame[]
    for (id, c) in enumerate(drawn_clusters)
        sub = copy(df[df.ad .== c, :])
        sub.ad_boot = fill(id, nrow(sub))
        push!(frames, sub)
    end
    return vcat(frames...)
end

# Part 1 bootstrap: continuous time
function bootstrap_cf_continuous(df::DataFrame, B::Int=500; seed::Int=42)
    draws   = bootstrap_resample(df, B; seed=seed)
    results = zeros(B)
    Threads.@threads for b in 1:B
        df_b = build_boot_df(df, draws[b])
        fs   = feols(df_b,  @formula(time ~ phone + frfam + fe(ad_boot) + fe(female)))
        df_b.v2hat = fs.residuals
        ss   = fepois(df_b, @formula(visits ~ time + v2hat + frfam + fe(ad_boot) + fe(female)))
        idx  = findfirst(==("time"), coefnames(ss))
        results[b] = coef(ss)[idx]
    end
    return results
end

boot1 = bootstrap_cf_continuous(df1, 500)
naive_se1 = stderror(m1_cf)[findfirst(==("time"), coefnames(m1_cf))]
coef1     = coef(m1_cf)[findfirst(==("time"), coefnames(m1_cf))]

@printf("time coefficient:  %.4f  (true %.1f)\n", coef1, true_α)
@printf("Naive 2nd-stage SE: %.4f\n", naive_se1)
@printf("Bootstrap SE:       %.4f\n", std(boot1))
@printf("Bootstrap 95%% CI: [%.4f, %.4f]\n",
        quantile(boot1, 0.025), quantile(boot1, 0.975))
time coefficient:  0.8103  (true 0.8)
Naive 2nd-stage SE: 0.0051
Bootstrap SE:       0.0112
Bootstrap 95% CI: [0.7868, 0.8312]

The bootstrapped SE is larger than the naive second-stage SE, confirming that ignoring the first-stage uncertainty understates uncertainty.


18.3 Part 2 — Binary endogenous variable

When \(y_2 \in \{0,1\}\), the same linear projection first stage is still valid, but two additional approaches exploiting the binary structure become available.

18.3.1 Data generating process

We use a clean probit DGP for the binary endogenous variable with a fresh structural error e2 independent of Part 1. The endogeneity coefficient ρ = 0.2 is kept small so the CF assumption holds approximately well and estimates are close to the true value.

Random.seed!(123)

# Fresh structural error for Part 2 (independent of Part 1's e)
e2 = randn(n)

# Binary endogenous variable: clean probit latent index
# phone is the excluded instrument; no FE in latent index for clarity
time_hi = Int.(1.5 .* phone .+ 0.4 .* frfam .+ e2 .>= 0)

# Poisson outcome — true coefficient 0.8, endogeneity via ρ*e2 (ρ=0.2)
# Small ρ ensures the approximate CF assumption holds closely
ρ2 = 0.2
λ2 = exp.(0.5 .+ true_α .* time_hi .+ 0.4 .* frfam .+
          fe_ad[fe_grp] .+ 0.3 .* female .+ ρ2 .* e2)
visits2 = [rand(Poisson2i)) for λ2i in λ2]

df2 = DataFrame(visits=visits2, time_hi=time_hi, phone=phone,
                frfam=frfam, female=female, ad=fe_grp, e2=e2)

println("time_hi mean:      $(round(mean(time_hi), digits=3))")
println("Cor(time_hi, e2):  $(round(cor(time_hi, e2), digits=3))  (endogeneity)")
println("visits mean:       $(round(mean(visits2), digits=2))")
time_hi mean:      0.727
Cor(time_hi, e2):  0.613  (endogeneity)
visits mean:       4.78

18.3.2 Three approaches: theory recap

First stage What enters 2nd stage 1st-stage assumption 2nd-stage assumption
A feols (OLS) Residuals \(\hat{v}_2\) Exogeneity of \(\mathbf{z}\) only CF mean \(\exp(\cdots + \rho v_2)\) correct
B feprobit Gen. residuals \(\hat{r}_2\) Probit correctly specified CF mean \(\exp(\cdots + \rho r_2)\) correct
C Any model Fitted values \(\hat{p}_2\) Exogeneity of \(\mathbf{z}\) only Moment form matches the error: additive moments need an additive error; multiplicative (Mullahy) moments handle inside-\(\exp\) unobservables

All three require the second-stage Poisson conditional mean to be correctly specified. A and C share the same (weaker) first-stage assumption; B additionally requires correct probit specification.

18.3.3 Approach A — Linear projection CF

# Step 1: linear projection of binary time_hi
fs_a = feols(df2, @formula(time_hi ~ phone + frfam + fe(ad) + fe(female)))
df2.v2hat = fs_a.residuals

# Step 2: Poisson CF
m_a = fepois(df2, @formula(visits ~ time_hi + v2hat + frfam + fe(ad) + fe(female)))
println("Approach A — Linear projection CF:")
show_regression_html(m_a; title="Approach A: OLS residual as control function")
Approach A — Linear projection CF:

Approach A: OLS residual as control function

Estimate Std. Error t value Pr(>|t|)
time_hi 0.813 0.036 22.719 < 2e-16 ***
v2hat 0.330 0.035 9.396 < 2e-16 ***
frfam 0.413 0.023 17.921 < 2e-16 ***
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

18.3.4 Approach B — Probit generalized residuals

# Step 1: probit first stage
fs_b = feprobit(df2, @formula(time_hi ~ phone + frfam + fe(ad) + fe(female)))

# Generalized residual (Gourieroux et al.): φ(η)/Φ(η) if y=1 (inverse Mills ratio),
# -φ(η)/(1-Φ(η)) if y=0 (the reverse Mills ratio).
η  = fs_b.eta
nd = Normal()
df2.gr2 = @. ifelse(df2.time_hi == 1,
    pdf(nd, η) / cdf(nd, η),
    -pdf(nd, η) / (1.0 - cdf(nd, η)))

# Step 2: Poisson CF with generalized residual
m_b = fepois(df2, @formula(visits ~ time_hi + gr2 + frfam + fe(ad) + fe(female)))
println("Approach B — Probit generalized residual CF:")
show_regression_html(m_b; title="Approach B: probit generalized residual")
Approach B — Probit generalized residual CF:

Approach B: probit generalized residual

Estimate Std. Error t value Pr(>|t|)
time_hi 0.764 0.039 19.549 < 2e-16 ***
gr2 0.230 0.024 9.721 < 2e-16 ***
frfam 0.415 0.023 17.969 < 2e-16 ***
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

18.3.5 Approach C — Fitted values as instrument (IV/GMM Poisson)

Any function of the exogenous \(\mathbf{z}\) is a valid instrument. Probit, logit, and linear probability fitted values are all equally valid from a consistency standpoint — the choice affects efficiency (instrument strength). We implement IV/GMM Poisson directly, keeping time_hi as the regressor and using probit fitted probabilities as the instrument. We code both moment forms: the additive moments are what “IV Poisson” often means in practice (e.g. Stata’s ivpoisson gmm default additive option), but because this chapter’s DGP puts the unobservable inside \(\exp(\cdot)\), only the multiplicative (Mullahy) moments are consistent here:

"""
    ivpois(y, X, Z) -> (β, se)

IV/GMM Poisson (just-identified). X = [endog, exog], Z = [instrument, exog].
Damped IRLS: intercept initialised from log(mean(y)), half-steps for stability.
"""
function ivpois(y::Vector{Float64}, X::Matrix{Float64}, Z::Matrix{Float64};
                maxiter=500, tol=1e-8, step=0.5)
    p  = size(X, 2)
    β  = zeros(p)
    β[p] = log(mean(y))    # intercept from marginal mean — prevents divergence

    for _ in 1:maxiter
        μ = exp.(clamp.(X * β, -30.0, 30.0))
        H = Z' *.* X)
        g = Z' * (y .- μ)
        Δ = try H \ g catch; pinv(H) * g end
        β .+= step .* Δ
        maximum(abs.(step .* Δ)) < tol && break
    end

    μ = exp.(clamp.(X * β, -30.0, 30.0))
    H = Z' *.* X)
    B = Z' * ((y .- μ).^2 .* Z)       # robust sandwich meat
    V = try inv(H) * B * inv(H)' catch; pinv(H) * B * pinv(H)' end
    return β, sqrt.(max.(0.0, diag(V)))
end

"""
    ivpois_mult(y, X, Z) -> β

IV/GMM Poisson with MULTIPLICATIVE (Mullahy 1997) moments
Z'(y .* exp(-Xβ) .- 1) = 0 — consistent when the unobservable enters
inside the exponential mean (multiplicative error).
"""
function ivpois_mult(y::Vector{Float64}, X::Matrix{Float64}, Z::Matrix{Float64};
                     maxiter=1000, tol=1e-10, step=0.5)
    p  = size(X, 2)
    β  = zeros(p)
    β[p] = log(mean(y))
    for _ in 1:maxiter
        w = y .* exp.(clamp.(-X * β, -30.0, 30.0))
        g = Z' * (w .- 1)
        H = Z' * (w .* X)
        Δ = try H \ g catch; pinv(H) * g end
        β .+= step .* Δ
        maximum(abs.(step .* Δ)) < tol && break
    end
    return β
end

# Probit (without FE, to avoid collinearity with instrument matrix)
fs_c  = feprobit(df2, @formula(time_hi ~ phone + frfam))
phat  = fs_c.mu

# Regressor matrix X: [time_hi, frfam, intercept]
# Instrument matrix Z: [phat,    frfam, intercept]  ← phat replaces time_hi
X_c = hcat(Float64.(df2.time_hi), Float64.(df2.frfam), ones(n))
Z_c = hcat(Float64.(phat),        Float64.(df2.frfam), ones(n))

β_c, se_c = ivpois(Float64.(df2.visits), X_c, Z_c)
β_cm      = ivpois_mult(Float64.(df2.visits), X_c, Z_c)
@printf("\nApproach C — IV/GMM Poisson (no FE, for illustration)\n")
@printf("  additive moments:       time_hi = %7.4f   SE = %7.4f\n",
        β_c[1], se_c[1])
@printf("  multiplicative moments: time_hi = %7.4f   (true %.1f)\n",
        β_cm[1], true_α)

Approach C — IV/GMM Poisson (no FE, for illustration)
  additive moments:       time_hi =  0.5273   SE =  0.0706
  multiplicative moments: time_hi =  0.7260   (true 0.8)

The additive-moment estimate is visibly biased — the DGP’s unobservable \(\rho\,e_2\) sits inside \(\exp(\cdot)\), so the additive moments are misspecified (a 30-replication check gives an additive-moment mean of about \(0.61\) against a multiplicative-moment mean of about \(0.79\), with true value \(0.8\)). The multiplicative moments repair it.

Note

Approach C with FE requires demeaning the regressor and instrument matrices via within-group centering before solving the IV Poisson moment conditions. Because Poisson is nonlinear this is not straightforward, and ivpoisson in Stata similarly drops absorb(). Approach A via feols + fepois is the practical choice when many fixed effects are present.

18.3.6 A vs C — not equivalent in Poisson

In a linear model, using \(\hat{y}_2\) as an instrument (2SLS) is numerically identical to adding \(\hat{v}_2 = y_2 - \hat{y}_2\) as a regressor — the Frisch-Waugh-Lovell theorem. In Poisson this equivalence breaks, because Approach A puts \(\hat{v}_2\) inside \(\exp(\cdot)\) while Approach C uses \(\hat{y}_2\) in the moment conditions without modifying the exponential mean.

# Use linear fitted values as instruments (Approach C with linear first stage)
ŷ2  = Float64.(df2.time_hi) .- Float64.(fs_a.residuals)
X_l = hcat(Float64.(df2.time_hi), Float64.(df2.frfam), ones(n))
Z_l = hcat2,                    Float64.(df2.frfam), ones(n))

β_l, _ = ivpois(Float64.(df2.visits), X_l, Z_l)

coef_a = coef(m_a)[findfirst(==("time_hi"), coefnames(m_a))]
@printf("Approach A  (CF, residual as regressor, with FE):     α̂₁ = %.4f\n", coef_a)
@printf("C-additive  (IV, linear fitted values, no FE):        α̂₁ = %.4f\n", β_l[1])
@printf("C-additive  (IV, probit fitted values, no FE):        α̂₁ = %.4f\n", β_c[1])
@printf("C-Mullahy   (IV, multiplicative moments, no FE):      α̂₁ = %.4f\n", β_cm[1])
@printf("True α₁ = %.1f\n", true_α)
Approach A  (CF, residual as regressor, with FE):     α̂₁ = 0.8128
C-additive  (IV, linear fitted values, no FE):        α̂₁ = 0.6194
C-additive  (IV, probit fitted values, no FE):        α̂₁ = 0.5273
C-Mullahy   (IV, multiplicative moments, no FE):      α̂₁ = 0.7260
True α₁ = 0.8

The estimates differ despite using the same first-stage linear projection, confirming that 2SLS ≠ CF for Poisson — and, within Approach C, that the moment form matters as much as the instrument.

18.3.7 Bootstrap for Approach A (binary EEV)

# Part 2 bootstrap: binary time_hi
function bootstrap_cf_binary(df::DataFrame, B::Int=500; seed::Int=42)
    draws   = bootstrap_resample(df, B; seed=seed)
    results = zeros(B)
    Threads.@threads for b in 1:B
        df_b = build_boot_df(df, draws[b])
        fs   = feols(df_b,  @formula(time_hi ~ phone + frfam + fe(ad_boot) + fe(female)))
        df_b.v2hat = fs.residuals
        ss   = fepois(df_b, @formula(visits ~ time_hi + v2hat + frfam + fe(ad_boot) + fe(female)))
        idx  = findfirst(==("time_hi"), coefnames(ss))
        results[b] = coef(ss)[idx]
    end
    return results
end

boot2 = bootstrap_cf_binary(df2, 500)
naive_se2 = stderror(m_a)[findfirst(==("time_hi"), coefnames(m_a))]

@printf("Approach A bootstrap (binary EEV, 500 reps, cluster by ad)\n")
@printf("  Point estimate:     %.4f  (true %.1f)\n", coef_a, true_α)
@printf("  Naive 2nd-stage SE: %.4f\n", naive_se2)
@printf("  Bootstrap SE:       %.4f\n", std(boot2))
@printf("  Bootstrap 95%% CI:  [%.4f, %.4f]\n",
        quantile(boot2, 0.025), quantile(boot2, 0.975))
Approach A bootstrap (binary EEV, 500 reps, cluster by ad)
  Point estimate:     0.8128  (true 0.8)
  Naive 2nd-stage SE: 0.0358
  Bootstrap SE:       0.0343
  Bootstrap 95% CI:  [0.7380, 0.8766]

18.3.8 Comparison across all methods

coef_naive2 = coef(
    fepois(df2, @formula(visits ~ time_hi + frfam + fe(ad) + fe(female)))
)[findfirst(==("time_hi"),
    coefnames(fepois(df2, @formula(visits ~ time_hi + frfam + fe(ad) + fe(female)))))]
coef_b = coef(m_b)[findfirst(==("time_hi"), coefnames(m_b))]

methods = ["True value", "Naive Poisson (binary y₂)",
           "A: Linear CF (with FE)", "B: Probit GR CF (with FE)",
           "C: IV Poisson, additive moments (no FE)",
           "C: IV Poisson, Mullahy moments (no FE)"]
ests    = [true_α, coef_naive2, coef_a, coef_b, β_c[1], β_cm[1]]

println("\n── Binary EEV: coefficient on time_hi ─────────────────────")
for (m, e) in zip(methods, ests)
    @printf("  %-42s  α̂₁ = %6.4f\n", m, e)
end
println()
println("Note: the CF approaches (A, B) recover the truth closely —")
println("with small ρ=0.2 the approximate CF mean assumption is mild.")
println("Additive-moment IV Poisson does NOT: the unobservable enters")
println("inside exp(·), so its moments are misspecified (Mullahy 1997).")
println("The multiplicative-moment version repairs it.")

── Binary EEV: coefficient on time_hi ─────────────────────
  True value                                  α̂₁ = 0.8000
  Naive Poisson (binary y₂)                   α̂₁ = 1.0852
  A: Linear CF (with FE)                      α̂₁ = 0.8128
  B: Probit GR CF (with FE)                   α̂₁ = 0.7636
  C: IV Poisson, additive moments (no FE)     α̂₁ = 0.5273
  C: IV Poisson, Mullahy moments (no FE)      α̂₁ = 0.7260

Note: the CF approaches (A, B) recover the truth closely —
with small ρ=0.2 the approximate CF mean assumption is mild.
Additive-moment IV Poisson does NOT: the unobservable enters
inside exp(·), so its moments are misspecified (Mullahy 1997).
The multiplicative-moment version repairs it.

18.4 Why Julia is faster

The parallel bootstrap above uses Threads.@threads which runs each of the 500 replications simultaneously across all CPU cores. A single replication calls feols + fepois, both implemented with the fast within-demeaning algorithm from Panelest.jl. On a machine with 8 cores this is roughly 6–8× faster than Stata’s serial bootstrap command, and the speed advantage grows with replication count and sample size.