Code
using DataFrames
using LinearAlgebra
using Statistics
using Random
using CairoMakie
CairoMakie.activate!(type = "png")
using Panelest
using StatsModels
using CSV
using ShiftShareIVusing DataFrames
using LinearAlgebra
using Statistics
using Random
using CairoMakie
CairoMakie.activate!(type = "png")
using Panelest
using StatsModels
using CSV
using ShiftShareIVShift-share, or Bartik, instruments answer one specific problem, so it is clearest to write the regression down first.
We want the effect of a local economic shock on a local outcome. For region \(\ell\),
where \(X_\ell\) is the shock the region actually experienced — local import competition per worker, in the China-shock literature — and \(Y_\ell\) is the outcome, say the change in the local manufacturing employment share. The parameter we want is \(\beta\).
The endogenous regressor is \(X_\ell\). Regions do not receive their shocks at random. A region whose import competition rises sharply may be a region whose industries were already losing ground, and whatever caused that decline sits in \(u_\ell\) and moves \(Y_\ell\) on its own. Then \(\text{Cov}(X_\ell, u_\ell) \ne 0\), and OLS mixes the effect of the shock with the local conditions that produced it. We cannot sign that bias in advance; it depends on whether those local conditions push the outcome up or down.
The shift-share instrument replaces the realised local shock with a predicted one, assembled from two pieces that are meant to have nothing to do with \(u_\ell\): each region’s baseline industry shares, fixed before the outcome period, and national or foreign industry-level shocks, common across regions. A region with a large baseline share in an industry is mechanically more exposed to a shock hitting that industry, and that exposure is predicted rather than realised.
Building the instrument is the easy part. The difficult part is saying which of the two pieces carries the exogeneity assumption — the shares, the shocks, or both — because the two answers lead to different identification arguments, different diagnostics, and different standard errors. That is what the rest of the chapter is about.
For each location \(\ell\) and industry \(k\), let \(s_{\ell k}\) be the share of local employment in industry \(k\) at baseline, and let \(g_k\) be a national growth rate (or trade shock) for that industry. The shift-share instrument is
This is “shift-share” because it combines a local share with an industry-level shift. Bartik (1991) used local industry mix to predict local employment growth. Autor, Dorn, and Hanson (2013) used local industry shares interacted with industry-level Chinese import growth in other high-income countries.
The first-stage regression is then
with \(B_\ell\) instrumenting for the observed local shock in a 2SLS for the outcome of interest.
There are two ways to justify a shift-share instrument. They put the exogeneity assumption on different objects.
Treat the shocks \(g_k\) as the source of identifying variation. Shares are exposure weights. Under this view, shocks should be uncorrelated with the second-stage unobservables, conditional on industry-level controls. BHJ show how to rewrite the regression at the shock level.
The two views are not mutually exclusive. In an application, we should be clear which one is being defended and report diagnostics for both.
Standard OLS or cluster-robust standard errors on the regional regression underestimate uncertainty because the same industry shocks appear in many regions, inducing cross-region correlation that region-level clustering does not capture. Two corrections are now standard:
bhj_collapse function.Use a small DGP with regions, industries, and a known causal effect.
# The CSVs below are 500 locations x 20 industries; beta_true is the DGP coefficient.
beta_true = 0.5
df = CSV.read("data/shift_share_sim.csv", DataFrame)
shares = Matrix(CSV.read("data/shift_share_shares.csv", DataFrame))
shocks = CSV.read("data/shift_share_shocks.csv", DataFrame).shock
X = df.X; Y = df.Y; u = df.u
first(df[:, [:region, :X, :Y, :u]], 6)| Row | region | X | Y | u |
|---|---|---|---|---|
| Int64 | Float64 | Float64 | Float64 | |
| 1 | 1 | -0.518194 | 0.11729 | -0.308258 |
| 2 | 2 | 0.212703 | -1.06765 | -0.538806 |
| 3 | 3 | 0.652136 | 0.892118 | 1.37164 |
| 4 | 4 | 0.285676 | 0.861261 | 1.09974 |
| 5 | 5 | -0.334493 | -0.329605 | -0.111544 |
| 6 | 6 | 0.37613 | -1.72747 | -1.75462 |
A naive OLS suffers from the confounder u:
ols = feols(df, @formula(Y ~ X))
ivss = feiv(df, @formula(Y ~ 1), endo = :X, inst = :B)
@printf("%-25s %8s %8s\n", "Estimator", "Coef", "SE")
@printf("%-25s %8.3f %8.3f\n", "OLS (biased)",
coef(ols)[end], stderror(ols)[end])
@printf("%-25s %8.3f %8.3f\n", "Shift-share IV",
coef(ivss)[1], stderror(ivss)[1])
@printf("True beta: %.3f\n", beta_true)Estimator Coef SE
OLS (biased) 1.451 0.079
Shift-share IV 0.531 0.120
True beta: 0.500
OLS is biased upward by the confounder; the shift-share IV recovers a value close to the true effect.
The GPSS decomposition says the shift-share IV is a weighted average of \(K\) just-identified IVs (one per industry share). Compute the weights:
rw = rotemberg_weights(shares, shocks, X, Y)
@printf("%-10s %8s %8s %8s\n", "Industry", "Shock", "α_k", "β_k")
for row in eachrow(rw)
@printf("%-10d %8.3f %8.3f %8.3f\n",
row.industry, row.shock, row.alpha, row.beta_k)
end
@printf("\nGPSS identity: Σ α_k β_k = %.4f\n", sum(rw.alpha_beta))
@printf("Shift-share IV estimate: %.4f\n", coef(ivss)[1])Industry Shock α_k β_k
1 1.250 0.054 0.604
2 -0.959 0.048 0.244
3 -0.212 0.005 2.576
4 0.745 0.002 -10.225
5 2.663 0.387 0.469
6 -1.068 0.082 1.025
7 0.100 -0.001 -1.627
8 -0.718 0.025 -0.689
9 1.181 0.058 0.853
10 -0.197 0.004 1.188
11 0.741 0.023 -0.112
12 0.051 -0.001 -0.055
13 -0.442 0.014 0.611
14 0.270 0.003 -0.013
15 0.631 0.003 -3.240
16 2.271 0.204 0.746
17 -0.212 0.001 0.174
18 -0.115 0.000 -39.532
19 0.020 -0.000 1.234
20 -1.183 0.089 0.554
GPSS identity: Σ α_k β_k = 0.5309
Shift-share IV estimate: 0.5309
Two diagnostics matter:
fig = Figure(size = (640, 380))
ax = Axis(fig[1, 1],
xlabel = "Industry",
ylabel = "Just-identified IV estimate (β_k)",
title = "Industry-level IV estimates and Rotemberg weights")
# Bubble plot: position = beta_k, size = |alpha_k|
scatter!(ax, rw.industry, rw.beta_k,
markersize = 30 .* sqrt.(abs.(rw.alpha)),
color = (:steelblue, 0.7))
hlines!(ax, [beta_true], color = :firebrick, linestyle = :dash,
linewidth = 2, label = "True β = $(beta_true)")
axislegend(ax, position = :rb, framevisible = false)
figCollapse the location-level data to the shock level and run a weighted IV at the shock (industry) level. This reframes the identifying assumption: shocks must be uncorrelated with the shock-level aggregated outcome residual.
collapsed = bhj_collapse(shares, shocks, Y, X)
# BHJ shock-level 2SLS: regress Y_agg ~ X_agg | shock, weighted by weight
df_collapsed = collapsed
iv_bhj = feiv(df_collapsed, @formula(Y_agg ~ 1),
endo = :X_agg, inst = :shock, weights = :weight)
@printf("Location-level shift-share IV: %.3f\n", coef(ivss)[1])
@printf("BHJ shock-level IV: %.3f\n", coef(iv_bhj)[1])
@printf("(Should be close; equivalent in the no-controls case)\n")Location-level shift-share IV: 0.531
BHJ shock-level IV: 0.537
(Should be close; equivalent in the no-controls case)
The BHJ shock-level regression coincides with the location-level IV when there are no controls — up to implementation details of the weighting, so expect close rather than identical values in practice. Its value is interpretive: validity is now a statement about shocks (20 observations, one per industry) rather than locations (500 observations), and testing shock-level exogeneity is more transparent.
The standard shift-share application is ADH (2013). Commuting zones are exposed differently to Chinese import competition because their baseline industry mixes differ. The instrument is
where the shocks \(\Delta M^{\text{other}}_k\) are growth in Chinese imports into other high-income countries. This leave-one-out construction removes US-specific demand from the shock.
The snippet below is schematic — the full ADH specification additionally needs period effects, the ADH control set, and population weights (a validated replication against the GPSS benchmark lives in the ShiftShareIV.jl repository).
# David Dorn distributes the replication data at ddorn.net
# Files needed:
# workfile_china.csv (commuting zone data, 1990–2007)
# industry_shares.csv (czone × industry shares)
using CSV
cz = DataFrame(CSV.File("data/workfile_china.csv"))
shares = Matrix(select(cz, r"share_")) # L × K share matrix
shocks = cz.import_shock_other # K-vector of "other-countries" shocks
B = bartik_iv(shares, shocks) # Bartik instrument
df_adh = hcat(cz, DataFrame(B = B))
# First-stage + second-stage
iv_adh = feiv(df_adh, @formula(d_sh_empl_mfg ~ 1),
endo = :d_tradeusch_pw, inst = :B,
vcov_type = Vcov.cluster(:statefip))
# Rotemberg decomposition
rw_adh = rotemberg_weights(shares, shocks, df_adh.d_tradeusch_pw,
df_adh.d_sh_empl_mfg)
# Top 5 industries by |alpha|:
sort(rw_adh, :alpha, rev=true)[1:5, :]
# BHJ shock-level regression
collapsed_adh = bhj_collapse(shares, shocks, df_adh.d_sh_empl_mfg,
df_adh.d_tradeusch_pw)
iv_bhj_adh = feiv(collapsed_adh, @formula(Y_agg ~ 1),
endo = :X_agg, inst = :shock, weights = :weight)For a new shift-share paper, I would expect:
rotemberg_weights): identify which industries drive the estimate; flag weight concentration and heterogeneous \(\hat\beta_k\).bhj_collapse): reframe validity as a statement about 20 industry shocks rather than 500 commuting zones.ShiftShareIV.jl provides bartik_iv, rotemberg_weights, and bhj_collapse; Panelest.jl provides feols and feiv.