Computational Trade Models

Gravity, Structural Estimation, General-Equilibrium Counterfactuals & Global Value Chains
using R, Python & Stata

Applied Informatics and Computational Economics Lab

21 July 2026

Outline

  • Part I — The Gravity Equation
    the fact, the Armington foundation, multilateral resistance, zeros
  • Part II — A Known-Truth Laboratory
    simulate a world, then see which estimator finds it again
  • Part III — Structural Gravity in Practice
    three-way fixed effects, domestic flows, trade agreements
  • Part IV — Estimator Choice
    the PML family, RESET, separation
  • Part V — The Trade Elasticity
    Head–Ries, tetrads, Caliendo–Parro
  • Part VI — General Equilibrium
    exact hat algebra, the fixed point, gains from trade
  • Part VII — Multi-Sector & Input–Output
    Caliendo–Parro with linkages, calibrated on FIGARO
  • Part VIII — Global Value Chains
    Leontief inverse, value-added exports, upstreamness
  • Part IX — Firm Heterogeneity & Practice
    Melitz, margins, exercises, further reading

Three deliberate departures from the standard order used in this lecture series. All three are choices, not drift.

1. The required sequence runs once, across the front matter and Parts I–II. Packages → Problem → Methodology → DGP → Diagnostics → Estimation → Results Table is delivered there in full. Each later part then re-enters at The Problem & the Data and closes with Summary: Routines & Practice. Nine parts cannot each carry the whole sequence without burying the content.

2. The literature is reviewed once, at the end. There is no front-matter survey slide; Further Reading in Part IX carries the whole bibliography, opening with the four references that cover the entire deck. A reading list is more useful after the methods than before them, and stating the same papers twice cost a slide that How the Data Got Here now uses.

3. The tests are distributed, not collected. RESET and the separation check sit in Part IV beside the estimator they judge; the solver’s validation-against-truth sits in Part VI beside the solver. Each still gets its own theory slide and its own code slide.

This deck is about modelling trade flows and then asking what they would be under a different policy.

Three companion decks own neighbouring ground and are not repeated here:

  • Networks and Trade Analysis — graphs, centrality, communities, the EU trade network. That deck asks who is connected to whom; this one asks what those flows would be under a different trade policy.
  • Structural Estimation in Econometrics — GMM, SMM, BLP demand, dynamic discrete choice. Gravity here is estimated by pseudo-maximum likelihood, and the general-equilibrium model is solved, not GMM-estimated.
  • Bootstrap Methods in Econometrics — resampling theory. Part III uses the pair cluster bootstrap but does not re-derive it.

Most slides carry three tabs — R, Python, Stata. Some carry two.

Where Stata has no route to the method at all, the Stata tab is simply absent rather than present-and-apologetic. The Software Toolkit slide at the end lists exactly which methods those are and why.

Required Packages

library(fixest)        # feols(), fepois() — PPML with high-dimensional FE
library(data.table)    # fread() on piped/filtered multi-GB source files
library(nleqslv)       # Broyden solver for the GE fixed point
library(Matrix)        # sparse ICIO algebra, solve() for the Leontief inverse
library(countrycode)   # ISO3 <-> country name <-> region aggregation
library(sandwich)      # vcovCL() — clustered and two-way clustered variance
library(lmtest)        # coeftest() — RESET and coefficient tests
library(modelsummary)  # msummary() — the results tables and their LaTeX
library(tidyverse)     # data wrangling & ggplot2
library(patchwork)     # multi-panel figures
library(ggrepel)       # non-overlapping country labels
library(png)           # readPNG() — reload Stata-exported graphs
import numpy as np                      # arrays, linear algebra, Leontief inverse
import pandas as pd                     # data frames, CSV input
import pyfixest as pf                   # pf.fepois() — PPML with multiple FE
import statsmodels.api as sm            # GLM/Poisson, RESET, robust covariance
from scipy.optimize import root         # the GE fixed point
from scipy import stats                 # distributions, Pareto tail fitting
import matplotlib.pyplot as plt         # all figures
* SSC packages
ssc install ppmlhdfe        // PPML with high-dimensional fixed effects
ssc install ppml            // Santos Silva-Tenreyro's original
ssc install ppml_panel_sg   // structural gravity with exporter/importer-time FE
ssc install reghdfe         // linear analogue, used for the log-OLS benchmarks
ssc install ftools          // reghdfe/ppmlhdfe dependency
ssc install estout          // esttab — the results tables

* Shipped with Stata SE
poisson, glm                // the PML family: Poisson, Gamma, Gaussian
estat ovtest                // RESET
mata                        // Leontief inverse and the GE fixed point

The matrix work — the Leontief inverse in Part VIII and the fixed-point iteration in Parts VI and VII — runs in Mata, which has no matsize ceiling. The aggregated input–output system is deliberately kept at \(96 \times 96\) so all three languages invert it comfortably.

The Data — Four Modern Sources

File Source Coverage Rows
ctm-gravity.csv CEPII Gravity V202211 150 traders, waves 2000–2020 133 206
ctm-itpde.csv ITPD-E Release 3 (USITC) 50 countries, 4 broad sectors, 2000–2016, with domestic flows 46 523
ctm-sim.csv · ctm-sim-truth.csv Simulated Armington world, seed 14159 60 countries, known \(\theta\), known counterfactual 3 600 · 60
ctm-icio.csv Eurostat FIGARO 12 regions × 8 sectors, 2022 10 464
ctm-firms.csv Eurostat TEC 32 countries, size classes and top-\(k\) shares, 2015–2024 15 586
ctm-tariffs.csv WITS/TRAINS applied MFN rates, 2020 EU–US tariffs by sector, for the Part VII counterfactual 60

On every slide that follows, R, Python and Stata read the same file. Any disagreement between the three tabs is therefore a real disagreement between methods, never a difference in the data.

The datasets most trade courses still use stopped a decade or more ago: the WTO Advanced Guide panel ends in 2006, the gravity package ships a 2006 cross-section, and WIOD’s last table is 2014.

Every source above is the current release, running to 2020, 2019 and 2024 respectively. The methods are the same; the world they describe is not.

Two of the covariates are genuinely new and get a slide of their own in Part III: scaled_sci_2021, Facebook’s Social Connectedness Index between country pairs, and diplo_disagreement, distance in United Nations voting records.

ctm-itpde.csv keeps the rows where exporter equals importer — a country’s trade with itself.

Almost every classroom gravity dataset throws those away. Without them the multilateral resistance terms are not identified from trade data alone, the border effect cannot be measured at all, and the trade elasticity has to be imported from someone else’s paper. Parts III, V and VI all rest on that diagonal.

The price is real and worth stating: ITPD-E builds domestic flows only where the production statistics allow it, so China, Switzerland, Malaysia and Thailand are not in the 50-country sample. Manufacturing is complete for all 50 countries in all five waves; services and mining have gaps.

How the Data Got Here

Two of the four sources are too large to open.

  • CEPII Gravity V202211 arrives as a 207 MB zip holding one CSV member of roughly 1.3 GB and 87 columns. Reading it whole costs several GB of memory to build a table we then discard 95% of.
  • ITPD-E Release 3 is a 982 MB zip carrying 170 industries. The deck uses four broad sectors.

Neither file is downloaded, unpacked and then filtered. Both are filtered while being read, so nothing bigger than the surviving rows is ever in memory. Three ordinary tools do all of it — unzip -p, funzip, gawk — and they turn any oversized public archive into a table that fits.

The two recipes on the next tabs are the ones that fetched these files, and they transfer: point either at another oversized public archive and it downloads once, filters during the read, and lands a table you can actually open.

data.table::fread takes a shell command in place of a filename. The pipe runs, gawk keeps the six waves, and only those rows are ever parsed by R.

zipf <- "../data/ctm-gravity-raw.zip"            # 207 MB, cached
if (!file.exists(zipf)) {                        # download once, never twice
  download.file(paste0("https://www.cepii.fr/DATA_DOWNLOAD/gravity/",
                       "data/Gravity_csv_V202211.zip"),
                zipf, quiet = TRUE, mode = "wb")
}

YEARS   <- c(2000, 2004, 2008, 2012, 2016, 2020)
yfilter <- paste(sprintf("$1==%d", YEARS), collapse = " || ")

cmd <- sprintf("unzip -p %s Gravity_V202211.csv | gawk -F, 'NR==1 || %s'",
               zipf, yfilter)

cep <- fread(cmd = cmd, select = cep_cols, showProgress = FALSE)

Three details earn their keep:

  • unzip -p writes the member to stdout, so the 1.3 GB CSV never touches disk.
  • select = drops 57 of the 87 columns before they reach R.
  • The file.exists guard makes the script re-runnable: raw downloads are cached in ../data/, and a second run costs seconds instead of 300 MB.

ITPD-E is never unzipped at all. funzip inflates the stream as it arrives, gawk sums 170 industries into 4 broad sectors on the fly, and what reaches the disk is the finished 17 MB aggregate.

curl -sL 'https://www.usitc.gov/data/gravity/itpd_e/r03/ITPDE_R03.zip' | funzip |
gawk -F, -v OFS=',' 'NR==1 { next } ($3==2000 || $3==2004 || $3==2008 ||
                               $3==2012 || $3==2016) {
    patsplit($0, f, "([^,]*)|(\"[^\"]*\")")     # quote-aware field split
    bs = f[8]; gsub(/"/, "", bs)                # broad_sector, unquoted
    k = f[1] SUBSEP f[2] SUBSEP f[3] SUBSEP bs  # exporter, importer, year, sector
    s[k] += f[4] + 0; c[k] += 1 }
  END { print "exporter_iso3","importer_iso3","year","broad_sector","trade","n_ind"
        for (k in s) { split(k, a, SUBSEP); print a[1],a[2],a[3],a[4],s[k],c[k] } }' \
  > ../data/ctm-itpde-raw.csv

The trap that costs an afternoon. ITPD-E’s industry and country names are quoted and contain commas, so -F, misaligns every field after the sixth — silently, with no error, producing a table of plausible nonsense. patsplit() with the pattern ([^,]*)|("[^"]*") respects the quotes.

Note the two-stage parse: the year filter runs on $3 under plain FS=",", which is safe because fields 1–3 all precede the first quoted field. Cheap test first, expensive correct split only on the rows that survive.

Code
files <- c("ctm-gravity.csv", "ctm-itpde.csv", "ctm-sim.csv", "ctm-sim-truth.csv",
           "ctm-icio.csv", "ctm-firms.csv", "ctm-tariffs.csv")

rows <- integer(length(files))
cols <- integer(length(files))
mb   <- numeric(length(files))

for (i in seq_along(files)) {
  path    <- file.path("../data", files[i])
  d       <- read.csv(path)
  rows[i] <- nrow(d)
  cols[i] <- ncol(d)
  mb[i]   <- round(file.size(path) / 1024^2, 1)
}

tab <- data.frame(file = files, rows = rows, cols = cols, MB = mb)
print(tab, row.names = FALSE)
              file   rows cols   MB
   ctm-gravity.csv 133206   31 18.7
     ctm-itpde.csv  46523   16  2.9
       ctm-sim.csv   3600   16  0.5
 ctm-sim-truth.csv     60   19  0.0
      ctm-icio.csv  10464    7  0.3
     ctm-firms.csv  15586    7  0.6
   ctm-tariffs.csv     60    9  0.0

Two source archives of about 1.3 GB and 982 MB, plus two Eurostat downloads and two WITS queries, reduce to the few MB above. The counts are read off the files themselves, so this table cannot go stale if a vintage changes — it will simply report different numbers, which is the point.

The access patterns are the providers’ own, not inventions of this deck:

  • Conte, Cotterlaz & Mayer (2022), The CEPII Gravity Database, CEPII WP 2022-05 — column dictionary and usage notes. cepii.fr
  • Borchert, Larch, Shikher & Yotov (2021), The International Trade and Production Database for Estimation, International Economics 166 — construction, and why the domestic diagonal exists. 10.1016/j.inteco.2020.08.001
  • Eurostat, FIGARO inter-country supply, use and input–output tables — methodology and the naio_10_fcp_ii4 bulk endpoint. ec.europa.eu/eurostat
  • data.table, ?fread — the cmd = argument and select =. rdatatable.gitlab.io
  • GNU Awk manual, string functions — patsplit() and SUBSEP. gnu.org/software/gawk

Part I — The Gravity Equation

Part I — The Problem & the Data

Two countries. How much do they trade?

The answer that has survived sixty years of testing is that trade rises with economic size and falls with distance:

\[ X_{ij} \;=\; G \cdot \frac{Y_i^{\alpha}\, Y_j^{\beta}}{D_{ij}^{\delta}} \]

Tinbergen wrote that down in 1962 by analogy with Newton, fitted it, and found it worked. It kept working for twenty years before anyone could say why.

That gap matters. An equation that fits but has no structure cannot answer the question we actually care about — what happens to trade if policy changes? The rest of Part I closes the gap, and then breaks the naive estimator that everybody starts with.

../data/ctm-gravity.csv — CEPII Gravity V202211.

  • 150 largest traders, six waves: 2000, 2004, 2008, 2012, 2016, 2020
  • 133 206 rows, of which 132 306 are international pairs
  • goods exports from BACI, in thousand USD
  • distance, contiguity, common language, colonial history, religion
  • GDP, population, GDP per capita, WTO and EU membership, trade agreements
  • and two covariates that did not exist a decade ago — the Facebook Social Connectedness Index and distance in United Nations voting records

Four-year waves rather than annual data: trade costs take time to adjust, and consecutive years of a gravity panel are far from independent observations.

  1. Look at the fact itself, on one scatter plot
  2. Derive it from consumer demand, so it has structure
  3. Discover that structure implies a term nobody can observe
  4. Estimate the naive log-linear regression everybody starts with
  5. Notice that it silently discarded a tenth of the data
  6. Notice that what it did keep, it got wrong

Steps 5 and 6 are the argument for everything in Parts II to IV.

The Gravity Fact

Exports against distance, 2016, all pairs with a positive flow. The line is a Cleveland lowess smoother with span 0.3 — identical in all three tabs, as are the axis limits and the ticks.

Code
library(data.table)
g <- fread("../data/ctm-gravity.csv")
pos <- g[domestic == 0 & year == 2016 & trade > 0 & !is.na(dist)]
pos[, `:=`(ldist = log(dist), ltrade = log(trade))]

lo <- as.data.frame(lowess(pos$ldist, pos$ltrade, f = 0.3, iter = 0))
names(lo) <- c("ldist", "ltrade")

ggplot(pos) +
  aes(x = ldist, y = ltrade) +
  geom_point(colour = "grey55", alpha = 0.15, size = 0.4) +
  geom_line(data = lo, colour = "#D85A30", linewidth = 1.4) +
  coord_cartesian(xlim = c(4, 10), ylim = c(-5, 20), expand = FALSE) +
  scale_x_continuous(breaks = 4:10) +
  scale_y_continuous(breaks = seq(-5, 20, 5)) +
  labs(x = "log distance (km)", y = "log exports (thousand USD)",
       title = "Gravity fact, 2016") +
  theme_lecture

Code
import pandas as pd, numpy as np, matplotlib.pyplot as plt
from statsmodels.nonparametric.smoothers_lowess import lowess

g = pd.read_csv("../data/ctm-gravity.csv")
pos = g[(g.domestic == 0) & (g.year == 2016) & (g.trade > 0) & g.dist.notna()]
ldist, ltrade = np.log(pos.dist.values), np.log(pos.trade.values)

lo = lowess(ltrade, ldist, frac=0.3, it=0)

fig, ax = plt.subplots(figsize=(8, 5))
ax.scatter(ldist, ltrade, color="grey", alpha=0.15, s=2)
ax.plot(lo[:, 0], lo[:, 1], color="#D85A30", linewidth=2.4)
axopts = ax.set(xlim=(4, 10), ylim=(-5, 20), xticks=range(4, 11),
                yticks=range(-5, 21, 5), xlabel="log distance (km)",
                ylabel="log exports (thousand USD)", title="Gravity fact, 2016")
plt.show()

Code
quietly import delimited "../data/ctm-gravity.csv", clear varnames(1)
quietly keep if domestic == 0 & year == 2016 & trade > 0 & dist < .
quietly generate ldist  = ln(dist)
quietly generate ltrade = ln(trade)

twoway (scatter ltrade ldist if ltrade >= -5 & ltrade <= 20,             ///
            msize(vtiny) mcolor("85 85 85%20"))                          ///
       (lowess ltrade ldist, bwidth(0.3) lcolor("216 90 48") lwidth(thick)), ///
       xscale(range(4 10)) yscale(range(-5 20))                          ///
       xlabel(4(1)10) ylabel(-5(5)20)                                    ///
       xtitle("log distance (km)") ytitle("log exports (thousand USD)")  ///
       title("Gravity fact, 2016") legend(off) graphregion(color(white))
graph export "../plots/ctm-p1-fact.png", replace width(1600)

The scatter clips at \(\log X = -5\) and \(\log X = 20\) in every tab. Stata’s yscale(range()) only extends an axis, so its scatter is restricted with an if condition instead — the smoother still runs on the full sample.

The Structural Form

Gravity is not an analogy. It falls out of CES demand with iceberg trade costs, and the shape of what it delivers is what the rest of the deck exploits:

\[ X_{ij} \;=\; \underbrace{(\beta_i p_i)^{1-\sigma}}_{\text{exporter}} \cdot \underbrace{\tau_{ij}^{1-\sigma}}_{\text{bilateral}} \cdot \underbrace{E_j P_j^{\sigma-1}}_{\text{importer}} \]

The structural form is multiplicative and separable. Three consequences run through the whole deck:

  • The exporter and importer terms are fixed effects. Part III never estimates them; it absorbs them.
  • The elasticity \(\sigma - 1\) is the trade elasticity \(\theta\). One number converts an observed trade cost into a trade flow, and Part V is entirely about measuring it.
  • Trade costs enter only through \(\tau_{ij}^{-\theta}\). If we can write \(\log \tau_{ij}\) as a function of distance, borders and language, the coefficients we estimate are \(-\theta\) times those elasticities — never the elasticities themselves.

\[ \log \tau_{ij} \;=\; \delta_d \log D_{ij} + \delta_c\, \text{contig}_{ij} + \delta_l\, \text{lang}_{ij} + \varepsilon_{ij} \]

A country’s exports to Portugal do not depend on the distance to Portugal alone. They depend on how far Portugal is from everybody else.

\[ X_{ij} \;=\; \frac{Y_i\, E_j}{Y}\left(\frac{\tau_{ij}}{\Pi_i P_j}\right)^{-\theta} \]

\[ \Pi_i^{-\theta} = \sum_k \left(\frac{\tau_{ik}}{P_k}\right)^{-\theta}\frac{E_k}{Y}, \qquad P_j^{-\theta} = \sum_k \left(\frac{\tau_{kj}}{\Pi_k}\right)^{-\theta}\frac{Y_k}{Y} \]

\(\Pi_i\) is outward multilateral resistance and \(P_j\) inward. Neither is observed. Both are defined by a system that references the other, so they must be solved for — which is exactly the fixed point Part VI computes.

Part III takes the cheaper route and absorbs them as fixed effects; that is the whole reason its specification looks the way it does.

Naive Gravity — Log-Linear OLS

Take logs of the Tinbergen form and run least squares. This is where nearly every empirical trade paper before 2006 began, and it is the estimator Parts II and IV dismantle.

\[ \log X_{ij} = \alpha + \beta_1 \log Y_i + \beta_2 \log Y_j + \beta_3 \log D_{ij} + \mathbf{z}_{ij}'\boldsymbol{\gamma} + u_{ij} \]

2016 cross-section, positive flows, heteroskedasticity-robust (HC1) standard errors in all three tabs.

Code
library(fixest)
e <- g[domestic == 0 & year == 2016 & trade > 0 &
       !is.na(gdp_o) & !is.na(gdp_d) & !is.na(dist)]
e[, `:=`(lgdp_o = log(gdp_o), lgdp_d = log(gdp_d), ldist = log(dist))]

fit <- feols(log(trade) ~ lgdp_o + lgdp_d + ldist + contig + comlang_off + comcol,
             data = e, vcov = "hetero")
summary(fit)

# the identical specification by Poisson PML, on the same year, keeping the
# pairs that log() dropped
gp <- g[domestic == 0 & year == 2016 & !is.na(gdp_o) & !is.na(gdp_d) & !is.na(dist)]
gp[, `:=`(lgdp_o = log(gdp_o), lgdp_d = log(gdp_d), ldist = log(dist))]

ppml <- fepois(trade ~ lgdp_o + lgdp_d + ldist + contig + comlang_off + comcol,
               data = gp, vcov = "hetero")

cat(sprintf("Distance elasticity   log-OLS %.3f   PPML %.3f   (obs %d vs %d)\n",
            coef(fit)["ldist"], coef(ppml)["ldist"], fit$nobs, ppml$nobs))
OLS estimation, Dep. Var.: log(trade)
Observations: 17,107
Standard-errors: Heteroskedasticity-robust 
              Estimate Std. Error   t value  Pr(>|t|)    
(Intercept) -24.708252   0.314156 -78.64953 < 2.2e-16 ***
lgdp_o        1.362012   0.009374 145.29931 < 2.2e-16 ***
lgdp_d        1.085387   0.009276 117.01463 < 2.2e-16 ***
ldist        -1.319152   0.021179 -62.28458 < 2.2e-16 ***
contig        1.011268   0.113007   8.94873 < 2.2e-16 ***
comlang_off   0.700221   0.056734  12.34212 < 2.2e-16 ***
comcol        0.600086   0.080187   7.48355 7.585e-14 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
RMSE: 2.34331   Adj. R2: 0.65674

Distance elasticity   log-OLS -1.319   PPML -0.648   (obs 17107 vs 18632)
Code
import pandas as pd, numpy as np, statsmodels.api as sm, pyfixest as pf

g = pd.read_csv("../data/ctm-gravity.csv")
e = g[(g.domestic == 0) & (g.year == 2016) & (g.trade > 0)].dropna(
        subset=["gdp_o", "gdp_d", "dist"])

X = pd.DataFrame({"lgdp_o": np.log(e.gdp_o.values), "lgdp_d": np.log(e.gdp_d.values),
                  "ldist": np.log(e.dist.values), "contig": e.contig.values,
                  "comlang_off": e.comlang_off.values, "comcol": e.comcol.values})
fit = sm.OLS(np.log(e.trade.values), sm.add_constant(X)).fit(cov_type="HC1")

# the identical specification by Poisson PML, keeping the pairs log() dropped
gp = g[(g.domestic == 0) & (g.year == 2016)].dropna(subset=["gdp_o", "gdp_d", "dist"]).copy()
gp = gp.assign(lgdp_o=np.log(gp.gdp_o), lgdp_d=np.log(gp.gdp_d), ldist=np.log(gp.dist))
ppml = pf.fepois("trade ~ lgdp_o + lgdp_d + ldist + contig + comlang_off + comcol",
                 data=gp, vcov="hetero")

out = fit.summary().as_text() + (
      "\n\nDistance elasticity   log-OLS %.3f   PPML %.3f   (obs %d vs %d)"
      % (fit.params["ldist"], ppml.coef()["ldist"], int(fit.nobs), gp.shape[0]))
import sys
nbytes = sys.stdout.write(out + "\n")
                            OLS Regression Results                            
==============================================================================
Dep. Variable:                      y   R-squared:                       0.657
Model:                            OLS   Adj. R-squared:                  0.657
Method:                 Least Squares   F-statistic:                     5805.
Date:                Κυρ, 02 Αυγ 2026   Prob (F-statistic):               0.00
Time:                        18:02:40   Log-Likelihood:                -38842.
No. Observations:               17107   AIC:                         7.770e+04
Df Residuals:                   17100   BIC:                         7.775e+04
Df Model:                           6                                         
Covariance Type:                  HC1                                         
===============================================================================
                  coef    std err          z      P>|z|      [0.025      0.975]
-------------------------------------------------------------------------------
const         -24.7083      0.314    -78.650      0.000     -25.324     -24.093
lgdp_o          1.3620      0.009    145.299      0.000       1.344       1.380
lgdp_d          1.0854      0.009    117.015      0.000       1.067       1.104
ldist          -1.3192      0.021    -62.285      0.000      -1.361      -1.278
contig          1.0113      0.113      8.949      0.000       0.790       1.233
comlang_off     0.7002      0.057     12.342      0.000       0.589       0.811
comcol          0.6001      0.080      7.484      0.000       0.443       0.757
==============================================================================
Omnibus:                     2816.366   Durbin-Watson:                   1.550
Prob(Omnibus):                  0.000   Jarque-Bera (JB):             6945.221
Skew:                          -0.926   Prob(JB):                         0.00
Kurtosis:                       5.512   Cond. No.                         509.
==============================================================================

Notes:
[1] Standard Errors are heteroscedasticity robust (HC1)

Distance elasticity   log-OLS -1.319   PPML -0.648   (obs 17107 vs 18632)
Code
sys.stdout.flush()
Code
quietly import delimited "../data/ctm-gravity.csv", clear varnames(1)
quietly keep if domestic == 0 & year == 2016 & trade > 0 & ///
                dist < . & gdp_o < . & gdp_d < .
quietly generate ltrade = ln(trade)
quietly generate lgdp_o = ln(gdp_o)
quietly generate lgdp_d = ln(gdp_d)
quietly generate ldist  = ln(dist)

regress ltrade lgdp_o lgdp_d ldist contig comlang_off comcol, robust
scalar b_ols = _b[ldist]
scalar n_ols = e(N)

* the identical specification by Poisson PML, keeping the pairs log() dropped
quietly import delimited "../data/ctm-gravity.csv", clear varnames(1)
quietly keep if domestic == 0 & year == 2016 & dist < . & gdp_o < . & gdp_d < .
quietly generate lgdp_o = ln(gdp_o)
quietly generate lgdp_d = ln(gdp_d)
quietly generate ldist  = ln(dist)

quietly ppmlhdfe trade lgdp_o lgdp_d ldist contig comlang_off comcol, ///
        noabsorb vce(robust)
display "Distance elasticity   log-OLS " %6.3f b_ols "   PPML " %6.3f _b[ldist] ///
        "   (obs " %6.0f n_ols " vs " %6.0f e(N) ")"
Linear regression                               Number of obs     =     17,107
                                                F(6, 17100)       =    5805.25
                                                Prob > F          =     0.0000
                                                R-squared         =     0.6569
                                                Root MSE          =     2.3438

------------------------------------------------------------------------------
             |               Robust
      ltrade | Coefficient  std. err.      t    P>|t|     [95% conf. interval]
-------------+----------------------------------------------------------------
      lgdp_o |   1.362012   .0093738   145.30   0.000     1.343639    1.380386
      lgdp_d |   1.085387   .0092757   117.01   0.000     1.067206    1.103568
       ldist |  -1.319152   .0211794   -62.28   0.000    -1.360666   -1.277638
      contig |   1.011268   .1130069     8.95   0.000     .7897631    1.232773
 comlang_off |   .7002208   .0567342    12.34   0.000     .5890159    .8114257
      comcol |   .6000863   .0801874     7.48   0.000     .4429108    .7572617
       _cons |  -24.70825   .3141564   -78.65   0.000    -25.32403   -24.09247
------------------------------------------------------------------------------









Distance elasticity   log-OLS -1.319   PPML -0.648   (obs  17107 vs  18632)

Size elasticities near one, a distance elasticity near \(-1.3\), contiguous neighbours trading \(e^{1.01} \approx 2.7\) times more. The fit looks excellent and the three languages agree to six decimals.

Then the last line of each tab runs the same specification by Poisson PML and gets a distance elasticity roughly half the size, on more data. One of those two numbers is wrong, and the fit statistics cannot tell you which.

Two things are wrong with the log-linear estimate, and they are independent.

The Zeros Problem

log(0) is undefined, so every pair that did not trade was dropped before the regression ran — and they were not dropped at random.

Code
o <- g[domestic == 0]

by_year <- o[, .(pairs = .N, zeros = sum(trade == 0),
                 pct = round(100 * mean(trade == 0), 1)), by = year][order(year)]

s <- o[year == 2016 & !is.na(gdp_d)]
s[, gq := cut(log(gdp_d), quantile(log(gdp_d), seq(0, 1, 0.2)),
              include.lowest = TRUE, labels = 1:5)]
by_size <- s[, .(pairs = .N, pct = round(100 * mean(trade == 0), 1)), by = gq][order(gq)]

print(by_year, row.names = FALSE)
print(by_size, row.names = FALSE)
Zero flows by wave
  year pairs zeros   pct
 <int> <int> <int> <num>
  2000 22052  5159  23.4
  2004 22052  3630  16.5
  2008 22052  2960  13.4
  2012 22052  2611  11.8
  2016 22052  2404  10.9
  2020 22052  3234  14.7

2016, by importer GDP quintile (1 = smallest)
     gq pairs   pct
 <fctr> <int> <num>
      1  4144  17.7
      2  3996  14.7
      3  4144   8.4
      4  3996   4.5
      5  3996   1.1
Code
o = g[g.domestic == 0].copy()
o["zero"] = (o.trade == 0).astype(int)

by_year = o.groupby("year").agg(pairs=("zero", "size"), zeros=("zero", "sum"),
                                pct=("zero", lambda v: round(100 * v.mean(), 1)))

s = o[(o.year == 2016) & o.gdp_d.notna()].copy()
s["gq"] = pd.qcut(np.log(s.gdp_d), 5, labels=[1, 2, 3, 4, 5])
by_size = s.groupby("gq", observed=True).agg(
    pairs=("zero", "size"), pct=("zero", lambda v: round(100 * v.mean(), 1)))

out = ("Zero flows by wave\n" + by_year.to_string() +
       "\n\n2016, by importer GDP quintile (1 = smallest)\n" + by_size.to_string())
import sys
nbytes = sys.stdout.write(out + "\n")
Zero flows by wave
      pairs  zeros   pct
year                    
2000  22052   5159  23.4
2004  22052   3630  16.5
2008  22052   2960  13.4
2012  22052   2611  11.8
2016  22052   2404  10.9
2020  22052   3234  14.7

2016, by importer GDP quintile (1 = smallest)
    pairs   pct
gq             
1    4144  17.7
2    3996  14.7
3    4144   8.4
4    3996   4.5
5    3996   1.1
Code
sys.stdout.flush()
Code
quietly import delimited "../data/ctm-gravity.csv", clear varnames(1)
quietly keep if domestic == 0
quietly generate byte zero = (trade == 0)

display "Zero flows by wave"
tabstat zero, by(year) statistics(n sum mean) format(%9.3f)

quietly keep if year == 2016 & gdp_d < .
quietly generate lgdp_d = ln(gdp_d)
quietly xtile gq = lgdp_d, nq(5)

display "2016, by importer GDP quintile (1 = smallest)"
tabstat zero, by(gq) statistics(n mean) format(%9.3f)
Zero flows by wave


Summary for variables: zero
Group variable: year 

    year |         N       Sum      Mean
---------+------------------------------
    2000 | 22052.000  5159.000     0.234
    2004 | 22052.000  3630.000     0.165
    2008 | 22052.000  2960.000     0.134
    2012 | 22052.000  2611.000     0.118
    2016 | 22052.000  2404.000     0.109
    2020 | 22052.000  3234.000     0.147
---------+------------------------------
   Total |  1.32e+05 19998.000     0.151
----------------------------------------




2016, by importer GDP quintile (1 = smallest)


Summary for variables: zero
Group variable: gq (5 quantiles of lgdp_d)

      gq |         N      Mean
---------+--------------------
       1 |  4144.000     0.177
       2 |  3996.000     0.147
       3 |  4144.000     0.084
       4 |  3996.000     0.045
       5 |  3996.000     0.011
---------+--------------------
   Total | 20276.000     0.093
------------------------------

The zero share falls from 23.4% in 2000 to 10.9% in 2016, then rises again to 14.7% in 2020. But the decisive cut is the second table: 17.7% of flows into the smallest importers are zero against 1.1% into the largest.

Dropping zeros therefore does not thin the sample evenly — it removes small economies, which is precisely the part of the world a trade-policy question is usually about.

That is only the first defect, and the second has nothing to do with zeros. The model says the level of trade is multiplicative with a mean-one error, and logs do not pass through an expectation:

\[ \log \mathbb{E}\!\left[\eta_{ij}\right] = 0 \quad\text{but}\quad \mathbb{E}\!\left[\log \eta_{ij}\right] \neq 0 \]

That second term is harmless only if it is constant. It is not: when the dispersion of \(\eta_{ij}\) varies with the regressors — and trade with small, distant partners is far noisier than trade with large, close ones — it moves with them and lands straight in \(\hat{\boldsymbol\beta}\). It would bias log-OLS even if every pair traded.

Part II settles which of the two distance elasticities to believe by building a world where the answer is known in advance.

Part I in review

  • Gravity is not an analogy. It falls out of CES demand with iceberg trade costs, and the structural form is multiplicative and separable.
  • Separability means the exporter and importer terms are fixed effects, and the only genuinely bilateral object is \(\tau_{ij}^{-\theta}\).
  • Estimated trade-cost coefficients are always \(-\theta\) times an elasticity, never the elasticity itself.
  • Multilateral resistance is real, unobserved, and defined by a fixed point. Ignoring it produced a 22-fold Canadian border effect.
  • Log-linear OLS has two independent defects: it discards zeros non-randomly, and Jensen’s inequality biases what remains.
Task R Python Stata
Read the panel data.table::fread pandas.read_csv import delimited
Log-linear OLS fixest::feols statsmodels.OLS regress
Robust (HC1) SEs vcov = "hetero" cov_type="HC1" , robust
Poisson PML fixest::fepois pyfixest.fepois ppmlhdfe
Lowess smoother stats::lowess statsmodels … lowess lowess
Quintiles cut(quantile(...)) pandas.qcut xtile
  • Match iter = 0 in R and it = 0 in Python to Stata’s single-pass lowess, or the three curves will not coincide.
  • Never let log() do your sample selection silently. Count the zeros first, and report how many observations the log specification dropped.
  • HC1 is the common denominator across all three languages. Two-way clustering by exporter and importer is the right answer for a gravity panel, and Part III does it — but regress cannot, so it needs reghdfe or ppmlhdfe.
  • CEPII lists countries that did not exist in a given year. Filter on country_exists_o and country_exists_d or a tenth of the panel arrives with missing distance and GDP for countries that plainly have both.

Part II — A Known-Truth Laboratory

Why Simulate When We Have Data?

Part I ended with two numbers for the same object:

\[ \hat\beta_{\text{dist}}^{\;\text{OLS}} = -1.319, \qquad \hat\beta_{\text{dist}}^{\;\text{PPML}} = -0.648 \]

Real data cannot tell us which is right, because the true distance elasticity of world trade is not written down anywhere. Every argument from real data is an argument about plausibility.

So we build a world where the answer is known by construction, run both estimators on it, and see which one finds it again.

Not a toy. The simulated world uses:

  • 60 real countries — the largest traders with complete CEPII coverage
  • their real bilateral distances, contiguity and common language
  • their real 2016 population and GDP per capita as labour and productivity
  • an equilibrium solved until every market clears to within \(2\times10^{-13}\)

What is invented is only what has to be: the trade elasticity \(\theta = 4\), the trade-cost elasticities, and the noise. Everything the estimator sees looks like the data from Part I. Everything it is trying to recover, we chose.

  1. That the DGP reproduces the awkward features of real trade data — zeros, extreme skew, and variance that grows faster than the mean
  2. That log-OLS misses the truth, in a direction and by an amount we can measure
  3. That PPML does not
  4. That this is a statement about the estimator, not about one lucky draw — which needs a Monte Carlo, not a single regression

DGP — Mathematical Specification

Armington: each of \(N = 60\) countries produces one variety, labour is the only factor, and the unit cost is \(c_i = w_i / A_i\). Expenditure shares are CES:

\[ \pi_{ij} \;=\; \frac{\big(c_i \tau_{ij}\big)^{-\theta}} {\sum_{k}\big(c_k \tau_{kj}\big)^{-\theta}}, \qquad \theta = 4 \]

Wages clear the market for each country’s good, with balanced trade:

\[ w_i L_i \;=\; \sum_{j} \pi_{ij}\, w_j L_j \]

That is \(N\) equations in \(N\) wages, one of them redundant by Walras’ law. The level is fixed by \(\sum_i w_i L_i = 1\).

\[ \log \tau_{ij} \;=\; \log \tau_0 + \delta_d \log \frac{D_{ij}}{D_{\min}} + \delta_c\, \text{contig}_{ij} + \delta_l\, \text{lang}_{ij} + \varepsilon_{ij} \]

with \(\tau_0 = 1.55\), \(\delta_d = 0.25\), \(\delta_c = -0.12\), \(\delta_l = -0.09\), \(\tau_{ii} = 1\), and \(\varepsilon_{ij} = \varepsilon_{ji} \sim N(0, 0.15^2)\) — the unobserved part of trade costs, which is the gravity error term.

Because the model is separable, the coefficients a regression can recover are \(-\theta\) times these elasticities:

\[ \beta_{\text{dist}} = -1.00, \qquad \beta_{\text{contig}} = 0.48, \qquad \beta_{\text{lang}} = 0.36 \]

The model gives a conditional mean. What we observe is a draw around it:

\[ \nu_{ij} \sim \text{Gamma}\!\left(\tfrac{1}{\sigma^2_{ij}}, \tfrac{1}{\sigma^2_{ij}}\right), \qquad \mathbb{E}[\nu_{ij}] = 1, \quad \text{Var}(\nu_{ij}) = \sigma^2_{ij} \]

\[ X_{ij} \sim \text{Poisson}\!\left(\lambda_{ij}\, \nu_{ij}\right), \qquad \sigma^2_{ij} = \left(\frac{D_{ij}}{\text{med}(D)}\right)^{1.5} \]

Two things are deliberate. The mean is correct\(\mathbb{E}[X_{ij}] = \lambda_{ij}\) — so any estimator that models the mean is consistent. And the variance grows with distance, so \(\mathbb{E}[\log \nu_{ij}]\) moves with a regressor. That is the Jensen mechanism of Part I, now switched on deliberately.

Poisson sampling also produces the zeros, without any separate selection rule.

DGP — Code Implementation

The world was drawn once, from seed 14159. Every tab reads the same two files and checks that they are internally consistent.

Code
s  <- fread("../data/ctm-sim.csv")       # 60 x 60 flows and the truth behind them
tr <- fread("../data/ctm-sim-truth.csv") # per-country truth + the DGP parameters

o <- s[domestic == 0][, ldist := log(dist)]

# the equilibrium condition, checked rather than assumed:
#   w_i L_i  ==  sum_j pi_ij w_j L_j
w <- setNames(tr$w, tr$iso3); L <- setNames(tr$L, tr$iso3); E <- setNames(tr$E, tr$iso3)
sales <- s[, .(sales = sum(pi_true * E[importer])), by = exporter]
gap   <- max(abs(sales$sales - (w * L)[sales$exporter]))

cat(sprintf("market-clearing gap  %.2e\n", gap))
cat(sprintf("theta %.0f   beta_dist %.2f   beta_contig %.2f   beta_lang %.2f\n",
            tr$theta[1], -tr$theta[1] * tr$delta_dist[1],
            -tr$theta[1] * tr$delta_contig[1], -tr$theta[1] * tr$delta_lang[1]))
countries 60   international pairs 3540   zeros 159 (4.5%)
market-clearing gap  1.75e-13   median domestic share  0.811
theta 4   beta_dist -1.00   beta_contig 0.48   beta_lang 0.36
Code
import pandas as pd, numpy as np

s  = pd.read_csv("../data/ctm-sim.csv")
tr = pd.read_csv("../data/ctm-sim-truth.csv")
o  = s[s.domestic == 0].copy()
o["ldist"] = np.log(o.dist)

E = dict(zip(tr.iso3, tr.E))
wL = dict(zip(tr.iso3, tr.w * tr.L))
sales = s.assign(v=s.pi_true * s.importer.map(E)).groupby("exporter").v.sum()
gap = float(np.max(np.abs(sales - sales.index.map(wL))))

th = tr.theta[0]
out = ("countries %d   international pairs %d   zeros %d (%.1f%%)\n"
       % (len(tr), len(o), int((o.trade == 0).sum()), 100 * (o.trade == 0).mean())
     + "market-clearing gap  %.2e   median domestic share  %.3f\n"
       % (gap, s[s.domestic == 1].pi_true.median())
     + "theta %.0f   beta_dist %.2f   beta_contig %.2f   beta_lang %.2f"
       % (th, -th * tr.delta_dist[0], -th * tr.delta_contig[0], -th * tr.delta_lang[0]))
import sys
nbytes = sys.stdout.write(out + "\n")
countries 60   international pairs 3540   zeros 159 (4.5%)
market-clearing gap  1.75e-13   median domestic share  0.811
theta 4   beta_dist -1.00   beta_contig 0.48   beta_lang 0.36
Code
sys.stdout.flush()
Code
quietly import delimited "../data/ctm-sim-truth.csv", clear varnames(1)
scalar th = theta[1]
scalar bd = -theta[1]*delta_dist[1]
scalar bc = -theta[1]*delta_contig[1]
scalar bl = -theta[1]*delta_lang[1]
quietly count
scalar ncty = r(N)

quietly keep iso3 e w l
quietly rename iso3 importer
quietly rename e ej
tempfile trth
quietly save `trth'

quietly import delimited "../data/ctm-sim.csv", clear varnames(1)
quietly count if domestic == 0
scalar npair = r(N)
quietly count if domestic == 0 & trade == 0
scalar nzero = r(N)
quietly summarize pi_true if domestic == 1, detail
scalar pmed = r(p50)

* the equilibrium condition, checked rather than assumed
quietly merge m:1 importer using `trth', keep(match) nogenerate
quietly generate double sales = pi_true*ej
quietly collapse (sum) sales, by(exporter)
quietly rename exporter importer
quietly merge 1:1 importer using `trth', keep(match) nogenerate
quietly generate double gap = abs(sales - w*l)
quietly summarize gap, meanonly
scalar mgap = r(max)

display "countries " ncty "   international pairs " npair ///
        "   zeros " nzero " (" %4.1f 100*nzero/npair "%)"
display "market-clearing gap  " %8.2e mgap "   median domestic share  " %5.3f pmed
display "theta " %2.0f th "   beta_dist " %5.2f bd ///
        "   beta_contig " %5.2f bc "   beta_lang " %5.2f bl
countries 60   international pairs 3540   zeros 159 ( 4.5%)

market-clearing gap   1.8e-13   median domestic share  0.811

theta  4   beta_dist -1.00   beta_contig  0.48   beta_lang  0.36

DGP Diagnostics — Does It Look Like Trade Data?

Twenty bins of pairs ordered by their model mean \(\lambda_{ij}\). If the data were Poisson the cloud would lie on the slope-1 line; if Gamma, on slope 2.

Code
o[, bin := cut(lambda_true, quantile(lambda_true, seq(0, 1, length.out = 21)),
               include.lowest = TRUE, labels = FALSE)]
b <- o[, .(m = mean(trade), v = var(trade)), by = bin]
b[, `:=`(lm = log(m), lv = log(v))]

ref <- data.frame(lm = c(2.5, 9), lv1 = c(2.5, 9) + 4.5, lv2 = 2 * c(2.5, 9) - 0.5)

ggplot(b) +
  aes(x = lm, y = lv) +
  geom_abline(intercept = 4.5, slope = 1, colour = "#1D9E75", linewidth = 1) +
  geom_abline(intercept = -0.5, slope = 2, colour = "#BA7517", linewidth = 1) +
  geom_point(colour = "#185FA5", size = 2.6) +
  coord_cartesian(xlim = c(2.5, 9), ylim = c(6, 18), expand = FALSE) +
  scale_x_continuous(breaks = 3:9) +
  scale_y_continuous(breaks = seq(6, 18, 2)) +
  labs(x = "log mean trade in bin", y = "log variance of trade in bin",
       title = "Variance grows faster than the mean") +
  theme_lecture

Code
import matplotlib.pyplot as plt

o["bin"] = pd.qcut(o.lambda_true, 20, labels=False)
b = o.groupby("bin").agg(m=("trade", "mean"), v=("trade", "var"))
lm, lv = np.log(b.m.values), np.log(b.v.values)
slope = np.polyfit(lm, lv, 1)[0]

x = np.array([2.5, 9.0])
fig, ax = plt.subplots(figsize=(8, 5))
ax.plot(x, x + 4.5, color="#1D9E75", linewidth=1.6)
ax.plot(x, 2 * x - 0.5, color="#BA7517", linewidth=1.6)
ax.scatter(lm, lv, color="#185FA5", s=28)
ax.text(0.03, 0.92, "fitted slope = %.3f" % slope, transform=ax.transAxes, fontsize=11)
ax.text(0.62, 0.30, "Poisson: slope 1", color="#1D9E75", transform=ax.transAxes, fontsize=10)
ax.text(0.34, 0.80, "Gamma: slope 2", color="#BA7517", transform=ax.transAxes, fontsize=10)
axopts = ax.set(xlim=(2.5, 9), ylim=(6, 18), xticks=range(3, 10),
                yticks=range(6, 19, 2), xlabel="log mean trade in bin",
                ylabel="log variance of trade in bin",
                title="Variance grows faster than the mean")
plt.show()

Code
quietly import delimited "../data/ctm-sim.csv", clear varnames(1)
quietly keep if domestic == 0
quietly xtile bin = lambda_true, nq(20)
quietly collapse (mean) m = trade (sd) sd = trade, by(bin)
quietly generate lm = ln(m)
quietly generate lv = ln(sd^2)
quietly generate ref1 = lm + 4.5
quietly generate ref2 = 2*lm - 0.5

twoway (line ref1 lm, sort lcolor("29 158 117") lwidth(medium))          ///
       (line ref2 lm, sort lcolor("186 117 23") lwidth(medium))          ///
       (scatter lv lm, mcolor("24 95 165") msize(medium)),               ///
       xscale(range(2.5 9)) yscale(range(6 18))                          ///
       xlabel(3(1)9) ylabel(6(2)18)                                      ///
       xtitle("log mean trade in bin") ytitle("log variance of trade in bin") ///
       title("Variance grows faster than the mean")                      ///
       legend(off) graphregion(color(white))
graph export "../plots/ctm-p2-diag.png", replace width(1600)

Regressing log variance on log mean across the bins gives a slope of 1.704 — between Poisson and Gamma, and comfortably above 1. The simulated world has the same awkward shape as the real one: 10.29 skewness in levels, 4.5% exact zeros, and a variance-to-mean ratio that climbs from 95 in the smallest bin to 4 466 in the largest.

Estimation — Poisson Pseudo-Maximum Likelihood

PPML on all 3 540 international pairs, exporter and importer fixed effects, standard errors clustered on the exporter.

Note what PPML is not doing here. Trade flows are continuous, not counts — Poisson is used for its multiplicative mean, and the likelihood is a pseudo-likelihood. The companion deck Duration, Survival and Count Models treats Poisson as a genuine count model, where the variance assumption is a claim about the data and overdispersion has to be tested rather than assumed away.

Code
ppml <- fepois(trade ~ ldist + contig + comlang | exporter + importer,
               data = o, vcov = ~exporter)
summary(ppml)
Poisson estimation, Dep. Var.: trade
Observations: 3,540
Fixed-effects: exporter: 60,  importer: 60
Standard-errors: Clustered (exporter) 
         Estimate Std. Error   z value   Pr(>|z|)    
ldist   -0.974044   0.023549 -41.36329  < 2.2e-16 ***
contig   0.497201   0.088229   5.63533 1.7473e-08 ***
comlang  0.328938   0.114519   2.87235 4.0743e-03 ** 
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
Log-Likelihood:  -593,297.3   Adj. Pseudo R2: 0.747106
           BIC: 1,187,591.5     Squared Cor.: 0.757543
Code
import pyfixest as pf

ppml = pf.fepois("trade ~ ldist + contig + comlang | exporter + importer",
                 data=o, vcov={"CRV1": "exporter"})

out = ppml.tidy().round(4).to_string()
import sys
nbytes = sys.stdout.write(out + "\n")
             Estimate  Std. Error  t value  Pr(>|t|)    2.5%   97.5%
Coefficient                                                         
ldist         -0.9740      0.0235 -41.3633    0.0000 -1.0202 -0.9279
contig         0.4972      0.0882   5.6353    0.0000  0.3243  0.6701
comlang        0.3289      0.1145   2.8723    0.0041  0.1045  0.5534
Code
sys.stdout.flush()
Code
quietly import delimited "../data/ctm-sim.csv", clear varnames(1)
quietly keep if domestic == 0
quietly generate ldist = ln(dist)
quietly egen expid = group(exporter)
quietly egen impid = group(importer)

ppmlhdfe trade ldist contig comlang, absorb(expid impid) cluster(expid)
Iteration 1:   deviance = 1.3506e+06  eps = .         iters = 4    tol = 1.0e-04  min(eta) =  -4.00  P   
Iteration 2:   deviance = 1.1708e+06  eps = 1.54e-01  iters = 5    tol = 1.0e-04  min(eta) =  -4.94      
Iteration 3:   deviance = 1.1642e+06  eps = 5.67e-03  iters = 4    tol = 1.0e-04  min(eta) =  -5.38      
Iteration 4:   deviance = 1.1641e+06  eps = 4.59e-05  iters = 4    tol = 1.0e-04  min(eta) =  -5.45      
Iteration 5:   deviance = 1.1641e+06  eps = 4.62e-08  iters = 3    tol = 1.0e-05  min(eta) =  -5.46      
Iteration 6:   deviance = 1.1641e+06  eps = 9.91e-14  iters = 2    tol = 1.0e-06  min(eta) =  -5.46   S  
Iteration 7:   deviance = 1.1641e+06  eps = 1.43e-16  iters = 4    tol = 1.0e-09  min(eta) =  -5.46   S O
------------------------------------------------------------------------------------------------------------
(legend: p: exact partial-out   s: exact solver   h: step-halving   o: epsilon below tolerance)
Converged in 7 iterations and 26 HDFE sub-iterations (tol = 1.0e-08)

HDFE PPML regression                              No. of obs      =      3,540
Absorbing 2 HDFE groups                           Residual df     =         59
Statistics robust to heteroskedasticity           Wald chi2(3)    =    2432.57
Deviance             =  1164103.947               Prob > chi2     =     0.0000
Log pseudolikelihood = -593297.2851               Pseudo R2       =     0.7472

Number of clusters (expid)  =         60
                                 (Std. err. adjusted for 60 clusters in expid)
------------------------------------------------------------------------------
             |               Robust
       trade | Coefficient  std. err.      z    P>|z|     [95% conf. interval]
-------------+----------------------------------------------------------------
       ldist |  -.9740444   .0233413   -41.73   0.000    -1.019793   -.9282962
      contig |   .4972006    .087453     5.69   0.000     .3257959    .6686053
     comlang |   .3289375   .1135112     2.90   0.004     .1064598    .5514153
       _cons |   14.35735   .1880499    76.35   0.000     13.98877    14.72592
------------------------------------------------------------------------------

Absorbed degrees of freedom:
-----------------------------------------------------+
 Absorbed FE | Categories  - Redundant  = Num. Coefs |
-------------+---------------------------------------|
       expid |        60          60           0    *|
       impid |        60           1          59     |
-----------------------------------------------------+
* = FE nested within cluster; treated as redundant for DoF computation

Results Table — Who Recovers the Truth?

    term truth    PPML log_OLS Gamma_PML err_PPML err_OLS err_Gamma
   ldist -1.00 -0.9740 -1.2968   -1.0093   0.0260 -0.2968   -0.0093
  contig  0.48  0.4972  0.2748    0.4113   0.0172 -0.2052   -0.0687
 comlang  0.36  0.3289  0.3430    0.3120  -0.0311 -0.0170   -0.0480

observations used:  PPML 3540    log-OLS 3381    Gamma PML 3381

Log-OLS misses the distance elasticity by \(-0.297\) — it says trade falls with distance a third faster than it truly does. Gamma PML, which fixes Jensen but still cannot take the log of zero, lands within 0.010.

Note what this single draw does not show. Gamma PML is closer than PPML on distance here (\(-0.009\) against \(+0.026\)), and PPML is closer on contiguity and language. One cross-section cannot rank two consistent estimators — the differences between them are sampling noise, while the gap to log-OLS is not. Separating those two things is what the next slide is for.

\begin{tabular}{lrrrr}
\hline\hline
                & Truth  & PPML    & log-OLS & Gamma PML \\
\hline
$\log$ distance & $-1.00$ & $-0.9740$ & $-1.2968$ & $-1.0093$ \\
                &         & $(0.0235)$ & $(0.0347)$ & $(0.0328)$ \\
Contiguity      & $0.48$  & $0.4972$  & $0.2748$  & $0.4113$ \\
                &         & $(0.0882)$ & $(0.0890)$ & $(0.0789)$ \\
Common language & $0.36$  & $0.3289$  & $0.3430$  & $0.3120$ \\
                &         & $(0.1145)$ & $(0.0914)$ & $(0.1071)$ \\
\hline
Observations    &         & 3540      & 3381      & 3381 \\
Exporter FE     &         & Yes       & Yes       & Yes \\
Importer FE     &         & Yes       & Yes       & Yes \\
\hline\hline
\end{tabular}
Code
ols <- feols(log(trade) ~ ldist + contig + comlang | exporter + importer,
             data = o[trade > 0], vcov = ~exporter)
gam <- feglm(trade ~ ldist + contig + comlang | exporter + importer,
             data = o[trade > 0], family = Gamma(link = "log"), vcov = ~exporter)

etable(ppml, ols, gam, headers = c("PPML", "log-OLS", "Gamma PML"))
Code
import statsmodels.api as sm

pos = o[o.trade > 0]
ols = pf.feols("np.log(trade) ~ ldist + contig + comlang | exporter + importer",
               data=pos, vcov={"CRV1": "exporter"})

# Gamma PML: pyfixest has no Gamma family, so the fixed effects are dummies
Xg = pd.get_dummies(pos[["exporter", "importer"]], drop_first=True, dtype=float)
Xg = sm.add_constant(pd.concat(
    [pos[["ldist", "contig", "comlang"]].reset_index(drop=True),
     Xg.reset_index(drop=True)], axis=1))
gam = sm.GLM(pos.trade.values, Xg,
             family=sm.families.Gamma(link=sm.families.links.Log())).fit(
             cov_type="cluster", cov_kwds={"groups": pos.exporter.values})

tru = {"ldist": -1.00, "contig": 0.48, "comlang": 0.36}
rows = []
for k, v in tru.items():
    rows.append([k, v, round(ppml.coef()[k], 4), round(ols.coef()[k], 4),
                 round(gam.params[k], 4)])
tab = pd.DataFrame(rows, columns=["term", "truth", "PPML", "log_OLS", "Gamma_PML"])

out = (tab.to_string(index=False) +
       "\n\nobservations used:  PPML %d    log-OLS %d    Gamma PML %d"
       % (len(o), len(pos), len(pos)))
import sys
nbytes = sys.stdout.write(out + "\n")
   term  truth    PPML  log_OLS  Gamma_PML
  ldist  -1.00 -0.9740  -1.2968    -1.0093
 contig   0.48  0.4972   0.2748     0.4113
comlang   0.36  0.3289   0.3430     0.3120

observations used:  PPML 3540    log-OLS 3381    Gamma PML 3381
Code
sys.stdout.flush()
Code
quietly import delimited "../data/ctm-sim.csv", clear varnames(1)
quietly keep if domestic == 0
quietly generate ldist  = ln(dist)
quietly generate ltrade = ln(trade) if trade > 0
quietly egen expid = group(exporter)
quietly egen impid = group(importer)

quietly ppmlhdfe trade ldist contig comlang, absorb(expid impid) cluster(expid)
eststo ppml
quietly regress ltrade ldist contig comlang i.expid i.impid, vce(cluster expid)
eststo ols
quietly glm trade ldist contig comlang i.expid i.impid if trade > 0, ///
        family(gamma) link(log) vce(cluster expid) nolog
eststo gam

esttab ppml ols gam, keep(ldist contig comlang) se b(4) se(4) ///
       mtitles("PPML" "log-OLS" "Gamma PML") nostar nonote nonumbers
                     PPML      log-OLS    Gamma PML
---------------------------------------------------
main                                               
ldist             -0.9740      -1.2968      -1.0093
                 (0.0233)     (0.0350)     (0.0352)

contig             0.4972       0.2748       0.4113
                 (0.0875)     (0.0898)     (0.0825)

comlang            0.3289       0.3430       0.3120
                 (0.1135)     (0.0922)     (0.1118)
---------------------------------------------------
N                    3540         3381         3381
---------------------------------------------------

Monte Carlo — Is It the Estimator or the Draw?

One regression cannot separate bias from luck. So hold the design fixed — the same 60 countries, the same distances — and redraw both the unobserved trade-cost shock \(\varepsilon_{ij}\) and the observation noise, 200 times.

Code
th <- tr$theta[1]; st <- tr$sigma_tau[1]
# recover the realised cost shock so it can be replaced by a fresh one
o[, eps0 := log(tau_true) - log(tr$tau0[1]) - tr$delta_dist[1] * log(dist / min(dist)) -
            tr$delta_contig[1] * contig - tr$delta_lang[1] * comlang]

set.seed(14159)
B <- 200
res <- matrix(NA_real_, B, 6)
for (b in 1:B) {
  lam <- o$lambda_true * exp(-th * (rnorm(nrow(o), 0, st) - o$eps0))
  nu  <- rgamma(nrow(o), shape = 1 / o$sigma2_true, rate = 1 / o$sigma2_true)
  d   <- copy(o)[, yb := as.numeric(rpois(nrow(o), lam * nu))]
  mp  <- fepois(yb ~ ldist + contig + comlang | exporter + importer, data = d)
  mo  <- feols(log(yb) ~ ldist + contig + comlang | exporter + importer, data = d[yb > 0])
  res[b, ] <- c(coef(mp)[c("ldist", "contig", "comlang")],
                coef(mo)[c("ldist", "contig", "comlang")])
}
B = 200 replications
    term truth PPML_bias PPML_rmse OLS_bias OLS_rmse
   ldist -1.00   -0.0032    0.0439  -0.3163   0.3178
  contig  0.48   -0.0139    0.1136  -0.1632   0.1801
 comlang  0.36    0.0105    0.1241  -0.0395   0.1004
Code
th, st = tr.theta[0], tr.sigma_tau[0]
o["eps0"] = (np.log(o.tau_true) - np.log(tr.tau0[0])
             - tr.delta_dist[0] * np.log(o.dist / o.dist.min())
             - tr.delta_contig[0] * o.contig - tr.delta_lang[0] * o.comlang)

rng = np.random.default_rng(14159)
B, n = 200, len(o)
res = np.empty((B, 6))
for b in range(B):
    lam = o.lambda_true.values * np.exp(-th * (rng.normal(0, st, n) - o.eps0.values))
    nu  = rng.gamma(1 / o.sigma2_true.values, o.sigma2_true.values)
    d   = o.assign(yb=rng.poisson(lam * nu).astype(float))
    mp  = pf.fepois("yb ~ ldist + contig + comlang | exporter + importer", data=d)
    mo  = pf.feols("np.log(yb) ~ ldist + contig + comlang | exporter + importer",
                   data=d[d.yb > 0])
    res[b] = [mp.coef()[k] for k in ["ldist", "contig", "comlang"]] + \
             [mo.coef()[k] for k in ["ldist", "contig", "comlang"]]

tru = np.array([-1.00, 0.48, 0.36])
mc = pd.DataFrame({
    "term": ["ldist", "contig", "comlang"], "truth": tru,
    "PPML_bias": (res[:, :3].mean(0) - tru).round(4),
    "PPML_rmse": np.sqrt(((res[:, :3] - tru) ** 2).mean(0)).round(4),
    "OLS_bias":  (res[:, 3:].mean(0) - tru).round(4),
    "OLS_rmse":  np.sqrt(((res[:, 3:] - tru) ** 2).mean(0)).round(4)})

out = "B = %d replications\n\n" % B + mc.to_string(index=False)
import sys
nbytes = sys.stdout.write(out + "\n")
B = 200 replications

   term  truth  PPML_bias  PPML_rmse  OLS_bias  OLS_rmse
  ldist  -1.00     0.0045     0.0424   -0.3114    0.3129
 contig   0.48     0.0066     0.1104   -0.1507    0.1699
comlang   0.36    -0.0018     0.1126   -0.0431    0.1057
Code
sys.stdout.flush()
Code
quietly import delimited "../data/ctm-sim-truth.csv", clear varnames(1)
scalar th = theta[1]
scalar st = sigma_tau[1]
scalar t0 = tau0[1]
scalar dd = delta_dist[1]
scalar dc = delta_contig[1]
scalar dl = delta_lang[1]

quietly import delimited "../data/ctm-sim.csv", clear varnames(1)
quietly keep if domestic == 0
quietly generate ldist = ln(dist)
quietly egen expid = group(exporter)
quietly egen impid = group(importer)
quietly summarize dist, meanonly
quietly generate eps0 = ln(tau_true) - ln(t0) - dd*ln(dist/r(min)) - dc*contig - dl*comlang

set seed 14159
local B = 200
matrix R = J(`B', 6, .)
forvalues b = 1/`B' {
  quietly generate double lam = lambda_true*exp(-th*(rnormal(0, st) - eps0))
  quietly generate double nu  = rgamma(1/sigma2_true, sigma2_true)
  quietly generate double yb  = rpoisson(lam*nu)
  quietly generate double lyb = ln(yb) if yb > 0
  quietly ppmlhdfe yb ldist contig comlang, absorb(expid impid)
  matrix R[`b',1] = _b[ldist]
  matrix R[`b',2] = _b[contig]
  matrix R[`b',3] = _b[comlang]
  quietly reghdfe lyb ldist contig comlang, absorb(expid impid)
  matrix R[`b',4] = _b[ldist]
  matrix R[`b',5] = _b[contig]
  matrix R[`b',6] = _b[comlang]
  quietly drop lam nu yb lyb
}

quietly svmat R
display "B = `B' replications"
display "   term      truth  PPML_bias  PPML_rmse   OLS_bias   OLS_rmse"
local nm "ldist contig comlang"
local tv "-1.00 0.48 0.36"
forvalues k = 1/3 {
  local v : word `k' of `tv'
  local s : word `k' of `nm'
  quietly summarize R`k', meanonly
  scalar pb = r(mean) - (`v')
  quietly generate double e1 = (R`k' - (`v'))^2
  quietly summarize e1, meanonly
  scalar pr = sqrt(r(mean))
  local k2 = `k' + 3
  quietly summarize R`k2', meanonly
  scalar ob = r(mean) - (`v')
  quietly generate double e2 = (R`k2' - (`v'))^2
  quietly summarize e2, meanonly
  scalar or2 = sqrt(r(mean))
  display %8s "`s'" %11.2f (`v') %11.4f pb %11.4f pr %11.4f ob %11.4f or2
  quietly drop e1 e2
}
B = 200 replications

   term      truth  PPML_bias  PPML_rmse   OLS_bias   OLS_rmse



   ldist      -1.00    -0.0081     0.0447    -0.3178     0.3194
  contig       0.48    -0.0071     0.1027    -0.1571     0.1782
 comlang       0.36    -0.0126     0.1083    -0.0466     0.1079

Over 200 replications PPML’s distance elasticity is off by \(-0.003\) with a root mean squared error of 0.044. Log-OLS is off by \(-0.316\) with an RMSE of 0.318 — the entire error is bias, and it is a hundred times PPML’s.

Each language draws its own 200 replications from its own generator, so the three tabs agree to Monte Carlo error rather than to the digit. Across R, Python and Stata the PPML bias comes out as \(-0.0032\), \(+0.0045\) and \(-0.0081\) — every one within a hundredth of zero — while log-OLS returns \(-0.316\), \(-0.311\) and \(-0.318\). The conclusion is identical in all three; the fourth decimal is not, and should not be reported as though it were.

The Verification Toolkit

The Monte Carlo just did something the rest of the deck cannot: it knew the answer in advance. Everywhere else the answer is unknown, and that is where the real risk lives.

A clean run is not a correct run. Every wrong number in this deck’s build history arrived with zero errors, zero warnings and a plausible magnitude.

1. Known truth. Simulate a world whose parameters you chose, then estimate it back. Part II does this for \(\theta\); Part VI’s solver is graded against a counterfactual solved in levels, using productivities the solver never sees. Expensive to build once, decisive forever.

2. The null shock. Feed the model a change of nothing — every shock equal to one — and demand that every result come back exactly one. Parts VI and VII both run this before reporting any counterfactual. It catches normalisation errors, indexing errors and stale state in a single line, and it costs one solve.

3. Accounting identities. Every model here has quantities that must hold regardless of the data: value-added shares reproduce gross output (\(\mathbf{v}'\mathbf{L} = \mathbf{1}'\)), expenditure shares sum to one, value-added exports never exceed gross exports, and Fally’s adding-up requires fitted trade to reproduce observed totals country by country. Check them before looking at the result you care about.

4. Independent re-implementation. Three tabs, three ecosystems, no shared code. When R, Python and Stata agree to four decimals, that agreement is the test suite — not decoration. When they disagree, one of them is wrong and the deck says which.

The order matters. Identities are cheap and catch the crudest errors; known-truth validation is expensive and catches everything else.

Defense 4 caught this one. The R and Python tabs of Part I’s residual analysis reported different numbers for the same computation, and only a value-by-value comparison exposed it.

Code
import pandas as pd, numpy as np, statsmodels.api as sm

rng = np.random.default_rng(14159)
d   = pd.DataFrame({"x": np.arange(10.0)})
d["y"] = 2 * d.x + 1 + rng.normal(size=10)

sub = d[d.x > 4]                          # the filter KEEPS the index 5..9
X   = pd.DataFrame({"x": sub.x.values})   # a fresh frame: index 0..4
fit = sm.OLS(sub.y.values, sm.add_constant(X)).fit()

bad  = sub.assign(r=fit.resid)                 # aligns on INDEX  -> all NaN
good = sub.assign(r=np.asarray(fit.resid))     # aligns on POSITION

out = ("assign(r=fit.resid)        " + str(np.round(bad.r.values, 3)) +
       "\nassign(r=np.asarray(...))  " + str(np.round(good.r.values, 3)) +
       "\n\nno error and no warning: a groupby on the first one returns\n"
       "a full table of plausible numbers built from nothing.")
import sys
nbytes = sys.stdout.write(out + "\n")
assign(r=fit.resid)        [nan nan nan nan nan]
assign(r=np.asarray(...))  [-0.714  0.543  0.656 -0.086 -0.399]

no error and no warning: a groupby on the first one returns
a full table of plausible numbers built from nothing.
Code
sys.stdout.flush()

pandas aligns on the index, not on position. The filtered frame kept its original labels while statsmodels returned a fresh RangeIndex, so every value landed in a row that does not exist. Always np.asarray() before assigning a model’s output back to a filtered frame.

Defense 3 caught this one, in all three languages at once: after a Poisson fit, every ecosystem predicts the linear index by default, and the adding-up identity then fails by 100% instead of \(10^{-7}\).

Code
set.seed(14159)
n   <- 200
x   <- rnorm(n)
y   <- rpois(n, exp(1 + 0.5 * x))
fit <- glm(y ~ x, family = poisson)

# Poisson with an intercept: the fitted MEANS must sum to the observed total
cat(sprintf("observed total            %10.2f\n", sum(y)))
cat(sprintf("sum of predict(fit)       %10.2f   <- the LINK\n", sum(predict(fit))))
cat(sprintf("sum of type = 'response'  %10.2f   <- the mean\n",
            sum(predict(fit, type = "response"))))
observed total                616.00
sum of predict(fit)           197.69   <- the LINK
sum of type = 'response'      616.00   <- the mean

The wrong line is not an error — it is a plausible vector of the wrong quantity. Use predict(type = "response") in R, .predict(type="response") in pyfixest, and predict …, mu after ppmlhdfe …, d in Stata. The same trap has three spellings and one symptom.

Three quiet ways a correct algorithm reports a wrong number:

  • Stata reads CSVs as float. Seven significant digits. Part II’s market-clearing residual reads \(10^{-8}\) instead of \(10^{-13}\), and a machine-precision equilibrium looks sloppy. set type double in every chunk that needs it — the collectcode preamble does not reliably carry it.
  • rpois returns an integer matrix in R. Head–Ries multiplies two diagonal elements, overflows, and returns NA with only a warning. storage.mode(X) <- "double" immediately after the draw.
  • A convergence tolerance is a claim about your answer. The GE solvers here run to \(10^{-12}\) because the validation they are graded against holds to \(10^{-11}\). A looser tolerance would still “converge” — to a different number.

None of this is original; it is standard practice, written down.

  • Yotov, Piermartini, Monteiro & Larch (2016), An Advanced Guide to Trade Policy Analysis, WTO/UNCTAD — the solved applications this deck follows run exactly these consistency checks. wto.org
  • Fally (2015), Structural gravity and fixed effects, JIE 97(1) — the adding-up property that defense 3 tests. 10.1016/j.jinteco.2015.05.005
  • Arkolakis, Costinot & Rodríguez-Clare (2012), New Trade Models, Same Old Gains?, AER 102(1) — the sufficient-statistic identity Part VI checks its solver against. 10.1257/aer.102.1.94
  • Gentzkow & Shapiro (2014), Code and Data for the Social Sciences: A Practitioner’s Guide — testing and replication discipline for empirical work. web.stanford.edu

Part II in review

  • The distance elasticity that log-OLS reported in Part I, \(-1.319\), is the kind of number a biased estimator produces. In a world where the truth is \(-1.00\), log-OLS returns \(-1.297\).
  • The bias is not caused by the zeros. Gamma PML drops exactly the same observations as log-OLS and still lands within \(0.010\) of the truth.
  • The bias is caused by heteroskedasticity plus the log. Fix the log, and the problem goes away.
  • PPML is consistent for the conditional mean whatever the variance function is, which is why it survives a variance-to-mean ratio running from 95 to 4 466.
  • One regression cannot show this. A Monte Carlo can, and it needs to redraw the unobserved trade costs as well as the sampling noise — otherwise it measures the luck of a single design draw.
Task R Python Stata
Poisson PML with FE fixest::fepois pyfixest.fepois ppmlhdfe
Gamma PML with FE fixest::feglm sm.GLM + dummies glm, family(gamma)
Linear with FE fixest::feols pyfixest.feols reghdfe
Cluster on exporter vcov = ~exporter vcov={"CRV1":"exporter"} cluster(expid)
Gamma\((1/\sigma^2,\,\cdot)\), mean 1 rgamma(shape=, rate=) rng.gamma(shape, scale) rgamma(a, b)
Comparison table etable DataFrame.to_string esttab, keep()
  • The three Gamma parameterisations are not the same call. R takes shape/rate, NumPy and Stata take shape/scale. Mean one requires rgamma(1/s2, rate = 1/s2) in R and gamma(1/s2, s2) in the other two.
  • Coefficients agree across the three languages to four decimals; clustered standard errors do not — Stata and fixest use different finite-sample corrections, so expect the third decimal to move.
  • pyfixest has no Gamma family. Use statsmodels.GLM with explicit dummies, and remember it is then estimating the fixed effects rather than absorbing them.
  • A Monte Carlo that redraws only the sampling noise measures the wrong thing. On this design it makes PPML look biased on contiguity by \(+0.094\), purely because the single realised draw of \(\varepsilon_{ij}\) happens to correlate with it.

Part III — Structural Gravity in Practice

Part III — The Problem & the Data

Part I derived the structural form and then ignored it:

\[ X_{ij} \;=\; \underbrace{(\beta_i p_i)^{-\theta}}_{\text{exporter}} \cdot \tau_{ij}^{-\theta} \cdot \underbrace{E_j P_j^{\theta}}_{\text{importer}} \]

The naive regression put \(\log Y_i\) and \(\log Y_j\) on the right-hand side as proxies for those two terms. They are not proxies. They are the wrong objects: the exporter term contains \(p_i\), the importer term contains the unobservable \(P_j\), and both move whenever any trade cost anywhere changes.

Fally (2015) makes the point exactly: estimate the model with exporter-time and importer-time fixed effects and those fixed effects are the multilateral resistance terms, not an approximation to them.

Part III switches from CEPII to ../data/ctm-itpde.csv — ITPD-E Release 3.

  • 50 countries, five waves 2000–2016, four broad sectors
  • 12 500 manufacturing observations, of which 250 are domestic
  • only 31 manufacturing zeros: at this level of aggregation, everybody trades

The domestic rows are the reason for the switch. Every classroom gravity dataset drops trade with oneself; without it the border effect is not identified at all, and Part V’s elasticity has to be borrowed from someone else’s paper.

The price: ITPD-E constructs domestic flows only where production statistics allow, so China, Switzerland, Malaysia and Thailand are not in this sample. Say so when you present it.

  1. What three-way fixed effects absorb, and what is left to estimate
  2. The workhorse specification, in all three languages
  3. Why the diagonal matters, and what it buys
  4. The border effect — a number that made trade economists uncomfortable
  5. The distance puzzle: sixteen years of globalisation, measured
  6. Trade agreements: pair fixed effects, phase-in, and a falsification test
  7. Standard errors: the difference between a \(t\) of 33 and a \(t\) of 2

Three-Way Fixed Effects — What They Absorb

Write the estimating equation with the structure taken seriously:

\[ X_{ijt} \;=\; \exp\!\Big(\mu_{it} + \nu_{jt} + \lambda_{ij} + \mathbf{z}_{ijt}'\boldsymbol{\beta}\Big)\,\eta_{ijt} \]

Term Absorbs Consequence
\(\mu_{it}\) exporter-time \((\beta_i p_{it})^{-\theta}\), outward resistance, GDP, population, institutions no country-level regressor survives
\(\nu_{jt}\) importer-time \(E_{jt}P_{jt}^{\theta}\), inward resistance, tariffs applied by \(j\) to everyone multilateral resistance is controlled, not modelled
\(\lambda_{ij}\) pair distance, contiguity, language, colonial history, and every unobserved pair trait only time-varying bilateral policy is identified

The three cannot all be used at once and still leave distance estimable. Distance is time-invariant, so pair fixed effects absorb it. That is a feature: it means the RTA coefficient in slide 7 is identified from switches within a pair, not from comparing pairs that have an agreement with pairs that never will.

Dropping \(\mu_{it}\) and \(\nu_{jt}\) does not just add omitted-variable bias to the constant. Because \(P_{jt}\) depends on all trade costs, it is correlated with \(\tau_{ijt}\) by construction:

\[ P_j^{-\theta} \;=\; \sum_k \left(\frac{\tau_{kj}}{\Pi_k}\right)^{-\theta}\frac{Y_k}{Y} \]

A country that is remote from everyone has a high \(P_j\) and therefore trades more with each individual partner than its size predicts. Omit \(P_j\) and that shows up as a smaller apparent distance effect for remote countries — the bias runs through the same regressor you are trying to measure.

Three-way PPML is not free. With \(N\) countries and \(T\) periods the number of fixed effects grows with the sample, and Weidner and Zylkin (2021) show the resulting incidental-parameter bias is \(O(1/N)\) and does not vanish in the usual asymptotics.

For a 50-country panel the correction matters most for coefficients identified off few switches. The RTA estimates in slide 7 are exactly that case, so treat their magnitude as indicative and their sign and significance as the finding.

10.1016/j.jinteco.2021.103513

Estimation — The Workhorse Specification

PPML on international manufacturing flows, exporter-time and importer-time fixed effects, standard errors two-way clustered on exporter and importer.

Code
d <- fread("../data/ctm-itpde.csv")
m <- d[broad_sector == "Manufacturing"]
m[, `:=`(ldist = log(dist), exp_y = paste(iso3_o, year),
         imp_y = paste(iso3_d, year), pair = paste(iso3_o, iso3_d))]

base <- fepois(trade ~ ldist + contig + comlang_off + comcol + fta_wto |
                 exp_y + imp_y,
               data = m[domestic == 0], vcov = ~iso3_o + iso3_d)
summary(base)
Poisson estimation, Dep. Var.: trade
Observations: 12,250
Fixed-effects: exp_y: 250,  imp_y: 250
Standard-errors: Clustered (iso3_o & iso3_d) 
             Estimate Std. Error    z value   Pr(>|z|)    
ldist       -0.673352   0.044917 -14.991075  < 2.2e-16 ***
contig       0.488043   0.104421   4.673784 2.9570e-06 ***
comlang_off  0.177015   0.112435   1.574378 1.1540e-01    
comcol       0.055790   0.180961   0.308296 7.5786e-01    
fta_wto      0.490015   0.068986   7.103160 1.2194e-12 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
Log-Likelihood: -2,967,402.1   Adj. Pseudo R2: 0.948335
           BIC:  5,939,548.5     Squared Cor.: 0.944081
Code
import pandas as pd, numpy as np, pyfixest as pf

d = pd.read_csv("../data/ctm-itpde.csv")
m = d[d.broad_sector == "Manufacturing"].copy()
m["ldist"] = np.log(m.dist)
m["exp_y"] = m.iso3_o + m.year.astype(str)
m["imp_y"] = m.iso3_d + m.year.astype(str)
m["pair"]  = m.iso3_o + m.iso3_d

base = pf.fepois("trade ~ ldist + contig + comlang_off + comcol + fta_wto | exp_y + imp_y",
                 data=m[m.domestic == 0], vcov={"CRV1": "iso3_o + iso3_d"})

out = base.tidy().round(4).to_string()
import sys
nbytes = sys.stdout.write(out + "\n")
             Estimate  Std. Error  t value  Pr(>|t|)    2.5%   97.5%
Coefficient                                                         
ldist         -0.6734      0.0449 -14.9911    0.0000 -0.7614 -0.5853
contig         0.4880      0.1044   4.6738    0.0000  0.2834  0.6927
comlang_off    0.1770      0.1124   1.5744    0.1154 -0.0434  0.3974
comcol         0.0558      0.1810   0.3083    0.7579 -0.2989  0.4105
fta_wto        0.4900      0.0690   7.1032    0.0000  0.3548  0.6252
Code
sys.stdout.flush()
Code
quietly import delimited "../data/ctm-itpde.csv", clear varnames(1)
quietly keep if broad_sector == "Manufacturing"
quietly generate ldist = ln(dist)
quietly egen expy  = group(iso3_o year)
quietly egen impy  = group(iso3_d year)
quietly egen pair  = group(iso3_o iso3_d)
quietly egen expid = group(iso3_o)
quietly egen impid = group(iso3_d)

ppmlhdfe trade ldist contig comlang_off comcol fta_wto if domestic == 0, ///
    absorb(expy impy) cluster(expid impid)
warning: dependent variable takes very low values after standardizing (1.0329e-08)
Iteration 1:   deviance = 1.3363e+07  eps = .         iters = 5    tol = 1.0e-04  min(eta) =  -4.42  P   
Iteration 2:   deviance = 7.0653e+06  eps = 8.91e-01  iters = 5    tol = 1.0e-04  min(eta) =  -5.96      
Iteration 3:   deviance = 5.9855e+06  eps = 1.80e-01  iters = 4    tol = 1.0e-04  min(eta) =  -7.32      
Iteration 4:   deviance = 5.8571e+06  eps = 2.19e-02  iters = 4    tol = 1.0e-04  min(eta) =  -8.66      
Iteration 5:   deviance = 5.8476e+06  eps = 1.62e-03  iters = 3    tol = 1.0e-04  min(eta) =  -9.69      
Iteration 6:   deviance = 5.8472e+06  eps = 7.51e-05  iters = 3    tol = 1.0e-04  min(eta) = -10.15      
Iteration 7:   deviance = 5.8472e+06  eps = 1.70e-06  iters = 3    tol = 1.0e-05  min(eta) = -10.25      
Iteration 8:   deviance = 5.8472e+06  eps = 2.78e-09  iters = 3    tol = 1.0e-06  min(eta) = -10.25   S  
Iteration 9:   deviance = 5.8472e+06  eps = 1.18e-14  iters = 2    tol = 1.0e-07  min(eta) = -10.25   S  
Iteration 10:  deviance = 5.8472e+06  eps = 0.00e+00  iters = 2    tol = 1.0e-09  min(eta) = -10.25   S O
------------------------------------------------------------------------------------------------------------
(legend: p: exact partial-out   s: exact solver   h: step-halving   o: epsilon below tolerance)
Converged in 10 iterations and 34 HDFE sub-iterations (tol = 1.0e-08)
Warning: VCV matrix was non-positive semi-definite; adjustment from Cameron, Gelbach & Miller applied.

HDFE PPML regression                              No. of obs      =     12,250
Absorbing 2 HDFE groups                           Residual df     =         49
Statistics robust to heteroskedasticity           Wald chi2(5)    =     362.13
Deviance             =  5847164.639               Prob > chi2     =     0.0000
Log pseudolikelihood = -2967402.008               Pseudo R2       =     0.9483

Number of clusters (expid)  =         50
Number of clusters (impid)  =         50
                           (Std. err. adjusted for 50 clusters in expid impid)
------------------------------------------------------------------------------
             |               Robust
       trade | Coefficient  std. err.      z    P>|z|     [95% conf. interval]
-------------+----------------------------------------------------------------
       ldist |  -.6733524    .044962   -14.98   0.000    -.7614762   -.5852285
      contig |   .4880429   .1044281     4.67   0.000     .2833675    .6927182
 comlang_off |   .1770152   .1124174     1.57   0.115    -.0433189    .3973493
      comcol |   .0557898   .1809246     0.31   0.758     -.298816    .4103956
     fta_wto |   .4900152   .0689766     7.10   0.000     .3548236    .6252069
       _cons |   14.29505   .3496407    40.88   0.000     13.60976    14.98033
------------------------------------------------------------------------------

Absorbed degrees of freedom:
-----------------------------------------------------+
 Absorbed FE | Categories  - Redundant  = Num. Coefs |
-------------+---------------------------------------|
        expy |       250         250           0    *|
        impy |       250         250           0    *|
-----------------------------------------------------+
* = FE nested within cluster; treated as redundant for DoF computation

The distance elasticity is \(-0.673\) — close to the PPML value Part I found on CEPII, and half the log-OLS estimate. Contiguity adds \(e^{0.488} = 1.63\) times more trade; a trade agreement, \(e^{0.490} = 1.63\) times. Common language and shared colonial history are not significant once the resistance terms are absorbed properly.

The Missing Diagonal

Every country’s largest trading partner is itself. In this sample the median country ships \(76\%\) of its manufacturing output to its own residents.

Structural gravity says that number is informative, not a nuisance:

\[ \frac{X_{ij}}{X_{jj}} \;=\; \left(\frac{c_i \tau_{ij}}{c_j \tau_{jj}}\right)^{-\theta} \]

Dividing a country’s imports from \(i\) by its purchases from itself cancels the inward multilateral resistance term exactly. What is left is a ratio of trade costs and unit costs — no unobservables.

That identity is the engine of Part V, and it needs \(X_{jj}\).

With the diagonal in the sample, a single dummy separates international from internal trade:

\[ \text{brdr}_{ij} = \mathbf{1}\{i \neq j\} \]

Its coefficient measures everything that makes crossing a border costly and is not distance, contiguity, language or a trade agreement: customs, currencies, regulation, standards, information, trust.

Without the diagonal, \(\text{brdr}_{ij} = 1\) for every observation and the coefficient does not exist. This is not a refinement. It is the difference between the question being answerable and not.

Internal distance is not observed — a country does not have a bilateral distance to itself. CEPII supplies a population-weighted internal distance \(d_{ii}\), computed from the distribution of population across subnational units, and that is what the diagonal rows carry here.

It is a construct, and the border coefficient is sensitive to it: a larger assumed \(d_{ii}\) makes internal trade look less remarkable and shrinks the border effect. Report the measure you used.

Estimation — Structural Gravity with Internal Trade

Code
m[, brdr := as.integer(domestic == 0)]

full <- fepois(trade ~ ldist + contig + comlang_off + comcol + fta_wto + brdr |
                 exp_y + imp_y,
               data = m, vcov = ~iso3_o + iso3_d)
summary(full)
cat(sprintf("border effect  exp(-b) = %.1f\n", exp(-coef(full)["brdr"])))
Poisson estimation, Dep. Var.: trade
Observations: 12,500
Fixed-effects: exp_y: 250,  imp_y: 250
Standard-errors: Clustered (iso3_o & iso3_d) 
             Estimate Std. Error  z value   Pr(>|z|)    
ldist       -0.427686   0.071202 -6.00665 1.8940e-09 ***
contig       0.758518   0.123431  6.14527 7.9825e-10 ***
comlang_off  0.247578   0.140109  1.76704 7.7221e-02 .  
comcol       0.632390   0.328554  1.92477 5.4259e-02 .  
fta_wto      0.638832   0.144948  4.40731 1.0466e-05 ***
brdr        -2.325264   0.277512 -8.37898  < 2.2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
Log-Likelihood: -8,240,505.4   Adj. Pseudo R2: 0.970406
           BIC: 16,485,774.8     Squared Cor.: 0.99111 

border effect  exp(-b) = 10.2
Code
m["brdr"] = (m.domestic == 0).astype(int)

full = pf.fepois("trade ~ ldist + contig + comlang_off + comcol + fta_wto + brdr | exp_y + imp_y",
                 data=m, vcov={"CRV1": "iso3_o + iso3_d"})

out = (full.tidy().round(4).to_string() +
       "\n\nborder effect  exp(-b) = %.1f" % np.exp(-full.coef()["brdr"]))
import sys
nbytes = sys.stdout.write(out + "\n")
             Estimate  Std. Error  t value  Pr(>|t|)    2.5%   97.5%
Coefficient                                                         
ldist         -0.4277      0.0712  -6.0066    0.0000 -0.5672 -0.2881
contig         0.7585      0.1234   6.1453    0.0000  0.5166  1.0004
comlang_off    0.2476      0.1401   1.7670    0.0772 -0.0270  0.5222
comcol         0.6324      0.3286   1.9247    0.0543 -0.0116  1.2764
fta_wto        0.6388      0.1449   4.4073    0.0000  0.3547  0.9229
brdr          -2.3253      0.2775  -8.3790    0.0000 -2.8692 -1.7814

border effect  exp(-b) = 10.2
Code
sys.stdout.flush()
Code
quietly import delimited "../data/ctm-itpde.csv", clear varnames(1)
quietly keep if broad_sector == "Manufacturing"
quietly generate ldist = ln(dist)
quietly egen expy  = group(iso3_o year)
quietly egen impy  = group(iso3_d year)
quietly egen pair  = group(iso3_o iso3_d)
quietly egen expid = group(iso3_o)
quietly egen impid = group(iso3_d)
quietly generate byte brdr = (domestic == 0)

ppmlhdfe trade ldist contig comlang_off comcol fta_wto brdr, ///
    absorb(expy impy) cluster(expid impid)
display "border effect  exp(-b) = " %4.1f exp(-_b[brdr])
warning: dependent variable takes very low values after standardizing (1.2915e-09)
Iteration 1:   deviance = 4.6629e+07  eps = .         iters = 7    tol = 1.0e-04  min(eta) =  -5.25  P   
Iteration 2:   deviance = 2.1985e+07  eps = 1.12e+00  iters = 8    tol = 1.0e-04  min(eta) =  -6.80      
Iteration 3:   deviance = 1.7263e+07  eps = 2.74e-01  iters = 7    tol = 1.0e-04  min(eta) =  -8.14      
Iteration 4:   deviance = 1.6497e+07  eps = 4.64e-02  iters = 7    tol = 1.0e-04  min(eta) =  -9.18      
Iteration 5:   deviance = 1.6400e+07  eps = 5.95e-03  iters = 6    tol = 1.0e-04  min(eta) = -10.19      
Iteration 6:   deviance = 1.6391e+07  eps = 5.45e-04  iters = 4    tol = 1.0e-04  min(eta) = -11.02      
Iteration 7:   deviance = 1.6390e+07  eps = 2.63e-05  iters = 3    tol = 1.0e-04  min(eta) = -11.36      
Iteration 8:   deviance = 1.6390e+07  eps = 3.81e-07  iters = 4    tol = 1.0e-05  min(eta) = -11.41      
Iteration 9:   deviance = 1.6390e+07  eps = 2.69e-10  iters = 4    tol = 1.0e-06  min(eta) = -11.42   S  
Iteration 10:  deviance = 1.6390e+07  eps = 2.13e-15  iters = 6    tol = 1.0e-08  min(eta) = -11.42   S  
Iteration 11:  deviance = 1.6390e+07  eps = 0.00e+00  iters = 4    tol = 1.0e-09  min(eta) = -11.42   S O
------------------------------------------------------------------------------------------------------------
(legend: p: exact partial-out   s: exact solver   h: step-halving   o: epsilon below tolerance)
Converged in 11 iterations and 60 HDFE sub-iterations (tol = 1.0e-08)
Warning: VCV matrix was non-positive semi-definite; adjustment from Cameron, Gelbach & Miller applied.

HDFE PPML regression                              No. of obs      =     12,500
Absorbing 2 HDFE groups                           Residual df     =         49
Statistics robust to heteroskedasticity           Wald chi2(6)    =     716.69
Deviance             =  16390277.86               Prob > chi2     =     0.0000
Log pseudolikelihood = -8240505.443               Pseudo R2       =     0.9704

Number of clusters (expid)  =         50
Number of clusters (impid)  =         50
                           (Std. err. adjusted for 50 clusters in expid impid)
------------------------------------------------------------------------------
             |               Robust
       trade | Coefficient  std. err.      z    P>|z|     [95% conf. interval]
-------------+----------------------------------------------------------------
       ldist |   -.427686   .0712277    -6.00   0.000    -.5672897   -.2880822
      contig |   .7585179    .123411     6.15   0.000     .5166368    1.000399
 comlang_off |   .2475784   .1402036     1.77   0.077    -.0272156    .5223723
      comcol |   .6323898   .3284794     1.93   0.054     -.011418    1.276198
     fta_wto |    .638832   .1449142     4.41   0.000     .3548054    .9228587
        brdr |  -2.325264   .2776176    -8.38   0.000    -2.869384   -1.781143
       _cons |   15.17648   .4316533    35.16   0.000     14.33046    16.02251
------------------------------------------------------------------------------

Absorbed degrees of freedom:
-----------------------------------------------------+
 Absorbed FE | Categories  - Redundant  = Num. Coefs |
-------------+---------------------------------------|
        expy |       250         250           0    *|
        impy |       250         250           0    *|
-----------------------------------------------------+
* = FE nested within cluster; treated as redundant for DoF computation

border effect  exp(-b) = 10.2

The border coefficient is \(-2.325\). Crossing a national frontier divides trade by \(e^{2.325} = \mathbf{10.2}\), holding distance, contiguity, language, colonial ties and trade agreements constant.

Compare that with the popup in Part I: Anderson and van Wincoop’s corrected Canadian border effect was 10.7. Twenty-three years later, different data, different countries, a different estimator — and the same order of magnitude.

Note also what happens to distance once the diagonal is in: the elasticity falls from \(-0.673\) to \(-0.428\). Much of what looked like a distance effect was the jump from internal to international trade.

Trade Agreements — Identification from Switches

Distance cannot be estimated alongside pair fixed effects. Trade agreements can, because pairs change status: 476 pair-wave switches in this panel.

The specification below carries two lags and one lead. Neither is decoration.

Code
setorder(m, iso3_o, iso3_d, year)
m[, `:=`(rta_l1 = shift(fta_wto, 1),        # one wave  = 4 years
         rta_l2 = shift(fta_wto, 2),
         rta_f1 = shift(fta_wto, -1)), by = pair]
i <- m[domestic == 0]

r1 <- fepois(trade ~ fta_wto | exp_y + imp_y + pair, data = i, vcov = ~iso3_o + iso3_d)
r2 <- fepois(trade ~ fta_wto + rta_l1 + rta_l2 | exp_y + imp_y + pair,
             data = i, vcov = ~iso3_o + iso3_d)
r3 <- fepois(trade ~ fta_wto + rta_l1 + rta_l2 + rta_f1 | exp_y + imp_y + pair,
             data = i, vcov = ~iso3_o + iso3_d)
etable(r1, r2, r3)
       term contemp with_lags with_lead se_lead
        rta  0.0684    0.1035    0.0195  0.0582
  rta lag 1       .    0.0484    0.0514  0.0250
  rta lag 2       .   -0.0641   -0.0551  0.0397
 rta LEAD 1       .         .    0.0988  0.0512

contemporaneous only : 0.0684  (se 0.0304,  t 2.25)
cumulative over 8 yrs: 0.0879  =>  9.2% more trade
Code
m = m.sort_values(["iso3_o", "iso3_d", "year"])
g = m.groupby("pair").fta_wto
m["rta_l1"] = g.shift(1)
m["rta_l2"] = g.shift(2)
m["rta_f1"] = g.shift(-1)
i = m[m.domestic == 0]

cl = {"CRV1": "iso3_o + iso3_d"}
r1 = pf.fepois("trade ~ fta_wto | exp_y + imp_y + pair", data=i, vcov=cl)
r2 = pf.fepois("trade ~ fta_wto + rta_l1 + rta_l2 | exp_y + imp_y + pair", data=i, vcov=cl)
r3 = pf.fepois("trade ~ fta_wto + rta_l1 + rta_l2 + rta_f1 | exp_y + imp_y + pair",
               data=i, vcov=cl)

k3 = ["fta_wto", "rta_l1", "rta_l2", "rta_f1"]
fmt = lambda v: "." if v is None else "%.4f" % v
tab = pd.DataFrame({
    "term": ["rta", "rta lag 1", "rta lag 2", "rta LEAD 1"],
    "contemp":   [fmt(r1.coef()["fta_wto"]), ".", ".", "."],
    "with_lags": [fmt(r2.coef()[k]) for k in k3[:3]] + ["."],
    "with_lead": [fmt(r3.coef()[k]) for k in k3],
    "se_lead":   [fmt(r3.se()[k]) for k in k3]})

out = (tab.to_string(index=False) +
       "\n\ncontemporaneous only : %.4f  (se %.4f,  t %.2f)"
       % (r1.coef()["fta_wto"], r1.se()["fta_wto"],
          r1.coef()["fta_wto"] / r1.se()["fta_wto"]) +
       "\ncumulative over 8 yrs: %.4f  =>  %.1f%% more trade"
       % (r2.coef().sum(), 100 * (np.exp(r2.coef().sum()) - 1)))
import sys
nbytes = sys.stdout.write(out + "\n")
      term contemp with_lags with_lead se_lead
       rta  0.0684    0.1035    0.0195  0.0582
 rta lag 1       .    0.0484    0.0514  0.0250
 rta lag 2       .   -0.0641   -0.0551  0.0397
rta LEAD 1       .         .    0.0988  0.0512

contemporaneous only : 0.0684  (se 0.0304,  t 2.25)
cumulative over 8 yrs: 0.0879  =>  9.2% more trade
Code
sys.stdout.flush()
Code
quietly import delimited "../data/ctm-itpde.csv", clear varnames(1)
quietly keep if broad_sector == "Manufacturing"
quietly generate ldist = ln(dist)
quietly egen expy  = group(iso3_o year)
quietly egen impy  = group(iso3_d year)
quietly egen pair  = group(iso3_o iso3_d)
quietly egen expid = group(iso3_o)
quietly egen impid = group(iso3_d)
quietly xtset pair year, delta(4)

quietly ppmlhdfe trade fta_wto if domestic == 0, ///
    absorb(expy impy pair) cluster(expid impid)
display "contemporaneous only : " %7.4f _b[fta_wto] "  (se " %6.4f _se[fta_wto] ///
        ",  t " %5.2f _b[fta_wto]/_se[fta_wto] ")"

quietly ppmlhdfe trade fta_wto L.fta_wto L2.fta_wto if domestic == 0, ///
    absorb(expy impy pair) cluster(expid impid)
scalar cum = _b[fta_wto] + _b[L.fta_wto] + _b[L2.fta_wto]
display "cumulative over 8 yrs: " %7.4f cum "  =>  " %4.1f 100*(exp(cum)-1) "% more trade"

ppmlhdfe trade fta_wto L.fta_wto L2.fta_wto F.fta_wto if domestic == 0, ///
    absorb(expy impy pair) cluster(expid impid)
contemporaneous only :  0.0684  (se 0.0304,  t  2.25)



cumulative over 8 yrs:  0.0879  =>   9.2% more trade

(dropped 2 observations that are either singletons or separated by a fixed effect)
warning: dependent variable takes very low values after standardizing (9.2788e-09)
Iteration 1:   deviance = 2.6689e+06  eps = .         iters = 5    tol = 1.0e-04  min(eta) =  -3.17  P   
Iteration 2:   deviance = 6.1342e+05  eps = 3.35e+00  iters = 5    tol = 1.0e-04  min(eta) =  -4.36      
Iteration 3:   deviance = 2.3226e+05  eps = 1.64e+00  iters = 5    tol = 1.0e-04  min(eta) =  -5.58      
Iteration 4:   deviance = 1.4435e+05  eps = 6.09e-01  iters = 4    tol = 1.0e-04  min(eta) =  -6.65      
Iteration 5:   deviance = 1.2500e+05  eps = 1.55e-01  iters = 4    tol = 1.0e-04  min(eta) =  -7.64      
Iteration 6:   deviance = 1.2093e+05  eps = 3.36e-02  iters = 3    tol = 1.0e-04  min(eta) =  -8.64      
Iteration 7:   deviance = 1.2011e+05  eps = 6.85e-03  iters = 2    tol = 1.0e-04  min(eta) =  -9.64      
Iteration 8:   deviance = 1.1995e+05  eps = 1.33e-03  iters = 2    tol = 1.0e-04  min(eta) = -10.62      
Iteration 9:   deviance = 1.1992e+05  eps = 2.55e-04  iters = 2    tol = 1.0e-04  min(eta) = -11.58      
Iteration 10:  deviance = 1.1991e+05  eps = 5.35e-05  iters = 2    tol = 1.0e-04  min(eta) = -12.50      
Iteration 11:  deviance = 1.1991e+05  eps = 1.26e-05  iters = 2    tol = 1.0e-05  min(eta) = -13.39      
Iteration 12:  deviance = 1.1991e+05  eps = 3.13e-06  iters = 2    tol = 1.0e-05  min(eta) = -14.26   S  
Iteration 13:  deviance = 1.1991e+05  eps = 7.75e-07  iters = 2    tol = 1.0e-06  min(eta) = -15.24   S  
Iteration 14:  deviance = 1.1991e+05  eps = 1.82e-07  iters = 2    tol = 1.0e-07  min(eta) = -16.18   S  
Iteration 15:  deviance = 1.1991e+05  eps = 3.59e-08  iters = 2    tol = 1.0e-07  min(eta) = -17.04   S  
Iteration 16:  deviance = 1.1991e+05  eps = 5.95e-09  iters = 2    tol = 1.0e-09  min(eta) = -17.71   S O
------------------------------------------------------------------------------------------------------------
(legend: p: exact partial-out   s: exact solver   h: step-halving   o: epsilon below tolerance)
Converged in 16 iterations and 46 HDFE sub-iterations (tol = 1.0e-08)
Warning: VCV matrix was non-positive semi-definite; adjustment from Cameron, Gelbach & Miller applied.

HDFE PPML regression                              No. of obs      =      4,898
Absorbing 3 HDFE groups                           Residual df     =         49
Statistics robust to heteroskedasticity           Wald chi2(4)    =       6.48
Deviance             =  119910.8253               Prob > chi2     =     0.1659
Log pseudolikelihood = -78520.82796               Pseudo R2       =     0.9970

Number of clusters (expid)  =         50
Number of clusters (impid)  =         50
                           (Std. err. adjusted for 50 clusters in expid impid)
------------------------------------------------------------------------------
             |               Robust
       trade | Coefficient  std. err.      z    P>|z|     [95% conf. interval]
-------------+----------------------------------------------------------------
     fta_wto |
         --. |   .0194578   .0581872     0.33   0.738     -.094587    .1335026
         L1. |   .0513578   .0249407     2.06   0.039     .0024749    .1002408
         L2. |  -.0550597   .0396874    -1.39   0.165    -.1328455    .0227261
         F1. |   .0988066   .0512231     1.93   0.054    -.0015888     .199202
             |
       _cons |   9.679705   .0597763   161.93   0.000     9.562546    9.796864
------------------------------------------------------------------------------

Absorbed degrees of freedom:
-----------------------------------------------------+
 Absorbed FE | Categories  - Redundant  = Num. Coefs |
-------------+---------------------------------------|
        expy |       100         100           0    *|
        impy |       100         100           0    *|
        pair |      2449        2449           0    *|
-----------------------------------------------------+
* = FE nested within cluster; treated as redundant for DoF computation

Three readings of the same variable.

Contemporaneous only: \(0.068\), a \(7.1\%\) trade increase — far below the \(e^{0.490}=1.63\) from the specification without pair fixed effects. That gap is the endogeneity Baier and Bergstrand identified: countries sign agreements with partners they already trade with.

With phase-in: the effect accumulates to \(0.088\) over eight years. Agreements work slowly.

The lead test: trade rises before the agreement takes effect (\(\hat\beta_{F1} = 0.099\), \(p = 0.054\)), and the contemporaneous term collapses to \(0.019\). That is the falsification test failing to clear — evidence of anticipation, or of agreements being signed with partners whose trade was already growing. Report it; do not bury it.

Inference — What the Standard Errors Depend On

Same coefficient, same data, five variance estimators.

Code
specs <- list(iid = "iid", robust = "hetero", `cluster exporter` = ~iso3_o,
              `cluster pair` = ~pair, `two-way exp & imp` = ~iso3_o + iso3_d)
# ssc(adj = FALSE) drops fixest's small-sample k adjustment, which ppmlhdfe
# does not apply -- without it the three languages differ by 15%
vt <- rbindlist(lapply(names(specs), function(nm) {
  f <- fepois(trade ~ fta_wto | exp_y + imp_y + pair, data = i,
              vcov = specs[[nm]], ssc = ssc(adj = FALSE))
  data.table(vcov = nm, estimate = coef(f)["fta_wto"], se = se(f)["fta_wto"],
             t = coef(f)["fta_wto"] / se(f)["fta_wto"])
}))
print(vt, row.names = FALSE)
              vcov estimate      se     t
            <char>    <num>   <num> <num>
               iid   0.0684 0.00182 37.51
            robust   0.0684 0.02789  2.45
  cluster exporter   0.0684 0.02801  2.44
      cluster pair   0.0684 0.03252  2.10
 two-way exp & imp   0.0684 0.03039  2.25
Code
specs = {"iid": "iid", "robust": "hetero", "cluster exporter": {"CRV1": "iso3_o"},
         "cluster pair": {"CRV1": "pair"},
         "two-way exp & imp": {"CRV1": "iso3_o + iso3_d"}}
rows = []
for nm, v in specs.items():
    # k_adj=False matches ppmlhdfe, which applies no small-sample k correction
    f = pf.fepois("trade ~ fta_wto | exp_y + imp_y + pair", data=i, vcov=v,
                  ssc=pf.ssc(k_adj=False))
    b, s = f.coef()["fta_wto"], f.se()["fta_wto"]
    rows.append([nm, round(b, 4), round(s, 5), round(b / s, 2)])
vt = pd.DataFrame(rows, columns=["vcov", "estimate", "se", "t"])

out = vt.to_string(index=False)
import sys
nbytes = sys.stdout.write(out + "\n")
             vcov  estimate      se     t
              iid    0.0684 0.00182 37.51
           robust    0.0684 0.02789  2.45
 cluster exporter    0.0684 0.02801  2.44
     cluster pair    0.0684 0.03252  2.10
two-way exp & imp    0.0684 0.03039  2.25
Code
sys.stdout.flush()
Code
quietly import delimited "../data/ctm-itpde.csv", clear varnames(1)
quietly keep if broad_sector == "Manufacturing"
quietly generate ldist = ln(dist)
quietly egen expy  = group(iso3_o year)
quietly egen impy  = group(iso3_d year)
quietly egen pair  = group(iso3_o iso3_d)
quietly egen expid = group(iso3_o)
quietly egen impid = group(iso3_d)
* ppmlhdfe always reports robust standard errors and has no unadjusted option,
* so the "iid" row of the other two tabs cannot be reproduced here -- which is
* the safer default, since that row is the one nobody should report.
display "             vcov   estimate         se         t"
quietly ppmlhdfe trade fta_wto if domestic == 0, absorb(expy impy pair) vce(robust)
display %17s "robust" %11.4f _b[fta_wto] %11.5f _se[fta_wto] %10.2f _b[fta_wto]/_se[fta_wto]

quietly ppmlhdfe trade fta_wto if domestic == 0, absorb(expy impy pair) cluster(expid)
display %17s "cluster exporter" %11.4f _b[fta_wto] %11.5f _se[fta_wto] %10.2f _b[fta_wto]/_se[fta_wto]

quietly ppmlhdfe trade fta_wto if domestic == 0, absorb(expy impy pair) cluster(pair)
display %17s "cluster pair" %11.4f _b[fta_wto] %11.5f _se[fta_wto] %10.2f _b[fta_wto]/_se[fta_wto]

quietly ppmlhdfe trade fta_wto if domestic == 0, absorb(expy impy pair) cluster(expid impid)
display %17s "two-way exp & imp" %11.4f _b[fta_wto] %11.5f _se[fta_wto] %10.2f _b[fta_wto]/_se[fta_wto]
             vcov   estimate         se         t

           robust     0.0684    0.02789      2.45


 cluster exporter     0.0684    0.02801      2.44

     cluster pair     0.0684    0.03252      2.10


two-way exp & imp     0.0684    0.03039      2.25

The point estimate never moves. The \(t\) statistic runs from 37.5 to 2.1.

Assuming independence across 12 250 observations that are 50 countries observed five times is not a small sin — it overstates precision by a factor of 15. Cameron, Gelbach and Miller’s two-way estimator is the default for gravity because a shock to Germany’s exports hits 49 observations at once and a shock to Germany’s imports hits another 49.

Two things about the code are worth more than the table. First, ppmlhdfe has no unadjusted option — it reports robust standard errors and nothing else, so the misleading first row cannot be produced in Stata at all. Second, fixest and pyfixest apply a small-sample \(k\) correction that ppmlhdfe does not; without ssc(adj = FALSE) the same estimator reports \(0.0320\) in R and \(0.0279\) in Stata, a \(15\%\) gap that looks like a bug and is a convention.

Part III in review

  • Exporter-time and importer-time fixed effects are the multilateral resistance terms. Not proxies for them.
  • Adding the domestic diagonal changes the distance elasticity from \(-0.673\) to \(-0.428\) and makes the border effect estimable at all: 10.2, against Anderson and van Wincoop’s 10.7.
  • Estimated wave by wave, the distance elasticity rose between 2000 and 2016 instead of falling — the distance puzzle. Containers and air freight did not make geography less relevant. The estimation exercises in Part IX ask you to reproduce it in a five-line loop.
  • Pair fixed effects cut the apparent RTA effect from \(63\%\) to \(7\%\). Most of the raw correlation was selection, exactly as Baier and Bergstrand argued.
  • Always run the lead. Here it is significant at \(10\%\), and honest reporting means saying so.
  • The choice of variance estimator moves \(t\) from 33 to 2 without touching a single coefficient.
Task R Python Stata
Three-way FE PPML fepois(... \| a+b+c) pf.fepois("... \| a+b+c") ppmlhdfe, absorb(a b c)
Two-way cluster vcov = ~iso3_o + iso3_d vcov={"CRV1":"iso3_o + iso3_d"} cluster(expid impid)
Interacted FE paste(iso3_o, year) or iso3_o^year string concat egen group()
Lags on 4-year waves shift(x, 1), by = pair groupby.shift(1) xtset pair year, delta(4) then L.
Model comparison etable DataFrame esttab
  • xtset pair year, delta(4) is required before L. and F. on a four-year wave panel. Without delta(4) Stata treats the gaps as missing and every lag is empty.
  • Two-way clustering needs enough clusters in both dimensions. Fifty exporters and fifty importers is adequate; ten would not be.
  • Interacting fixed effects by pasting strings is fine in R and Python but Stata needs a numeric group — egen expy = group(iso3_o year).
  • Internal distance is a construct. The border effect is sensitive to it, so state which measure you used; here it is CEPII’s population-weighted distw_harmonic.
  • If a coefficient is identified off a handful of switches, quote Weidner and Zylkin and treat the magnitude as indicative.

Part IV — Estimator Choice & Diagnostics

Part IV — Which Pseudo-Likelihood?

Part II settled that log-OLS is biased and PPML is not. It did so in a world built to make Poisson the right answer.

Real data is not built by anyone. Part IV asks the question a referee asks: how do you know Poisson is the right pseudo-likelihood here?

Every estimator in this family solves the same first-order condition:

\[ \sum_{ij} \frac{\big(X_{ij} - \mu_{ij}(\boldsymbol\beta)\big)}{V(\mu_{ij})} \,\frac{\partial \mu_{ij}}{\partial \boldsymbol\beta} \;=\; 0, \qquad \mu_{ij} = \exp(\mathbf{x}_{ij}'\boldsymbol\beta) \]

They differ only in the assumed variance function \(V(\mu)\). And Gourieroux, Monfort and Trognon’s result is that any of them is consistent as long as the conditional mean is correctly specified — the variance function affects efficiency, not consistency.

So the diagnostic question splits in two:

  1. Is \(\mu_{ij} = \exp(\mathbf{x}_{ij}'\boldsymbol\beta)\) the right mean? → RESET
  2. If so, which \(V(\mu)\) is closest to the truth? → efficiency
  1. The family, and what each member weights
  2. All four on the same data, and how far apart they land
  3. The RESET test — the only one of these that can reject
  4. Separation: when the estimate does not exist at all
  5. What to report

Back to ../data/ctm-gravity.csv, the 2016 CEPII cross-section: 18 632 international pairs among 150 countries, 1 525 of them zero.

Slide 5 switches to ITPD-E services, which is the only sector in either file where separation occurs naturally.

The PML Family

\[ \text{Var}(X_{ij}\mid \mathbf{x}_{ij}) \;=\; \phi \cdot V(\mu_{ij}) \]

Estimator \(V(\mu)\) Weights each observation by Uses zeros
Gaussian PML (log link) \(1\) \(\mu_{ij}^2\) — the largest flows dominate yes
Poisson PML \(\mu\) \(\mu_{ij}\) yes
Gamma PML \(\mu^2\) \(1\) — every pair counts equally no
log-OLS \(1\), but on the wrong dependent variable no

Reading down the table, the weight on a large flow falls. Gaussian PML is dominated by the United States and China; Gamma PML treats a \(\$10\,000\) flow between two small countries as equally informative as a \(\$400\) billion one.

Poisson sits between them, which is one reason it is the default — but only one reason, and not the important one.

Three properties, none of which is “trade is a count”:

  • Consistency under a correct mean, whatever the true variance is.
  • It admits zeros without transformation, so the sample is not selected.
  • Its first-order condition delivers an exact adding-up property: with exporter and importer fixed effects, predicted total exports equal actual total exports for every country. Fally (2015) showed this is what makes the fitted fixed effects be the multilateral resistance terms.

That third property is why Part VI can take PPML estimates straight into a general-equilibrium counterfactual. No other member of the family has it.

Negative binomial PML is common in applied trade work and is a mistake here.

Its estimates are not invariant to the units of the dependent variable: measure trade in dollars rather than thousands of dollars and the coefficients change. For a genuine count that is harmless, because the units are fixed by nature. For trade values it is not, and Bosquet and Boulhol (2014) show the sensitivity is large in practice.

This deck does not use it.

PML Comparison — Same Data, Four Estimators

Code
g <- fread("../data/ctm-gravity.csv")
e <- g[domestic == 0 & year == 2016 & !is.na(dist) & !is.na(gdp_o) & !is.na(gdp_d)]
e[, ldist := log(dist)]
S <- ssc(adj = FALSE)     # match ppmlhdfe, which applies no small-sample k adjustment
fml <- ~ ldist + contig + comlang_off + comcol | iso3_o + iso3_d

pois <- fepois(trade ~ ldist + contig + comlang_off + comcol | iso3_o + iso3_d,
               data = e, vcov = "hetero", ssc = S)
gamm <- feglm(trade ~ ldist + contig + comlang_off + comcol | iso3_o + iso3_d,
              data = e[trade > 0], family = Gamma(link = "log"),
              vcov = "hetero", ssc = S, glm.iter = 200)
gaus <- feglm(trade ~ ldist + contig + comlang_off + comcol | iso3_o + iso3_d,
              data = e[trade > 0], family = gaussian(link = "log"),
              vcov = "hetero", ssc = S, glm.iter = 200)
lols <- feols(log(trade) ~ ldist + contig + comlang_off + comcol | iso3_o + iso3_d,
              data = e[trade > 0], vcov = "hetero", ssc = S)
etable(pois, gamm, gaus, lols)
     estimator     n   ldist contig comlang
       Poisson 18632 -0.6923 0.6527 -0.0289
         Gamma 17107 -1.2999 0.9892  0.6628
 Gaussian(log) 17107 -0.5921 0.8780 -0.2528
       log-OLS 17107 -1.3689 1.0078  0.9317
Code
import pandas as pd, numpy as np, pyfixest as pf, statsmodels.api as sm

g = pd.read_csv("../data/ctm-gravity.csv")
e = g[(g.domestic == 0) & (g.year == 2016)].dropna(subset=["dist", "gdp_o", "gdp_d"]).copy()
e["ldist"] = np.log(e.dist)
pos = e[e.trade > 0]
S = pf.ssc(k_adj=False)

pois = pf.fepois("trade ~ ldist + contig + comlang_off + comcol | iso3_o + iso3_d",
                 data=e, vcov="hetero", ssc=S)
lols = pf.feols("np.log(trade) ~ ldist + contig + comlang_off + comcol | iso3_o + iso3_d",
                data=pos, vcov="hetero", ssc=S)

# pyfixest has no Gamma or log-link Gaussian family: dummies + statsmodels GLM
X = pd.get_dummies(pos[["iso3_o", "iso3_d"]], drop_first=True, dtype=float)
X = sm.add_constant(pd.concat([pos[["ldist", "contig", "comlang_off", "comcol"]]
                               .reset_index(drop=True), X.reset_index(drop=True)], axis=1))
y = pos.trade.values
gamm = sm.GLM(y, X, family=sm.families.Gamma(link=sm.families.links.Log())).fit(cov_type="HC0")
gaus = sm.GLM(y, X, family=sm.families.Gaussian(link=sm.families.links.Log())).fit(cov_type="HC0")

rows = [["Poisson", len(e), round(pois.coef()["ldist"], 4),
         round(pois.coef()["contig"], 4), round(pois.coef()["comlang_off"], 4)],
        ["Gamma", len(pos), round(gamm.params["ldist"], 4),
         round(gamm.params["contig"], 4), round(gamm.params["comlang_off"], 4)],
        ["Gaussian(log)", len(pos), round(gaus.params["ldist"], 4),
         round(gaus.params["contig"], 4), round(gaus.params["comlang_off"], 4)],
        ["log-OLS", len(pos), round(lols.coef()["ldist"], 4),
         round(lols.coef()["contig"], 4), round(lols.coef()["comlang_off"], 4)]]
tab = pd.DataFrame(rows, columns=["estimator", "n", "ldist", "contig", "comlang"])

out = tab.to_string(index=False)
import sys
nbytes = sys.stdout.write(out + "\n")
    estimator     n   ldist  contig  comlang
      Poisson 18632 -0.6923  0.6527  -0.0289
        Gamma 17107 -1.2999  0.9892   0.6628
Gaussian(log) 17107 -0.5921  0.8780  -0.2528
      log-OLS 17107 -1.3689  1.0078   0.9317
Code
sys.stdout.flush()
Code
quietly import delimited "../data/ctm-gravity.csv", clear varnames(1)
quietly keep if domestic == 0 & year == 2016 & dist < . & gdp_o < . & gdp_d < .
quietly generate ldist  = ln(dist)
quietly generate ltrade = ln(trade) if trade > 0
quietly egen expid = group(iso3_o)
quietly egen impid = group(iso3_d)

display "    estimator      n     ldist    contig   comlang"
quietly ppmlhdfe trade ldist contig comlang_off comcol, absorb(expid impid) vce(robust)
display %13s "Poisson" %7.0f e(N) %10.4f _b[ldist] ///
        %10.4f _b[contig] %10.4f _b[comlang_off]

quietly glm trade ldist contig comlang_off comcol i.expid i.impid if trade > 0, ///
        family(gamma) link(log) vce(robust) nolog
display %13s "Gamma" %7.0f e(N) %10.4f _b[ldist] ///
        %10.4f _b[contig] %10.4f _b[comlang_off]

quietly glm trade ldist contig comlang_off comcol i.expid i.impid if trade > 0, ///
        family(gaussian) link(log) vce(robust) nolog
display %13s "Gaussian(log)" %7.0f e(N) %10.4f _b[ldist] ///
        %10.4f _b[contig] %10.4f _b[comlang_off]

quietly reghdfe ltrade ldist contig comlang_off comcol, absorb(expid impid) vce(robust)
display %13s "log-OLS" %7.0f e(N) %10.4f _b[ldist] ///
        %10.4f _b[contig] %10.4f _b[comlang_off]
    estimator      n     ldist    contig   comlang

      Poisson  18632   -0.6923    0.6527   -0.0289

        Gamma  17107   -1.2999    0.9892    0.6628


Gaussian(log)  17107   -0.5921    0.8780   -0.2528

      log-OLS  17107   -1.3689    1.0078    0.9317

The four estimators split into two camps, and the dividing line is not the one you would guess.

\[ \hat\beta_{\text{dist}}: \quad \underbrace{-0.692}_{\text{Poisson}}, \;\; \underbrace{-0.592}_{\text{Gaussian}} \qquad\text{versus}\qquad \underbrace{-1.300}_{\text{Gamma}}, \;\; \underbrace{-1.369}_{\text{log-OLS}} \]

Gamma PML sits with log-OLS, not with Poisson — because \(V(\mu)=\mu^2\) gives every pair the same weight, and the small, distant, noisy flows that dominate the count then dominate the estimate too. In Part II’s simulated world Gamma was almost exactly right. Here it is not.

Both facts are consistent, and together they make the point: the choice of \(V(\mu)\) is an empirical question, not a convention.

Why Absorbing Beats Dummies

That table has 274 fixed effects in it — one per exporter and importer — and not one of them was printed, because not one of them was wanted. Two ways to get rid of them: estimate them and ignore them, or never form them at all.

The three tabs below time both routes on the same data and print the numbers. Read the timings off your run: they are machine- and load-dependent, and the ratio is not the point. The point is that the coefficient is identical.

Frisch–Waugh–Lovell. For a regression on \(\mathbf{X}\) and a matrix of dummies \(\mathbf{D}\), the coefficient on \(\mathbf{X}\) is unchanged if you instead residualise both sides on \(\mathbf{D}\) first:

\[ \hat{\boldsymbol\beta} \;=\; \big(\tilde{\mathbf{X}}'\tilde{\mathbf{X}}\big)^{-1}\tilde{\mathbf{X}}'\tilde{\mathbf{y}}, \qquad \tilde{\mathbf{X}} = \mathbf{M}_{\mathbf{D}}\mathbf{X} \]

This is an identity, not an approximation — which is why the timings below buy speed at no cost in accuracy.

With one fixed effect, \(\mathbf{M}_{\mathbf{D}}\mathbf{X}\) is just “subtract the group mean”: one pass over the data, no matrix anywhere. With two or more the projections do not commute, and there is no one-pass formula. But projecting alternately — demean by exporter, then by importer, then by exporter again — converges to the joint projection:

\[ \tilde{\mathbf{X}} \;=\; \lim_{k \to \infty} \big(\mathbf{M}_{1}\mathbf{M}_{2}\big)^{k}\,\mathbf{X} \]

That is the method of alternating projections. Each sweep costs \(O(n)\), and the dummies are never formed.

For a non-linear model, wrap it inside the IRLS loop: each Poisson iteration is a weighted least-squares problem, so absorb the fixed effects inside every iteration. That is exactly what fepois, pyfixest and ppmlhdfe do.

dummies absorbing
stored \(n \times (k + G)\) \(n \times k\)
per iteration \(O\!\big(n(k+G)^2\big)\) \(O(nk^2)\) plus a few \(O(n)\) sweeps
scales with \(G\) badly hardly at all

\(G\) is the number of fixed-effect levels. Here \(G\) is 274 and the sample is small; in Part III’s three-way panel \(G\) runs into the thousands, and the dummy route stops being merely slow and starts being impossible.

Code
d16 <- g[domestic == 0 & year == 2016 & !is.na(dist) & !is.na(gdp_o) & !is.na(gdp_d)]
d16[, ldist := log(dist)]

# absorbed: the fixed effects are projected out, never estimated
t_abs <- system.time(
  fit_abs <- fepois(trade ~ ldist + contig + comlang_off + comcol | iso3_o + iso3_d,
                    data = d16))[["elapsed"]]

# dummies: 274 extra columns in the design matrix, all of them estimated
t_dum <- system.time(
  fit_dum <- glm(trade ~ ldist + contig + comlang_off + comcol +
                   factor(iso3_o) + factor(iso3_d),
                 family = poisson, data = d16))[["elapsed"]]

cat(sprintf("absorbed   %6.2f s   ldist %.6f   (%d parameters)\n",
            t_abs, coef(fit_abs)["ldist"], length(coef(fit_abs))))
cat(sprintf("dummies    %6.2f s   ldist %.6f   (%d parameters)\n",
            t_dum, coef(fit_dum)["ldist"], length(coef(fit_dum))))
absorbed     0.03 s   ldist -0.692285   (4 parameters)
dummies     11.61 s   ldist -0.692285   (277 parameters)
Code
import time

d16 = g[(g.domestic == 0) & (g.year == 2016)].dropna(
          subset=["dist", "gdp_o", "gdp_d"]).copy()
d16["ldist"] = np.log(d16.dist)

t0 = time.time()
fit_abs = pf.fepois("trade ~ ldist + contig + comlang_off + comcol | iso3_o + iso3_d",
                    data=d16)
t_abs = time.time() - t0

t0 = time.time()
D = pd.get_dummies(d16[["iso3_o", "iso3_d"]], drop_first=True, dtype=float)
Xd = sm.add_constant(pd.concat([d16[["ldist", "contig", "comlang_off", "comcol"]]
                                .reset_index(drop=True), D.reset_index(drop=True)], axis=1))
fit_dum = sm.GLM(d16.trade.values, Xd, family=sm.families.Poisson()).fit()
t_dum = time.time() - t0

out = ("absorbed   %6.2f s   ldist %.6f   (%d parameters)\n"
       % (t_abs, fit_abs.coef()["ldist"], len(fit_abs.coef())) +
       "dummies    %6.2f s   ldist %.6f   (%d parameters)"
       % (t_dum, fit_dum.params["ldist"], Xd.shape[1]))
import sys
nbytes = sys.stdout.write(out + "\n")
absorbed     0.08 s   ldist -0.692285   (4 parameters)
dummies     26.15 s   ldist -0.692285   (277 parameters)
Code
sys.stdout.flush()
Code
quietly import delimited "../data/ctm-gravity.csv", clear varnames(1)
quietly keep if domestic == 0 & year == 2016 & dist < . & gdp_o < . & gdp_d < .
quietly generate ldist = ln(dist)
quietly egen expid = group(iso3_o)
quietly egen impid = group(iso3_d)

timer clear
timer on 1
quietly ppmlhdfe trade ldist contig comlang_off comcol, absorb(expid impid)
timer off 1
scalar b_abs = _b[ldist]

timer on 2
quietly poisson trade ldist contig comlang_off comcol i.expid i.impid
timer off 2
scalar b_dum = _b[ldist]

quietly timer list
display "absorbed  " %6.2f r(t1) " s   ldist " %10.6f b_abs
display "dummies   " %6.2f r(t2) " s   ldist " %10.6f b_dum
absorbed    0.80 s   ldist  -0.692285

dummies     8.16 s   ldist  -0.692285

The algorithm is published; only the timings are local.

  • Frisch & Waugh (1933), Partial Time Regressions as Compared with Individual Trends, Econometrica 1(4) — the partialling-out theorem. 10.2307/1907330
  • Lovell (1963), Seasonal Adjustment of Economic Time Series, JASA 58(304) — its general form. 10.1080/01621459.1963.10480682
  • Guimarães & Portugal (2010), A simple feasible procedure to fit models with high-dimensional fixed effects, Stata Journal 10(4) — iterative demeaning. 10.1177/1536867X1001000406
  • Gaure (2013), OLS with multiple high dimensional category variables, Computational Statistics & Data Analysis 66 — the method of alternating projections and its convergence. 10.1016/j.csda.2013.03.024
  • Correia (2017), Linear Models with High-Dimensional Fixed Effects: An Efficient and Feasible Estimatorreghdfe. scorreia.com
  • Correia, Guimarães & Zylkin (2020), Fast Poisson estimation with high-dimensional fixed effects, Stata Journal 20(1) — absorbing inside IRLS, i.e. ppmlhdfe. 10.1177/1536867X20909691

Same coefficient to six decimals in every tab, by both routes. The fixed effects were never the object of interest, and absorbing them is not a shortcut — it is the same estimator, computed without building something you were going to throw away.

The RESET Test

All four estimators above are consistent if the conditional mean is \(\exp(\mathbf{x}'\boldsymbol\beta)\). Santos Silva and Tenreyro’s RESET checks exactly that, and nothing else.

Fit the model, keep the linear predictor \(\hat{y}_{ij} = \mathbf{x}_{ij}'\hat{\boldsymbol\beta}\), then re-estimate with its square added:

\[ \mathbb{E}\!\left[X_{ij}\mid \mathbf{x}_{ij}\right] \;=\; \exp\!\left(\mathbf{x}_{ij}'\boldsymbol\beta + \gamma\,\hat{y}_{ij}^{\,2}\right) \]

Reject when the \(t\) statistic on \(\hat\gamma\) exceeds the usual critical value. Rejection says the exponential mean is misspecified — some non-linearity in \(\mathbf{x}\) has been left out.

\[ H_0: \gamma = 0 \quad\text{(mean correctly specified)} \]

Note what the test does not do: it says nothing about which variance function is right. A model can pass RESET and still be inefficient.

Code
reset_test <- function(fit, dat, poisson = TRUE) {
  dat <- copy(dat)
  dat[, yh2 := predict(fit, newdata = dat, type = "link")^2]
  aug <- if (poisson)
    fepois(trade ~ ldist + contig + comlang_off + comcol + yh2 | iso3_o + iso3_d,
           data = dat, vcov = "hetero", ssc = S)
  else
    feols(log(trade) ~ ldist + contig + comlang_off + comcol + yh2 | iso3_o + iso3_d,
          data = dat, vcov = "hetero", ssc = S)
  t <- coef(aug)["yh2"] / se(aug)["yh2"]
  c(gamma = coef(aug)["yh2"], t = t, p = 2 * pnorm(-abs(t)))
}
rbind(Poisson = reset_test(pois, e, TRUE),
      `log-OLS` = reset_test(lols, e[trade > 0], FALSE))
RESET test:  H0 = exponential mean correctly specified
 estimator    gamma       t          p
   Poisson -0.01471  -2.595  9.452e-03
   log-OLS -0.03587 -35.739 9.633e-280

reject H0 at 5% when |t| > 1.96
Code
from scipy import stats

def reset_test(fit, dat, poisson=True):
    d = dat.copy()
    d["yh2"] = fit.predict(newdata=d) ** 2
    f = "trade ~ ldist + contig + comlang_off + comcol + yh2 | iso3_o + iso3_d"
    if poisson:
        aug = pf.fepois(f, data=d, vcov="hetero", ssc=S)
    else:
        aug = pf.feols("np.log(trade) ~ ldist + contig + comlang_off + comcol + yh2"
                       " | iso3_o + iso3_d", data=d, vcov="hetero", ssc=S)
    t = aug.coef()["yh2"] / aug.se()["yh2"]
    return [round(aug.coef()["yh2"], 5), round(t, 3),
            float("%.4g" % (2 * stats.norm.cdf(-abs(t))))]

rt = pd.DataFrame([["Poisson"] + reset_test(pois, e, True),
                   ["log-OLS"] + reset_test(lols, pos, False)],
                  columns=["estimator", "gamma", "t", "p"])

out = ("RESET test:  H0 = exponential mean correctly specified\n\n"
       + rt.to_string(index=False) + "\n\nreject H0 at 5% when |t| > 1.96")
import sys
nbytes = sys.stdout.write(out + "\n")
RESET test:  H0 = exponential mean correctly specified

estimator    gamma       t             p
  Poisson -0.01471  -2.595  9.451000e-03
  log-OLS -0.03587 -35.739 9.633000e-280

reject H0 at 5% when |t| > 1.96
Code
sys.stdout.flush()
Code
quietly import delimited "../data/ctm-gravity.csv", clear varnames(1)
quietly keep if domestic == 0 & year == 2016 & dist < . & gdp_o < . & gdp_d < .
quietly generate ldist  = ln(dist)
quietly generate ltrade = ln(trade) if trade > 0
quietly egen expid = group(iso3_o)
quietly egen impid = group(iso3_d)

display "RESET test:  H0 = exponential mean correctly specified"
display "    estimator      gamma          t"

* the d option saves the sum of the fixed effects; predict, xbd needs it, and
* xbd (not xb) is what makes the linear predictor include them
quietly ppmlhdfe trade ldist contig comlang_off comcol, absorb(expid impid) vce(robust) d
quietly predict double yh, xbd
quietly generate double yh2 = yh^2
quietly ppmlhdfe trade ldist contig comlang_off comcol yh2, absorb(expid impid) vce(robust)
display %13s "Poisson" %11.5f _b[yh2] %11.3f _b[yh2]/_se[yh2]
quietly drop yh yh2

quietly reghdfe ltrade ldist contig comlang_off comcol, absorb(expid impid) vce(robust) resid
quietly predict double yh, xbd
quietly generate double yh2 = yh^2
quietly reghdfe ltrade ldist contig comlang_off comcol yh2, absorb(expid impid) vce(robust)
display %13s "log-OLS" %11.5f _b[yh2] %11.3f _b[yh2]/_se[yh2]

display "reject H0 at 5% when |t| > 1.96"
RESET test:  H0 = exponential mean correctly specified

    estimator      gamma          t

      Poisson   -0.01471     -2.595

      log-OLS   -0.03587    -35.448

reject H0 at 5% when |t| > 1.96

Both specifications are rejected, and the difference in degree is the finding.

PPML gives \(t = -2.60\): a rejection, but a marginal one on 18 632 observations, where any test has enormous power. Log-OLS gives \(t = -35.7\) and a \(p\) value with 280 leading zeros — the log-linear mean is not approximately wrong, it is comprehensively wrong.

RESET is the diagnostic to run before arguing about standard errors. An estimator whose mean function is rejected does not have a standard error worth computing.

Separation — When the Estimate Does Not Exist

Suppose every observation for which some fixed effect is active has \(X_{ij} = 0\). The Poisson likelihood is then maximised by driving that fixed effect to \(-\infty\):

\[ \hat\mu_{ij} = \exp(\hat\mu_i + \hat\nu_j) \;\to\; 0 \quad\text{as}\quad \hat\mu_i \to -\infty \]

The maximum does not exist in the interior. There is no finite estimate, no standard error, and no amount of iteration will find one.

Correia, Guimarães and Zylkin (2021) show this is not a curiosity: with high-dimensional fixed effects and many zeros it happens routinely, and a solver that merely stops iterating will report whatever it happened to reach.

The affected observations carry no information about \(\boldsymbol\beta\) and must be dropped — which is the correct fix, not a compromise.

arxiv.org/abs/1903.01633

Code
d <- fread("../data/ctm-itpde.csv")
sv <- d[broad_sector == "Services" & domestic == 0][, ldist := log(dist)]

# which exporter-year and importer-year cells have no positive flow at all?
sep_o <- sv[, .(pos = sum(trade > 0)), by = .(iso3_o, year)][pos == 0]
sep_d <- sv[, .(pos = sum(trade > 0)), by = .(iso3_d, year)][pos == 0]

fit <- fepois(trade ~ ldist + contig + comlang_off + fta_wto |
                iso3_o^year + iso3_d^year, data = sv, vcov = "hetero", ssc = S)
separated exporter-year cells:
 iso3_o  year   pos
 <char> <int> <int>
    KAZ  2004     0
separated importer-year cells:
 iso3_d  year   pos
 <char> <int> <int>
    KAZ  2004     0
    QAT  2004     0

rows in data 9159   observations used 9149   dropped 10
            Estimate Std. Error  z value Pr(>|z|)
ldist        -0.5872     0.0243 -24.1281   0.0000
contig        0.2786     0.0525   5.3034   0.0000
comlang_off   0.4483     0.0506   8.8538   0.0000
fta_wto       0.1432     0.0553   2.5917   0.0096
attr(,"vcov_type")
[1] "Heteroskedasticity-robust"
Code
d2 = pd.read_csv("../data/ctm-itpde.csv")
sv = d2[(d2.broad_sector == "Services") & (d2.domestic == 0)].copy()
sv["ldist"] = np.log(sv.dist)
sv["exp_y"] = sv.iso3_o + sv.year.astype(str)
sv["imp_y"] = sv.iso3_d + sv.year.astype(str)

sep_o = sv.groupby(["iso3_o", "year"]).trade.apply(lambda v: (v > 0).sum())
sep_d = sv.groupby(["iso3_d", "year"]).trade.apply(lambda v: (v > 0).sum())

fit = pf.fepois("trade ~ ldist + contig + comlang_off + fta_wto | exp_y + imp_y",
                data=sv, vcov="hetero", ssc=S)

out = ("separated exporter-year cells:\n" + sep_o[sep_o == 0].to_string() +
       "\nseparated importer-year cells:\n" + sep_d[sep_d == 0].to_string() +
       "\n\nrows in data %d   observations used %d   dropped %d\n"
       % (len(sv), fit._N, len(sv) - fit._N) + fit.tidy().round(4).to_string())
import sys
nbytes = sys.stdout.write(out + "\n")
separated exporter-year cells:
iso3_o  year
KAZ     2004    0
separated importer-year cells:
iso3_d  year
KAZ     2004    0
QAT     2004    0

rows in data 9159   observations used 9149   dropped 10
             Estimate  Std. Error  t value  Pr(>|t|)    2.5%   97.5%
Coefficient                                                         
ldist         -0.5872      0.0243 -24.1281    0.0000 -0.6349 -0.5395
contig         0.2786      0.0525   5.3034    0.0000  0.1756  0.3815
comlang_off    0.4483      0.0506   8.8538    0.0000  0.3491  0.5475
fta_wto        0.1432      0.0553   2.5917    0.0096  0.0349  0.2515
Code
sys.stdout.flush()
Code
quietly import delimited "../data/ctm-itpde.csv", clear varnames(1)
quietly keep if broad_sector == "Services" & domestic == 0
quietly generate ldist = ln(dist)
quietly egen expy = group(iso3_o year)
quietly egen impy = group(iso3_d year)

quietly egen pos_o = total(trade > 0), by(iso3_o year)
quietly egen pos_d = total(trade > 0), by(iso3_d year)
display "separated exporter-year cells:"
list iso3_o year if pos_o == 0, noobs clean
display "separated importer-year cells:"
list iso3_d year if pos_d == 0, noobs clean

ppmlhdfe trade ldist contig comlang_off fta_wto, absorb(expy impy) vce(robust)
separated exporter-year cells:

    iso3_o   year  
       KAZ   2004  
       KAZ   2004  
       KAZ   2004  

separated importer-year cells:

    iso3_d   year  
       KAZ   2004  
       QAT   2004  
       KAZ   2004  
       KAZ   2004  
       QAT   2004  
       KAZ   2004  
       QAT   2004  

(dropped 10 observations that are either singletons or separated by a fixed eff
> ect)
warning: dependent variable takes very low values after standardizing (3.9659e-
> 07)
Iteration 1:   deviance = 6.7221e+06  eps = .         iters = 4    tol = 1.0e-0
> 4                                                                            
>    min(eta) =  -4.55  P   
Iteration 2:   deviance = 4.0914e+06  eps = 6.43e-01  iters = 4    tol = 1.0e-0
> 4                                                                            
>    min(eta) =  -6.07      
Iteration 3:   deviance = 3.5873e+06  eps = 1.41e-01  iters = 4    tol = 1.0e-0
> 4                                                                            
>    min(eta) =  -7.62      
Iteration 4:   deviance = 3.5070e+06  eps = 2.29e-02  iters = 4    tol = 1.0e-0
> 4                                                                            
>    min(eta) =  -9.06      
Iteration 5:   deviance = 3.4945e+06  eps = 3.59e-03  iters = 3    tol = 1.0e-0
> 4                                                                            
>    min(eta) = -10.69      
Iteration 6:   deviance = 3.4927e+06  eps = 5.00e-04  iters = 3    tol = 1.0e-0
> 4                                                                            
>    min(eta) = -11.89      
Iteration 7:   deviance = 3.4926e+06  eps = 4.79e-05  iters = 2    tol = 1.0e-0
> 4                                                                            
>    min(eta) = -12.44      
Iteration 8:   deviance = 3.4926e+06  eps = 3.61e-06  iters = 2    tol = 1.0e-0
> 5                                                                            
>    min(eta) = -12.67      
Iteration 9:   deviance = 3.4926e+06  eps = 1.56e-07  iters = 2    tol = 1.0e-0
> 6                                                                            
>    min(eta) = -12.73   S  
Iteration 10:  deviance = 3.4926e+06  eps = 1.48e-09  iters = 3    tol = 1.0e-0
> 7                                                                            
>    min(eta) = -12.73   S  
Iteration 11:  deviance = 3.4926e+06  eps = 3.94e-13  iters = 2    tol = 1.0e-0
> 8                                                                            
>    min(eta) = -12.73   S  
Iteration 12:  deviance = 3.4926e+06  eps = 0.00e+00  iters = 2    tol = 1.0e-0
> 9                                                                            
>    min(eta) = -12.73   S O
-------------------------------------------------------------------------------
> -----------------------------
(legend: p: exact partial-out   s: exact solver   h: step-halving   o: epsilon 
> below tolerance)
Converged in 12 iterations and 35 HDFE sub-iterations (tol = 1.0e-08)

HDFE PPML regression                              No. of obs      =      9,149
Absorbing 2 HDFE groups                           Residual df     =      8,669
                                                  Wald chi2(4)    =    2158.89
Deviance             =  3492557.267               Prob > chi2     =     0.0000
Log pseudolikelihood = -1770699.789               Pseudo R2       =     0.9180
------------------------------------------------------------------------------
             |               Robust
       trade | Coefficient  std. err.      z    P>|z|     [95% conf. interval]
-------------+----------------------------------------------------------------
       ldist |  -.5872404   .0243397   -24.13   0.000    -.6349454   -.5395354
      contig |    .278585   .0525326     5.30   0.000      .175623    .3815469
 comlang_off |   .4482903   .0506354     8.85   0.000     .3490467    .5475339
     fta_wto |   .1432049   .0552589     2.59   0.010     .0348994    .2515104
       _cons |   13.18859   .2132811    61.84   0.000     12.77056    13.60661
------------------------------------------------------------------------------

Absorbed degrees of freedom:
-----------------------------------------------------+
 Absorbed FE | Categories  - Redundant  = Num. Coefs |
-------------+---------------------------------------|
        expy |       240           0         240     |
        impy |       241           5         236     |
-----------------------------------------------------+

Three languages, one problem, three different ways of telling you:

  • ppmlhdfe says dropped 10 observations that are either singletons or separated by a fixed effect — and it checks for separation before iterating, using the Correia–Guimarães–Zylkin algorithm.
  • fixest reports fixed-effects removed because of only 0 outcomes.
  • pyfixest raises a UserWarning.

All three then produce identical coefficients on the remaining 9 149 observations. The distance elasticity for services is \(-0.587\).

The danger is not these three. It is a solver that silently returns a large negative fixed effect and a coefficient computed from observations that contain no information.

Part IV in review

  • Every PML in this family is consistent if the mean is right. They differ in \(V(\mu)\), which determines efficiency and how much weight small flows get.
  • On the 2016 cross-section, Gamma PML lands with log-OLS at \(-1.30\) while Poisson lands at \(-0.69\). In Part II’s simulated world Gamma was almost exactly right. The correct variance function is an empirical question.
  • Poisson’s unique advantage is not that trade is a count. It is the adding-up property: fitted exports equal actual exports country by country, which is what makes the fixed effects usable as multilateral resistance in Part VI.
  • Run RESET. Here it rejects log-OLS at \(t = -35.7\) and PPML at \(t = -2.60\) — the same verdict, three orders of magnitude apart in severity.
  • Check separation. With many zeros and high-dimensional fixed effects there may be no finite maximum, and the affected observations must go.
  • Do not use negative binomial PML on trade values: it is not scale-invariant.
Task R Python Stata
Poisson PML fepois pf.fepois ppmlhdfe
Gamma / Gaussian PML feglm(family=) sm.GLM + dummies glm, family() link(log)
Linear predictor predict(type="link") .predict() predict, xb / xbd
RESET refit with yh2 refit with yh2 refit with yh2
Separation check note in output UserWarning reported by ppmlhdfe
Match Stata’s SEs ssc(adj = FALSE) pf.ssc(k_adj=False) default
  • feglm with a Gamma or log-link Gaussian family needs glm.iter = 200; the default 25 iterations silently stops short and reports a non-converged fit with a warning that is easy to miss.
  • Both Stata commands need help before predict, xbd will work: ppmlhdfe requires the d option at estimation and reghdfe requires resid``**. Without thempredicterrors; worse, plainxb` succeeds silently and gives a linear predictor with the fixed effects missing**, which turned the Poisson RESET statistic from \(-2.60\) into \(-1.57\).
  • Compute the RESET regressor from the linear predictor, not from fitted levels — squaring \(\exp(\mathbf{x}'\hat\beta)\) overflows on large flows.
  • Separation is a property of the data and the fixed effects together. Adding a fixed effect can create it; this deck’s manufacturing sample has none and its services sample has three cells.
  • Report the number of observations each estimator actually used. In the table above it ranges from 17 107 to 18 632 for the same “sample”.
  • The comparison table shows coefficients only, deliberately. For Gamma and log-link Gaussian, fixest::feglm’s robust standard errors do not match statsmodels or Stata’s glm — they treat the dispersion parameter differently. The point estimates agree to four decimals in all three; the robust standard errors do not, and a slide that showed both without saying so would be quietly misleading.

Part V — The Trade Elasticity

One Number Runs Everything

Parts I to IV estimated coefficients on distance, contiguity and trade agreements. Every one of them is \(-\theta\) times an elasticity of trade costs. Without \(\theta\), none of them can be turned into a policy statement.

And in Part VI, \(\theta\) alone determines the gains from trade:

\[ \hat{W}_i \;=\; \hat{\lambda}_{ii}^{\,-1/\theta} \]

Halve \(\theta\) and you double the welfare effect of every counterfactual. Arkolakis, Costinot and Rodríguez-Clare showed that across a whole class of models, the domestic share and \(\theta\) are sufficient statistics for welfare. Everything else in the model washes out.

So a factor-of-two disagreement about \(\theta\) is a factor-of-two disagreement about the value of the world trading system.

And it is the same \(\theta\) whatever story you believe about where trade comes from. Armington, Eaton–Kortum and Melitz–Chaney are three different micro-foundations with one common reduced form:

\[ \frac{\partial \log X_{ij}}{\partial \log \tau_{ij}} \;=\; -\theta \]

That is the ACR equivalence, and it is why this part can measure \(\theta\) without first committing to a model — and why the Melitz machinery in Part IX does not change any of Part VI’s welfare arithmetic.

\(\theta\) multiplies trade costs, and trade costs are unobserved:

\[ X_{ij} \;\propto\; \tau_{ij}^{-\theta}, \qquad \log \tau_{ij} = \delta_d \log D_{ij} + \dots \]

A gravity regression identifies the product \(\theta\delta_d\), never \(\theta\) alone. To separate them you need one of:

  • a trade cost you can observe in levels — a tariff (Caliendo–Parro)
  • the domestic diagonal, which pins \(\tau_{ii} = 1\) (Head–Ries)
  • an assumption about \(\delta\) from outside the data

There is no fourth option, and every estimate of \(\theta\) in the literature rests on one of these three.

Three classical estimators, each applied twice — once to the simulated world, where \(\theta = 4\) by construction, and once to real manufacturing data, where nobody knows:

  1. Head–Ries — the ratio identity, which needs the domestic diagonal
  2. Tetrads — differencing away both fixed effects with a reference pair
  3. Caliendo–Parro — triple differences on bilateral tariffs

Then all of them on one table against the known truth. The spread is the lesson.

The simulated data now carries three worlds, all drawn from seed 14159:

Column World
trade baseline, symmetric iceberg costs
trade_cf one country raises import costs 15% (Part VI)
trade_tar bilateral applied tariffs, 0–20%, \(t_{ij} \neq t_{ji}\)

The third exists because Caliendo–Parro’s triple difference is built to cancel anything separable into an exporter effect and an importer effect. Symmetric iceberg costs cancel. So does a uniform import surcharge by one country. Only genuinely pair-specific, direction-specific policy survives — which is exactly what a tariff schedule is, and exactly why they used tariffs.

Head–Ries — The Ratio Identity

Take the structural gravity equation and form the ratio of the two directions of trade between \(i\) and \(j\), divided by both countries’ internal trade:

\[ \frac{X_{ij}\,X_{ji}}{X_{ii}\,X_{jj}} \;=\; \frac{(c_i\tau_{ij})^{-\theta}(c_j\tau_{ji})^{-\theta}} {(c_i\tau_{ii})^{-\theta}(c_j\tau_{jj})^{-\theta}} \;=\; \left(\frac{\tau_{ij}\tau_{ji}}{\tau_{ii}\tau_{jj}}\right)^{-\theta} \]

Every exporter and importer term cancels — unit costs, multilateral resistance, expenditure, all of it. With \(\tau_{ii} = \tau_{jj} = 1\) the geometric-mean bilateral trade cost is:

\[ \tilde{\tau}_{ij} \;=\; \sqrt{\tau_{ij}\tau_{ji}} \;=\; \left(\frac{X_{ii}X_{jj}}{X_{ij}X_{ji}}\right)^{\frac{1}{2\theta}} \]

No regression, no fixed effects, no unobservables — but it delivers \(\tau\) given \(\theta\), not \(\theta\) itself. And it is impossible without \(X_{ii}\), which is why Part III switched to ITPD-E.

Head–Ries — Code

Code
s  <- fread("../data/ctm-sim.csv")
tr <- fread("../data/ctm-sim-truth.csv")
th <- tr$theta[1]
iso <- sort(unique(s$exporter))
M <- as.matrix(dcast(s, exporter ~ importer, value.var = "trade")[, -1])
rownames(M) <- iso
storage.mode(M) <- "double"      # rpois returns integers; products overflow

hr <- CJ(i = seq_along(iso), j = seq_along(iso))[i < j]
hr[, tau_hat  := ((M[cbind(i, i)] * M[cbind(j, j)]) /
                  (M[cbind(i, j)] * M[cbind(j, i)]))^(1 / (2 * th))]
hr[, tau_true := sqrt(as.matrix(dcast(s, exporter ~ importer,
                                      value.var = "tau_true")[, -1])[cbind(i, j)] *
                      as.matrix(dcast(s, exporter ~ importer,
                                      value.var = "tau_true")[, -1])[cbind(j, i)])]
hr <- hr[is.finite(tau_hat)]
cat(sprintf("correlation with the truth: %.4f\n", cor(hr$tau_hat, hr$tau_true)))
SIMULATED WORLD (theta known to be 4)
  pairs 1634   correlation with truth 0.7315
  mean tau: estimated 5.644  true 4.848   median abs error 0.4943
ITPD-E MANUFACTURING 2016, assuming theta = 4
  pairs 1221   median tau 3.23   quartiles 2.50 / 4.38
  cheapest pairs:
      o     dd   tau
 <char> <char> <num>
    JPN    TWN  1.03
    SGP    TWN  1.03
    BEL    NLD  1.05
    IDN    SGP  1.10
  costliest pairs:
      o     dd   tau
 <char> <char> <num>
    CRI    IRN 36.34
    CRI    KAZ 20.66
    IRN    LVA 16.69
    COL    IRN 16.24

  same data, theta = 6: median tau 2.19    theta = 8: 1.80
Code
import pandas as pd, numpy as np

s  = pd.read_csv("../data/ctm-sim.csv")
tr = pd.read_csv("../data/ctm-sim-truth.csv")
th = tr.theta[0]
M  = s.pivot(index="exporter", columns="importer", values="trade").values.astype(float)
TT = s.pivot(index="exporter", columns="importer", values="tau_true").values

iu = np.triu_indices(M.shape[0], k=1)
tau_hat  = ((M[iu[0], iu[0]] * M[iu[1], iu[1]]) /
            (M[iu] * M[iu[1], iu[0]])) ** (1 / (2 * th))
tau_true = np.sqrt(TT[iu] * TT[iu[1], iu[0]])
ok = np.isfinite(tau_hat)

d  = pd.read_csv("../data/ctm-itpde.csv")
mm = d[(d.broad_sector == "Manufacturing") & (d.year == 2016)]
MR = mm.pivot(index="iso3_o", columns="iso3_d", values="trade").values.astype(float)
ir = sorted(mm.iso3_o.unique())
ju = np.triu_indices(MR.shape[0], k=1)

def hr(theta):
    return ((MR[ju[0], ju[0]] * MR[ju[1], ju[1]]) /
            (MR[ju] * MR[ju[1], ju[0]])) ** (1 / (2 * theta))

# keep the labels aligned with the UNFILTERED array, then mask -- filtering
# first and indexing the names afterwards silently mislabels every pair
raw   = hr(4)
good  = np.isfinite(raw) & (raw > 0)
names = np.array([(ir[a], ir[b]) for a, b in zip(*ju)])
r4    = raw[good]
lab4  = names[good]
order = np.argsort(r4)

out = ("SIMULATED WORLD (theta known to be 4)\n"
       "  pairs %d   correlation with truth %.4f\n"
       % (ok.sum(), np.corrcoef(tau_hat[ok], tau_true[ok])[0, 1]) +
       "  mean tau: estimated %.3f  true %.3f   median abs error %.4f\n\n"
       % (tau_hat[ok].mean(), tau_true[ok].mean(),
          np.median(np.abs(tau_hat[ok] - tau_true[ok]))) +
       "ITPD-E MANUFACTURING 2016, assuming theta = 4\n"
       "  pairs %d   median tau %.2f   quartiles %.2f / %.2f\n"
       % (len(r4), np.median(r4), np.percentile(r4, 25), np.percentile(r4, 75)) +
       "  cheapest pairs: " + ", ".join("%s-%s %.2f" % (lab4[k][0], lab4[k][1], r4[k])
                                        for k in order[:4]) + "\n" +
       "  costliest pairs: " + ", ".join("%s-%s %.2f" % (lab4[k][0], lab4[k][1], r4[k])
                                         for k in order[::-1][:4]) + "\n\n" +
       "  same data, theta = 6: median tau %.2f    theta = 8: %.2f"
       % (np.median(hr(6)[good]), np.median(hr(8)[good])))
import sys
nbytes = sys.stdout.write(out + "\n")
SIMULATED WORLD (theta known to be 4)
  pairs 1634   correlation with truth 0.7315
  mean tau: estimated 5.644  true 4.848   median abs error 0.4943

ITPD-E MANUFACTURING 2016, assuming theta = 4
  pairs 1221   median tau 3.23   quartiles 2.50 / 4.38
  cheapest pairs: JPN-TWN 1.03, SGP-TWN 1.03, BEL-NLD 1.05, IDN-SGP 1.10
  costliest pairs: CRI-IRN 36.34, CRI-KAZ 20.66, IRN-LVA 16.69, COL-IRN 16.24

  same data, theta = 6: median tau 2.19    theta = 8: 1.80
Code
sys.stdout.flush()
Code
quietly import delimited "../data/ctm-itpde.csv", clear varnames(1)
quietly keep if broad_sector == "Manufacturing" & year == 2016
quietly keep iso3_o iso3_d trade

* domestic flows onto every row: X_ii for the exporter, X_jj for the importer
preserve
quietly keep if iso3_o == iso3_d
quietly rename trade x_oo
quietly keep iso3_o x_oo
tempfile dom
quietly save `dom'
restore
quietly merge m:1 iso3_o using `dom', keep(match) nogenerate
quietly rename iso3_d iso3_tmp
quietly rename iso3_o iso3_d
quietly rename iso3_tmp iso3_o
quietly rename x_oo x_dd
quietly merge m:1 iso3_o using `dom', keep(match) nogenerate
quietly rename x_oo x_oo2

* reverse flow X_ji
preserve
quietly keep iso3_o iso3_d trade
quietly rename trade x_ji
quietly rename iso3_o tmp
quietly rename iso3_d iso3_o
quietly rename tmp iso3_d
tempfile rev
quietly save `rev'
restore
quietly merge 1:1 iso3_o iso3_d using `rev', keep(match) nogenerate

quietly keep if iso3_o < iso3_d & trade > 0 & x_ji > 0
quietly generate double tau4 = ((x_oo2*x_dd)/(trade*x_ji))^(1/8)
quietly generate double tau6 = ((x_oo2*x_dd)/(trade*x_ji))^(1/12)
quietly generate double tau8 = ((x_oo2*x_dd)/(trade*x_ji))^(1/16)

display "ITPD-E MANUFACTURING 2016, assuming theta = 4"
quietly summarize tau4, detail
display "  pairs " r(N) "   median tau " %5.2f r(p50) ///
        "   quartiles " %5.2f r(p25) " / " %5.2f r(p75)
gsort tau4
display "  cheapest pairs:"
list iso3_o iso3_d tau4 in 1/4, noobs clean
gsort -tau4
display "  costliest pairs:"
list iso3_o iso3_d tau4 in 1/4, noobs clean
quietly summarize tau6, detail
scalar m6 = r(p50)
quietly summarize tau8, detail
display "  same data, theta = 6: median tau " %5.2f m6 "    theta = 8: " %5.2f r(p50)
ITPD-E MANUFACTURING 2016, assuming theta = 4


  pairs 1221   median tau  3.23   quartiles  2.50 /  4.38


  cheapest pairs:

    iso3_o   iso3_d        tau4  
       JPN      TWN   1.0287254  
       SGP      TWN   1.0332556  
       BEL      NLD   1.0530077  
       IDN      SGP    1.101956  


  costliest pairs:

    iso3_o   iso3_d        tau4  
       CRI      IRN   36.342424  
       CRI      KAZ   20.661793  
       IRN      LVA    16.69379  
       COL      IRN   16.236425  




  same data, theta = 6: median tau  2.19    theta = 8:  1.80

On the simulated world the estimator is unbiased in shape but not in level: it correlates 0.73 with the true trade costs and its mean is 5.64 against a truth of 4.85. On real manufacturing data with \(\theta = 4\) imposed, the cheapest pairs are Japan–Taiwan (1.03), Singapore–Taiwan (1.03) and Belgium–Netherlands (1.05); the costliest are Costa Rica–Iran (36.3) and Costa Rica–Kazakhstan (20.7).

Those rankings are believable. The levels are not robust: raise \(\theta\) from 4 to 8 and every trade cost falls by roughly half, because \(\theta\) enters as \(1/(2\theta)\). Head–Ries converts an assumption about \(\theta\) into a number about \(\tau\); it does not test it.

Tetrads — Differencing Away Both Fixed Effects

Pick a reference exporter \(k\) and a reference importer \(\ell\), with \(k \neq \ell\). Then form the double ratio:

\[ \frac{X_{ij}\,X_{k\ell}}{X_{i\ell}\,X_{kj}} \;=\; \left(\frac{\tau_{ij}\,\tau_{k\ell}}{\tau_{i\ell}\,\tau_{kj}}\right)^{-\theta} \]

\(i\) appears once in each of numerator and denominator, so its exporter term cancels; \(j\) likewise. Taking logs gives a regression with no fixed effects at all:

\[ \log\!\frac{X_{ij}X_{k\ell}}{X_{i\ell}X_{kj}} = -\theta\delta_d \log\!\frac{D_{ij}D_{k\ell}}{D_{i\ell}D_{kj}} + u_{ij} \]

Head, Mayer and Ries introduced this to study colonial trade, where the object of interest was how bilateral costs evolved and the fixed effects were a nuisance. It identifies \(\theta\delta_d\), the same product a gravity regression gives — so it does not solve the identification problem, it sidesteps the fixed effects.

Two practical warnings: the reference pair must have positive trade in all four cells, and the constructed observations reuse the same flows repeatedly, so the errors are mechanically correlated and naive standard errors are too small.

Tetrads — Code

Code
tot  <- rowSums(M) + colSums(M)
ord  <- order(-tot)
kref <- ord[1]; lref <- ord[2]          # two largest traders, k != l

tet <- CJ(i = 1:NN, j = 1:NN)[i != j & i != kref & j != lref & i != lref & j != kref]
tet[, y := log((M[cbind(i, j)] * M[kref, lref]) /
               (M[cbind(i, rep(lref, .N))] * M[cbind(rep(kref, .N), j)]))]
tet[, x := log((D[cbind(i, j)] * D[kref, lref]) /
               (D[cbind(i, rep(lref, .N))] * D[cbind(rep(kref, .N), j)]))]
tet <- tet[is.finite(y) & is.finite(x)]
summary(lm(y ~ x, data = tet))
SIMULATED WORLD   reference CHN / USA   n = 3097
  slope -1.3419 (se 0.0341)      truth -theta*delta_d = -1.00
ITPD-E MANUFACTURING 2016   reference USA / DEU   n = 2249
  slope -1.1501 (se 0.0236)      => theta * delta_d = 1.1501
Code
D  = s.pivot(index="exporter", columns="importer", values="dist").values
DR = mm.pivot(index="iso3_o", columns="iso3_d", values="dist").values

def tetrad(M, D, labels):
    n = M.shape[0]
    tot = np.nansum(M, 1) + np.nansum(M, 0)
    ordr = np.argsort(-tot)
    k, l = ordr[0], ordr[1]
    i, j = np.meshgrid(np.arange(n), np.arange(n), indexing="ij")
    i, j = i.ravel(), j.ravel()
    keep = (i != j) & (i != k) & (j != l) & (i != l) & (j != k)
    i, j = i[keep], j[keep]
    with np.errstate(divide="ignore", invalid="ignore"):
        y = np.log((M[i, j] * M[k, l]) / (M[i, l] * M[k, j]))
        x = np.log((D[i, j] * D[k, l]) / (D[i, l] * D[k, j]))
    ok = np.isfinite(y) & np.isfinite(x)
    X = np.column_stack([np.ones(ok.sum()), x[ok]])
    bhat = np.linalg.lstsq(X, y[ok], rcond=None)[0]
    r = y[ok] - X @ bhat
    v = (r @ r) / (ok.sum() - 2) * np.linalg.inv(X.T @ X)
    return labels[k], labels[l], int(ok.sum()), bhat[1], np.sqrt(v[1, 1])

k1, l1, n1, b1, s1 = tetrad(M, D, sorted(s.exporter.unique()))
k2, l2, n2, b2, s2 = tetrad(MR, DR, ir)

out = ("SIMULATED WORLD   reference %s / %s   n = %d\n" % (k1, l1, n1) +
       "  slope %.4f (se %.4f)      truth -theta*delta_d = %.2f\n\n"
       % (b1, s1, -tr.theta[0] * tr.delta_dist[0]) +
       "ITPD-E MANUFACTURING 2016   reference %s / %s   n = %d\n" % (k2, l2, n2) +
       "  slope %.4f (se %.4f)      => theta * delta_d = %.4f" % (b2, s2, -b2))
import sys
nbytes = sys.stdout.write(out + "\n")
SIMULATED WORLD   reference CHN / USA   n = 3097
  slope -1.3419 (se 0.0341)      truth -theta*delta_d = -1.00

ITPD-E MANUFACTURING 2016   reference USA / DEU   n = 2249
  slope -1.1501 (se 0.0236)      => theta * delta_d = 1.1501
Code
sys.stdout.flush()
Code
quietly import delimited "../data/ctm-itpde.csv", clear varnames(1)
quietly keep if broad_sector == "Manufacturing" & year == 2016
quietly keep iso3_o iso3_d trade dist

* reference exporter USA and reference importer DEU -- the two largest traders
quietly summarize trade if iso3_o == "USA" & iso3_d == "DEU", meanonly
scalar x_kl = r(mean)
quietly summarize dist if iso3_o == "USA" & iso3_d == "DEU", meanonly
scalar d_kl = r(mean)

preserve
quietly keep if iso3_d == "DEU"
quietly keep iso3_o trade dist
quietly rename trade x_il
quietly rename dist  d_il
tempfile refimp
quietly save `refimp'
restore
preserve
quietly keep if iso3_o == "USA"
quietly keep iso3_d trade dist
quietly rename trade x_kj
quietly rename dist  d_kj
tempfile refexp
quietly save `refexp'
restore

quietly merge m:1 iso3_o using `refimp', keep(match) nogenerate
quietly merge m:1 iso3_d using `refexp', keep(match) nogenerate
quietly drop if iso3_o == iso3_d
quietly drop if inlist(iso3_o, "USA", "DEU") | inlist(iso3_d, "USA", "DEU")
quietly keep if trade > 0 & x_il > 0 & x_kj > 0

quietly generate double y = ln((trade*x_kl)/(x_il*x_kj))
quietly generate double x = ln((dist *d_kl)/(d_il*d_kj))
display "ITPD-E MANUFACTURING 2016   reference USA / DEU"
regress y x
display "  => theta * delta_d = " %6.4f -_b[x]
ITPD-E MANUFACTURING 2016   reference USA / DEU

      Source |       SS           df       MS      Number of obs   =     2,249
-------------+----------------------------------   F(1, 2247)      =   2376.10
       Model |  3898.66465         1  3898.66465   Prob > F        =    0.0000
    Residual |  3686.83772     2,247  1.64078225   R-squared       =    0.5140
-------------+----------------------------------   Adj R-squared   =    0.5137
       Total |  7585.50237     2,248   3.3743338   Root MSE        =    1.2809

------------------------------------------------------------------------------
           y | Coefficient  Std. err.      t    P>|t|     [95% conf. interval]
-------------+----------------------------------------------------------------
           x |  -1.150105   .0235942   -48.75   0.000    -1.196373   -1.103836
       _cons |    .202202   .0298456     6.77   0.000     .1436741    .2607299
------------------------------------------------------------------------------

  => theta * delta_d = 1.1501

On the simulated world the tetrad slope is \(-1.342\) where the truth is \(-1.000\). The estimator is not unbiased — and the reason is the whole of Part I: this is ordinary least squares on a log ratio. Jensen’s inequality does not care that four flows were combined first.

On real data the tetrad slope is \(-1.150\), so \(\theta\delta_d \approx 1.15\). With \(\delta_d = 0.25\) that would be \(\theta \approx 4.6\); with \(\delta_d = 0.2\), \(\theta \approx 5.8\). The product is identified; the split is not.

Caliendo–Parro — Triple Differences on Tariffs

Take three countries \(i\), \(j\), \(k\) and form the ratio of the two directions around the triangle:

\[ \frac{X_{ij}X_{jk}X_{ki}}{X_{ik}X_{kj}X_{ji}} \;=\; \left(\frac{\tau_{ij}\tau_{jk}\tau_{ki}} {\tau_{ik}\tau_{kj}\tau_{ji}}\right)^{-\theta} \]

Each country appears once as exporter and once as importer in the numerator, and once in each role in the denominator. Every country-level term cancels — and so does any symmetric part of \(\tau\).

With observed applied tariffs \(t_{ij}\) entering as \(\tau_{ij} = \tilde\tau_{ij}(1 + t_{ij})\):

\[ \log\frac{X_{ij}X_{jk}X_{ki}}{X_{ik}X_{kj}X_{ji}} = -\theta \log\frac{(1+t_{ij})(1+t_{jk})(1+t_{ki})} {(1+t_{ik})(1+t_{kj})(1+t_{ji})} + u_{ijk} \]

The slope is \(-\theta\). No \(\delta\), no assumption, no diagonal — this is the one estimator that identifies \(\theta\) on its own.

Its price is data: it needs bilateral applied tariffs that differ by direction. A symmetric trade cost, or a uniform surcharge by one country, cancels along with everything else.

Code
Mt <- mkm("trade_tar")     # the tariff world
TR <- mkm("tariff")

set.seed(14159)
idx <- t(replicate(6000, sample(NN, 3)))
ok  <- apply(idx, 1, function(v) all(c(Mt[v[1], v[2]], Mt[v[2], v[3]], Mt[v[3], v[1]],
                                       Mt[v[1], v[3]], Mt[v[3], v[2]], Mt[v[2], v[1]]) > 0))
idx <- idx[ok, , drop = FALSE]

lhs <- apply(idx, 1, function(v) { i <- v[1]; j <- v[2]; k <- v[3]
  log((Mt[i, j] * Mt[j, k] * Mt[k, i]) / (Mt[i, k] * Mt[k, j] * Mt[j, i])) })
rhs <- apply(idx, 1, function(v) { i <- v[1]; j <- v[2]; k <- v[3]
  log(((1 + TR[i, j]) * (1 + TR[j, k]) * (1 + TR[k, i])) /
      ((1 + TR[i, k]) * (1 + TR[k, j]) * (1 + TR[j, i]))) })

summary(lm(lhs ~ rhs))          # slope = -theta
Caliendo-Parro on the tariff world:  triplets 4596
  slope -3.2670 (se 0.3162)   =>  theta = 3.2670      truth = 4
the same moment, estimated by PPML instead of OLS on logs:
  theta = 3.4164 (se 0.7931)   on all 3540 pairs

baseline world, where trade costs are symmetric:
  sd of the right-hand side = 1.14e-16   -- the moment is empty
Code
import pyfixest as pf

Mt = s.pivot(index="exporter", columns="importer", values="trade_tar").values.astype(float)
TR = s.pivot(index="exporter", columns="importer", values="tariff").values

rng = np.random.default_rng(14159)
n = Mt.shape[0]
idx = np.array([rng.choice(n, 3, replace=False) for _ in range(6000)])
i, j, k = idx[:, 0], idx[:, 1], idx[:, 2]
ok = ((Mt[i, j] > 0) & (Mt[j, k] > 0) & (Mt[k, i] > 0) &
      (Mt[i, k] > 0) & (Mt[k, j] > 0) & (Mt[j, i] > 0))
i, j, k = i[ok], j[ok], k[ok]

lhs = np.log((Mt[i, j] * Mt[j, k] * Mt[k, i]) / (Mt[i, k] * Mt[k, j] * Mt[j, i]))
rhs = np.log(((1 + TR[i, j]) * (1 + TR[j, k]) * (1 + TR[k, i])) /
             ((1 + TR[i, k]) * (1 + TR[k, j]) * (1 + TR[j, i])))
X = np.column_stack([np.ones(len(rhs)), rhs])
bh = np.linalg.lstsq(X, lhs, rcond=None)[0]
r = lhs - X @ bh
V = (r @ r) / (len(r) - 2) * np.linalg.inv(X.T @ X)

o = s[s.domestic == 0].copy()
o["lt"] = np.log(1 + o.tariff)
pp = pf.fepois("trade_tar ~ lt | exporter + importer", data=o,
               vcov="hetero", ssc=pf.ssc(k_adj=False))

TTm = s.pivot(index="exporter", columns="importer", values="tau_true").values
r0 = np.log((TTm[i, j] * TTm[j, k] * TTm[k, i]) / (TTm[i, k] * TTm[k, j] * TTm[j, i]))

out = ("Caliendo-Parro on the tariff world:  triplets %d\n" % len(lhs) +
       "  slope %.4f (se %.4f)   =>  theta = %.4f      truth = %.0f\n\n"
       % (bh[1], np.sqrt(V[1, 1]), -bh[1], tr.theta[0]) +
       "the same moment, estimated by PPML instead of OLS on logs:\n"
       "  theta = %.4f (se %.4f)   on all %d pairs\n"
       % (-pp.coef()["lt"], pp.se()["lt"], len(o)) +
       "\nbaseline world, where trade costs are symmetric:\n"
       "  sd of the right-hand side = %.2e   -- the moment is empty" % r0.std())
import sys
nbytes = sys.stdout.write(out + "\n")
Caliendo-Parro on the tariff world:  triplets 4598
  slope -3.1015 (se 0.3132)   =>  theta = 3.1015      truth = 4

the same moment, estimated by PPML instead of OLS on logs:
  theta = 3.4164 (se 0.7931)   on all 3540 pairs

baseline world, where trade costs are symmetric:
  sd of the right-hand side = 1.12e-16   -- the moment is empty
Code
sys.stdout.flush()
Code
quietly import delimited "../data/ctm-sim.csv", clear varnames(1)
quietly keep if domestic == 0
quietly generate double lt = ln(1 + tariff)
quietly egen expid = group(exporter)
quietly egen impid = group(importer)

display "the Caliendo-Parro moment, estimated by PPML rather than OLS on logs:"
ppmlhdfe trade_tar lt, absorb(expid impid) vce(robust)
display "  theta = " %6.4f -_b[lt] "  (se " %6.4f _se[lt] ")      truth = 4"
the Caliendo-Parro moment, estimated by PPML rather than OLS on logs:

Iteration 1:   deviance = 2.6798e+06  eps = .         iters = 4    tol = 1.0e-0
> 4                                                                            
>    min(eta) =  -3.69  P   
Iteration 2:   deviance = 2.2807e+06  eps = 1.75e-01  iters = 3    tol = 1.0e-0
> 4                                                                            
>    min(eta) =  -4.49      
Iteration 3:   deviance = 2.2513e+06  eps = 1.30e-02  iters = 3    tol = 1.0e-0
> 4                                                                            
>    min(eta) =  -4.92      
Iteration 4:   deviance = 2.2509e+06  eps = 1.82e-04  iters = 2    tol = 1.0e-0
> 4                                                                            
>    min(eta) =  -5.00      
Iteration 5:   deviance = 2.2509e+06  eps = 7.80e-08  iters = 2    tol = 1.0e-0
> 4                                                                            
>    min(eta) =  -5.01      
Iteration 6:   deviance = 2.2509e+06  eps = 3.66e-14  iters = 2    tol = 1.0e-0
> 5                                                                            
>    min(eta) =  -5.01   S  
Iteration 7:   deviance = 2.2509e+06  eps = 0.00e+00  iters = 2    tol = 1.0e-0
> 9                                                                            
>    min(eta) =  -5.01   S O
-------------------------------------------------------------------------------
> -----------------------------
(legend: p: exact partial-out   s: exact solver   h: step-halving   o: epsilon 
> below tolerance)
Converged in 7 iterations and 18 HDFE sub-iterations (tol = 1.0e-08)

HDFE PPML regression                              No. of obs      =      3,540
Absorbing 2 HDFE groups                           Residual df     =      3,420
                                                  Wald chi2(1)    =      18.55
Deviance             =  2250895.382               Prob > chi2     =     0.0000
Log pseudolikelihood =   -1136155.9               Pseudo R2       =     0.3832
------------------------------------------------------------------------------
             |               Robust
   trade_tar | Coefficient  std. err.      z    P>|z|     [95% conf. interval]
-------------+----------------------------------------------------------------
          lt |  -3.416414   .7931775    -4.31   0.000    -4.971013   -1.861814
       _cons |   6.742704    .083289    80.96   0.000     6.579461    6.905948
------------------------------------------------------------------------------

Absorbed degrees of freedom:
-----------------------------------------------------+
 Absorbed FE | Categories  - Redundant  = Num. Coefs |
-------------+---------------------------------------|
       expid |        60           0          60     |
       impid |        60           1          59     |
-----------------------------------------------------+

  theta = 3.4164  (se 0.7932)      truth = 4

Three ways of using the same identifying variation, on data where \(\theta = 4\):

Estimator \(\hat\theta\) Standard error
Triple differences, OLS on log ratios 3.27 0.32
PPML with tariffs and both fixed effects 3.42 0.79

(R, Python and Stata each draw their own 6 000 random triplets, so the OLS row moves by about \(\pm 0.2\) between the tabs; the PPML row uses every pair and is identical in all three.)

The triple difference is a log-ratio estimator, so it carries the Part I bias; the PPML version does not, but pays for it in precision because a \(0\)\(20\%\) tariff is a weak instrument for trade costs that range over a factor of ten.

The last line of each tab is the important one. On the baseline world, where trade costs are symmetric, the right-hand side has a standard deviation of about \(10^{-16}\) — the moment is numerically empty. Caliendo–Parro does not fail gracefully on symmetric data; it produces a slope of \(-8.6 \times 10^{13}\) and a standard error to match.

Sector by Sector — Elasticities and Coverage

Everything so far used manufacturing. ITPD-E has four broad sectors, and they do not behave alike — nor is the data equally good in all of them.

Code
d5 <- fread("../data/ctm-itpde.csv")
S5 <- ssc(adj = FALSE)

sector_row <- function(sc) {
  x  <- d5[broad_sector == sc]
  xi <- x[domestic == 0][, `:=`(ldist = log(dist),
                                exp_y = paste(iso3_o, year),
                                imp_y = paste(iso3_d, year))]
  p <- fepois(trade ~ ldist + contig + comlang_off | exp_y + imp_y,
              data = xi, vcov = ~iso3_o + iso3_d, ssc = S5)

  m16 <- x[year == 2016]
  ir  <- sort(unique(m16$iso3_o)); n <- length(ir)
  M <- as.matrix(dcast(m16, iso3_o ~ iso3_d, value.var = "trade")[, -1])
  rownames(M) <- ir; storage.mode(M) <- "double"
  D <- as.matrix(dcast(m16, iso3_o ~ iso3_d, value.var = "dist")[, -1]); rownames(D) <- ir
  hr <- CJ(i = 1:n, j = 1:n)[i < j]
  hr[, tau := ((M[cbind(i,i)] * M[cbind(j,j)]) / (M[cbind(i,j)] * M[cbind(j,i)]))^(1/8)]
  hr <- hr[is.finite(tau) & tau > 0]

  data.table(sector = sc, domestic = x[domestic == 1 & trade > 0, .N],
             hr_pairs = nrow(hr), ppml = coef(p)["ldist"], se = se(p)["ldist"],
             hr_med_tau = median(hr$tau))
}
rbindlist(lapply(c("Agriculture", "Mining and Energy", "Manufacturing", "Services"),
                 sector_row))
domestic  = country-sector-wave cells with a domestic flow, out of 250
hr_pairs  = pairs usable for Head-Ries in 2016, out of 1225
ppml, tetrad both estimate theta * delta_dist; hr_med_tau assumes theta = 4
            sector domestic hr_pairs ppml_ldist     se  tetrad hr_med_tau
            <char>    <int>    <int>      <num>  <num>   <num>      <num>
       Agriculture      236      980    -0.9105 0.0933 -2.0720       4.31
 Mining and Energy      198      695    -1.2103 0.1776 -2.7963       8.69
     Manufacturing      250     1221    -0.7895 0.0514 -1.1501       3.23
          Services      179      574    -0.6318 0.0670 -1.2903       6.09

total domestic coverage: 863 of 1000 cells
Code
import pyfixest as pf

d5 = pd.read_csv("../data/ctm-itpde.csv")
S5 = pf.ssc(k_adj=False)

def sector_row(sc):
    x = d5[d5.broad_sector == sc].copy()
    xi = x[x.domestic == 0].copy()
    xi["ldist"] = np.log(xi.dist)
    xi["exp_y"] = xi.iso3_o + xi.year.astype(str)
    xi["imp_y"] = xi.iso3_d + xi.year.astype(str)
    p = pf.fepois("trade ~ ldist + contig + comlang_off | exp_y + imp_y",
                  data=xi, vcov={"CRV1": "iso3_o + iso3_d"}, ssc=S5)

    m16 = x[x.year == 2016]
    ir  = sorted(m16.iso3_o.unique()); n = len(ir)
    M = m16.pivot(index="iso3_o", columns="iso3_d", values="trade").values.astype(float)
    D = m16.pivot(index="iso3_o", columns="iso3_d", values="dist").values
    tot = np.nansum(M, 1) + np.nansum(M, 0)
    o = np.argsort(-tot); k, l = o[0], o[1]
    i, j = np.meshgrid(np.arange(n), np.arange(n), indexing="ij")
    i, j = i.ravel(), j.ravel()
    keep = (i != j) & (i != k) & (j != l) & (i != l) & (j != k)
    i, j = i[keep], j[keep]
    with np.errstate(divide="ignore", invalid="ignore"):
        y  = np.log((M[i, j] * M[k, l]) / (M[i, l] * M[k, j]))
        xx = np.log((D[i, j] * D[k, l]) / (D[i, l] * D[k, j]))
    ok = np.isfinite(y) & np.isfinite(xx)
    b = np.polyfit(xx[ok], y[ok], 1)[0]

    iu = np.triu_indices(n, k=1)
    with np.errstate(divide="ignore", invalid="ignore"):
        tau = ((M[iu[0], iu[0]] * M[iu[1], iu[1]]) /
               (M[iu] * M[iu[1], iu[0]])) ** (1 / 8)
    tau = tau[np.isfinite(tau) & (tau > 0)]
    return [sc, int(((x.iso3_o == x.iso3_d) & (x.trade > 0)).sum()), len(tau),
            round(p.coef()["ldist"], 4), round(p.se()["ldist"], 4),
            round(b, 4), round(float(np.median(tau)), 2)]

st = pd.DataFrame([sector_row(s) for s in
                   ["Agriculture", "Mining and Energy", "Manufacturing", "Services"]],
                  columns=["sector", "domestic", "hr_pairs", "ppml_ldist", "se",
                           "tetrad", "hr_med_tau"])

out = ("domestic  = country-sector-wave cells with a domestic flow, out of 250\n"
       "hr_pairs  = pairs usable for Head-Ries in 2016, out of 1225\n"
       "ppml, tetrad both estimate theta * delta_dist; hr_med_tau assumes theta = 4\n\n"
       + st.to_string(index=False) +
       "\n\ntotal domestic coverage: %d of 1000 cells" % st.domestic.sum())
import sys
nbytes = sys.stdout.write(out + "\n")
domestic  = country-sector-wave cells with a domestic flow, out of 250
hr_pairs  = pairs usable for Head-Ries in 2016, out of 1225
ppml, tetrad both estimate theta * delta_dist; hr_med_tau assumes theta = 4

           sector  domestic  hr_pairs  ppml_ldist     se  tetrad  hr_med_tau
      Agriculture       236       980     -0.9105 0.0933 -2.0720        4.31
Mining and Energy       198       695     -1.2103 0.1776 -2.7963        8.69
    Manufacturing       250      1221     -0.7895 0.0514 -1.1501        3.23
         Services       179       574     -0.6318 0.0670 -1.2903        6.09

total domestic coverage: 863 of 1000 cells
Code
sys.stdout.flush()
Code
set type double
* the tetrad column is produced in the R and Python tabs; here it would need the
* four-way merge of Part V's tetrad chunk repeated inside a sector loop
display "domestic = cells with a domestic flow out of 250; hr_pairs out of 1225"
display "          sector  domestic  hr_pairs  ppml_ldist        se"

foreach sc in "Agriculture" "Mining and Energy" "Manufacturing" "Services" {
  quietly import delimited "../data/ctm-itpde.csv", clear varnames(1)
  quietly keep if broad_sector == "`sc'"
  quietly count if iso3_o == iso3_d & trade > 0
  scalar ndom = r(N)
  quietly generate ldist = ln(dist)
  quietly egen expy  = group(iso3_o year)
  quietly egen impy  = group(iso3_d year)
  quietly egen expid = group(iso3_o)
  quietly egen impid = group(iso3_d)
  quietly ppmlhdfe trade ldist contig comlang_off if iso3_o != iso3_d, ///
      absorb(expy impy) cluster(expid impid)
  scalar b = _b[ldist]
  scalar sd = _se[ldist]

  quietly keep if year == 2016
  quietly keep iso3_o iso3_d trade
  preserve
  quietly keep if iso3_o == iso3_d
  quietly rename trade x_oo
  quietly keep iso3_o x_oo
  tempfile dom
  quietly save `dom'
  restore
  quietly merge m:1 iso3_o using `dom', keep(match) nogenerate
  quietly rename iso3_d tmp
  quietly rename iso3_o iso3_d
  quietly rename tmp iso3_o
  quietly rename x_oo x_dd
  quietly merge m:1 iso3_o using `dom', keep(match) nogenerate
  preserve
  quietly keep iso3_o iso3_d trade
  quietly rename trade x_ji
  quietly rename iso3_o t2
  quietly rename iso3_d iso3_o
  quietly rename t2 iso3_d
  tempfile rev
  quietly save `rev'
  restore
  quietly merge 1:1 iso3_o iso3_d using `rev', keep(match) nogenerate
  quietly count if iso3_o < iso3_d & trade > 0 & x_ji > 0 & x_oo > 0 & x_dd > 0

  display %16s "`sc'" %10.0f ndom %10.0f r(N) %12.4f b %10.4f sd
}
domestic = cells with a domestic flow out of 250; hr_pairs out of 1225

          sector  domestic  hr_pairs  ppml_ldist        se

 12.   scalar b = _b[ldist]
 13.   scalar sd = _se[ldist]
 14. 
 40. 
     Agriculture       236       980     -0.9105    0.0934
Mining and Energy       198       695     -1.2103    0.1777
   Manufacturing       250      1221     -0.7895    0.0514
        Services       179       574     -0.6318    0.0671

Three things, and only the first was expected.

Trade costs differ enormously by sector. The PPML distance elasticity runs from \(-1.21\) in mining and energy — bulk commodities, where freight is most of the cost — to \(-0.63\) in services, much of which travels down a wire. Applying manufacturing’s \(-0.79\) to all four would misprice both ends.

The tetrad estimate is 1.5 to 2.5 times the PPML one in every single sector. Agriculture \(-2.07\) against \(-0.91\); mining \(-2.80\) against \(-1.21\). This is Part V’s central point reproduced four times independently: tetrads are OLS on log ratios, and the bias does not average away.

The data is not equally good. Manufacturing has all 250 domestic cells and 1 221 of 1 225 usable Head–Ries pairs. Services has 179 cells and only 574 pairs — under half. Across all four sectors, 863 of 1 000 domestic cells exist.

That last line is why the services elasticity deserves less confidence than its standard error suggests. The standard error describes sampling variation in the observations you have; it says nothing about the 29% of the diagonal that is missing.

Five Estimators, One Known Truth

The simulated world has theta = 4, exactly.
                         estimator      identifies estimate  error
        Head-Ries (mean tau ratio) tau given theta    4.657  0.657
        Tetrads, OLS on log ratios theta * delta_d    5.368  1.368
 Caliendo-Parro, OLS on log ratios           theta    3.267 -0.733
            Caliendo-Parro by PPML           theta    3.416 -0.584
    PPML on log distance / delta_d           theta    4.549  0.549

Head-Ries and the distance route both need delta_d, which is known here
and unknown in any real dataset.

Five defensible estimators, one world, and answers spanning 3.3 to 5.4 — a factor of \(1.6\), against a truth we chose ourselves.

That range is not a failure of the simulation. It is Head and Mayer’s meta-analysis, reproduced under laboratory conditions: the published spread of \(\theta\) from 2 to 12 is mostly method, not sampling error.

Part VI is where this stops being an academic point. Welfare goes as \(\hat\lambda_{ii}^{-1/\theta}\), so an estimate of 3.3 rather than 5.6 inflates every gain-from-trade calculation by 70%.

Part V in review

  • Gravity identifies \(\theta\delta\), never \(\theta\). Separating them needs an observed cost in levels, the domestic diagonal, or an outside assumption.
  • Head–Ries cancels every unobservable exactly and gives \(\tau\) given \(\theta\). Its rankings are credible (Japan–Taiwan 1.03, Costa Rica–Iran 36.3); its levels move by a factor of two when \(\theta\) moves from 4 to 8.
  • Tetrads remove both fixed effects without estimating them, but identify the same product a gravity regression does.
  • Caliendo–Parro is the only one that identifies \(\theta\) alone — and it needs bilateral, direction-specific tariffs. On symmetric costs the moment is numerically empty and the estimator returns nonsense rather than a warning.
  • All three classical estimators are OLS on log ratios, and all three inherit the bias of Part I. On the known-truth world the tetrad slope is \(-1.34\) where the truth is \(-1.00\).
  • Five estimators, one world, \(\hat\theta\) from 3.3 to 5.6. Report which one you used and what it assumed.
Task R Python Stata
Flows to a matrix dcast + as.matrix DataFrame.pivot reshape wide or merges
Upper triangle CJ(i,j)[i < j] np.triu_indices keep if iso_o < iso_d
Attach \(X_{ii}\) to each row matrix index M[cbind(i,i)] M[i, i] fancy index merge m:1 on a domestic tempfile
Random triplets replicate(sample(n,3)) rng.choice(n,3) runiform + sort
The moment by PPML fepois pf.fepois ppmlhdfe
  • rpois returns integers. Head–Ries multiplies two diagonal flows, which overflows .Machine$integer.max and returns NA with only a warning. Set storage.mode(M) <- "double" immediately after building the matrix.
  • The tetrad reference exporter and importer must be different countries. Using the same one puts \(X_{kk}\) and \(D_{kk}\) in the formula, and the internal distance is missing — every observation becomes NA and the regression reports 0 non-NA cases rather than anything useful.
  • Tetrad and triple-difference observations reuse the same flows, so their errors are correlated by construction. Cluster on the reference pair, or bootstrap; do not quote the naive standard error.
  • Before running Caliendo–Parro, check that the right-hand side varies. If trade costs are symmetric the moment is identically zero and the regression will still return a number.
  • Head–Ries needs \(X_{ij}\), \(X_{ji}\), \(X_{ii}\) and \(X_{jj}\) all positive. On ITPD-E manufacturing that is nearly every pair; on services it is not.

Part VI — General Equilibrium & Exact Hat Algebra

From Estimation to Counterfactual

Part III estimated that a trade agreement raises bilateral trade by about \(7\%\). Now a minister asks: what happens to real wages if we leave one?

The coefficient cannot answer that, for three reasons.

  1. Leaving an agreement changes \(\tau_{ij}\) for one pair, which changes \(P_j\) for every country, which changes every other flow.
  2. Wages adjust until goods markets clear again. The coefficient holds them fixed.
  3. What the minister wants is welfare, not trade. Trade is an intermediate quantity.

General equilibrium is not a refinement here. It is the difference between a partial-equilibrium number and the question that was asked.

The naive route is to estimate every structural parameter — productivities, preferences, endowments — and re-solve the model. That is a calibration exercise with dozens of degrees of freedom.

Dekle, Eaton and Kortum’s insight is that you do not need any of them. Write everything in changes relative to the observed equilibrium and the levels cancel. What remains is:

  • observed trade shares \(\pi_{ij}\)
  • observed expenditure and income \(E_j\), \(Y_i\)
  • the trade elasticity \(\theta\)
  • the shock \(\hat\tau_{ij}\)

That is the whole input list. Everything else is inferred from the fact that the observed data is an equilibrium.

  1. The equilibrium system, in levels
  2. The same system in changes — exact hat algebra
  3. The algorithm, and why it converges
  4. The solver, in three languages
  5. Validation: does it recover a counterfactual we already know the answer to?
  6. The gains from trade, and how much they depend on \(\theta\)
  7. A real counterfactual: European disintegration
  8. Why the estimates must come from PPML and not log-OLS

The Armington General-Equilibrium System

\[ \pi_{ij} \;=\; \frac{\big(c_i \tau_{ij}\big)^{-\theta}} {\sum_k \big(c_k \tau_{kj}\big)^{-\theta}}, \qquad c_i = \frac{w_i}{A_i} \]

\[ Y_i \;=\; \sum_j \pi_{ij} E_j, \qquad Y_i = w_i L_i, \qquad E_j = Y_j + D_j \]

\(N\) equations in \(N\) wages, one redundant by Walras’ law, plus the definition of the price index:

\[ P_j \;=\; \Big[\sum_k (c_k\tau_{kj})^{-\theta}\Big]^{-1/\theta} \]

To solve this you need \(A_i\), \(L_i\) and \(\tau_{ij}\) in levels. None is observed.

\(D_j = E_j - Y_j\) is country \(j\)’s trade deficit. In the simulated world it is zero by construction. In real data it is not, and ignoring it is not a harmless simplification.

On ITPD-E manufacturing 2016 the largest imbalance is \(87.5\%\) of absorption and the median is \(12.4\%\). Imposing balanced trade on that data means the observed flows are not an equilibrium of the model you are solving, and the counterfactual inherits the contradiction.

The standard treatment — and the one used here — is to hold deficits fixed in levels through the counterfactual:

\[ E_j' \;=\; \hat{w}_j\, Y_j + D_j \]

Fixed nominal deficits are a modelling choice, not a result. A country running a large deficit is treated as continuing to receive the same transfer whatever happens to trade costs.

The alternative — deficits proportional to income — is equally defensible and gives different answers. Report which you used.

Concretely, on the European counterfactual below, imposing balanced trade instead turns Japan’s welfare change from \(+0.3\%\) into \(+8.3\%\). That is not a rounding difference; it is an artifact of forcing the data to satisfy a restriction it does not satisfy.

Exact Hat Algebra

Write \(\hat{x} = x'/x\) for the change in any variable. Divide the counterfactual system by the baseline system.

Trade shares:

\[ \hat{\pi}_{ij} \;=\; \frac{\big(\hat{w}_i \hat{\tau}_{ij}\big)^{-\theta}} {\sum_k \pi_{kj}\big(\hat{w}_k \hat{\tau}_{kj}\big)^{-\theta}} \]

The denominator is a share-weighted average of the shocks. That is the whole trick: the unobservable levels \(c_k^{-\theta}\) have been replaced by the observed shares \(\pi_{kj}\).

New shares and the market-clearing condition:

\[ \pi_{ij}' = \pi_{ij}\hat{\pi}_{ij}, \qquad \hat{w}_i Y_i \;=\; \sum_j \pi_{ij}'\big(\hat{w}_j Y_j + D_j\big) \]

Price index and welfare:

\[ \hat{P}_j = \Big[\sum_k \pi_{kj}\big(\hat{w}_k\hat{\tau}_{kj}\big)^{-\theta}\Big]^{-1/\theta}, \qquad \hat{W}_j = \frac{\hat{w}_j}{\hat{P}_j} = \hat{\lambda}_{jj}^{\,-1/\theta} \]

The last equality is Arkolakis, Costinot and Rodríguez-Clare’s. Real income change depends on the data only through the change in the domestic expenditure share:

\[ \hat{W}_j \;=\; \hat{\lambda}_{jj}^{\,-1/\theta} \]

Two sufficient statistics: \(\lambda_{jj}\) and \(\theta\). It holds for Armington, for Eaton–Kortum, and for Melitz with Pareto — models with completely different micro-foundations and identical welfare formulas.

That is why Part IX’s firm heterogeneity does not change a single number here, and why Part V’s factor-of-1.6 disagreement about \(\theta\) propagates directly into every welfare statement this deck makes.

Needed Not needed
baseline shares \(\pi_{ij}\) productivities \(A_i\)
income \(Y_i\) and deficits \(D_j\) endowments \(L_i\)
the elasticity \(\theta\) trade costs in levels \(\tau_{ij}\)
the shock \(\hat\tau_{ij}\) preference parameters

Only changes in trade costs are required. The level of \(\tau\) — the thing Head–Ries had to assume \(\theta\) to recover — never appears.

The Fixed-Point Algorithm

\[ \textbf{repeat: } \quad \hat{w}_i \;\leftarrow\; \hat{w}_i\left(1 + \psi\, \frac{\sum_j \pi_{ij}'\big(\hat{w}_j Y_j + D_j\big) - \hat{w}_i Y_i} {\hat{w}_i Y_i}\right) \]

  1. Guess \(\hat{w} = \mathbf{1}\).
  2. Form \(\hat\pi_{ij}\) from the current \(\hat{w}\) and the shock.
  3. Compute excess demand for each country’s goods: sales minus income.
  4. Raise the wage where demand exceeds supply, damped by \(\psi\).
  5. Normalise — fix world income, \(\sum_i \hat{w}_i Y_i = \sum_i Y_i\).
  6. Stop when the largest relative excess demand is below \(10^{-12}\).

Damping matters. With \(\psi = 1\) the iteration overshoots and oscillates; at \(\psi = 0.2\)\(0.3\) it converges monotonically in a few hundred steps.

Walras’ law makes one market-clearing condition redundant, so the system pins down only relative wages. Without a normalisation the iteration drifts along that indeterminacy and never converges.

Fixing world income is the natural choice because welfare is computed from \(\hat{w}/\hat{P}\), a ratio in which the normalisation cancels.

If a GE solver fails to converge, the normalisation is the first thing to check — before the damping and long before the elasticity.

The null shock. Set \(\hat\tau \equiv 1\) and solve. Every \(\hat{w}\) must come back exactly \(1\). On the real-data solver below this returns \(\max|\hat{w}-1| = 0\) — not approximately zero, exactly zero. Any solver that fails this has a bug in the baseline, not in the counterfactual.

The real-wage identity. \(\hat{w}_j/\hat{P}_j\) and \(\hat{\lambda}_{jj}^{-1/\theta}\) are computed by different routes and must agree. Below they agree to \(2\times10^{-16}\).

GE Solver — Code

Code
hat_algebra <- function(PI, Y, D, tauhat, theta,
                        psi = 0.3, tol = 1e-12, maxit = 2e5) {
  N <- nrow(PI)
  w <- rep(1, N); names(w) <- rownames(PI)
  one <- rep(1, N)
  for (it in seq_len(maxit)) {
    K   <- PI * (outer(w, one) * tauhat)^(-theta)   # pi_kj (what_k tauhat_kj)^-theta
    PIn <- sweep(K, 2, colSums(K), "/")             # counterfactual shares
    En  <- w * Y + D                                # expenditure, deficits fixed
    Z   <- as.vector(PIn %*% En) - w * Y            # excess demand
    gap <- max(abs(Z)) / max(w * Y)
    if (gap < tol) break
    w <- w * (1 + psi * Z / (w * Y))
    w <- w / (sum(w * Y) / sum(Y))                  # world income is the numeraire
  }
  K <- PI * (outer(w, one) * tauhat)^(-theta)
  list(what = w, PInew = sweep(K, 2, colSums(K), "/"),
       Phat = colSums(K)^(-1 / theta), it = it, gap = gap)
}
simulated world: 60 countries, 383 iterations, relative gap 9.71e-13
wage changes range from 0.9845 to 1.0509
Code
import pandas as pd, numpy as np

def hat_algebra(PI, Y, D, tauhat, theta, psi=0.3, tol=1e-12, maxit=200000):
    N = PI.shape[0]
    w = np.ones(N)
    for it in range(1, maxit + 1):
        K   = PI * (np.outer(w, np.ones(N)) * tauhat) ** (-theta)
        PIn = K / K.sum(axis=0)
        En  = w * Y + D
        Z   = PIn @ En - w * Y
        gap = np.max(np.abs(Z)) / np.max(w * Y)
        if gap < tol:
            break
        w = w * (1 + psi * Z / (w * Y))
        w = w / (np.sum(w * Y) / np.sum(Y))
    K = PI * (np.outer(w, np.ones(N)) * tauhat) ** (-theta)
    return dict(what=w, PInew=K / K.sum(axis=0),
                Phat=K.sum(axis=0) ** (-1 / theta), it=it, gap=gap)

s6  = pd.read_csv("../data/ctm-sim.csv")
tr6 = pd.read_csv("../data/ctm-sim-truth.csv").sort_values("iso3")
th6 = tr6.theta.iloc[0]
PI6   = s6.pivot(index="exporter", columns="importer", values="pi_true").values
TAU6  = s6.pivot(index="exporter", columns="importer", values="tau_true").values
TAU6c = s6.pivot(index="exporter", columns="importer", values="tau_cf").values
E6    = tr6.E.values

r6 = hat_algebra(PI6, E6, np.zeros(len(E6)), TAU6c / TAU6, th6)

out = ("simulated world: %d countries, %d iterations, relative gap %.2e\n"
       % (len(E6), r6["it"], r6["gap"]) +
       "wage changes range from %.4f to %.4f" % (r6["what"].min(), r6["what"].max()))
import sys
nbytes = sys.stdout.write(out + "\n")
simulated world: 60 countries, 383 iterations, relative gap 9.71e-13
wage changes range from 0.9845 to 1.0509
Code
sys.stdout.flush()
Code
set type double
quietly import delimited "../data/ctm-sim-truth.csv", clear varnames(1)
quietly sort iso3
mata: E = st_data(., "e")

quietly import delimited "../data/ctm-sim.csv", clear varnames(1)
quietly sort exporter importer
mata:
N   = 60
th  = 4
PI  = rowshape(st_data(., "pi_true"), N)
TH  = rowshape(st_data(., "tau_cf"), N) :/ rowshape(st_data(., "tau_true"), N)
w   = J(N, 1, 1)
one = J(1, N, 1)
for (it = 1; it <= 200000; it++) {
    K   = PI :* ((w * one) :* TH):^(-th)
    PIn = K :/ (J(N,1,1) * colsum(K))
    En  = w :* E
    Z   = PIn * En - En
    gap = max(abs(Z)) / max(En)
    if (gap < 1e-12) break
    w   = w :* (1 :+ 0.3 * Z :/ En)
    w   = w :/ (sum(w :* E) / sum(E))
}
K   = PI :* ((w * one) :* TH):^(-th)
PIn = K :/ (J(N,1,1) * colsum(K))
st_numscalar("it", it)
st_numscalar("gap", gap)
st_numscalar("wmin", min(w))
st_numscalar("wmax", max(w))
st_matrix("Wh", (diagonal(PIn) :/ diagonal(PI)):^(-1/th))
st_matrix("what", w)
end

display "simulated world: 60 countries, " it " iterations, relative gap " %8.2e gap
display "wage changes range from " %6.4f wmin " to " %6.4f wmax
------------------------------------------------- mata (type end to exit) -----
: N   = 60

: th  = 4

: PI  = rowshape(st_data(., "pi_true"), N)

: TH  = rowshape(st_data(., "tau_cf"), N) :/ rowshape(st_data(., "tau_true"), N
> )

: w   = J(N, 1, 1)

: one = J(1, N, 1)

: for (it = 1; it <= 200000; it++) {
>     K   = PI :* ((w * one) :* TH):^(-th)
>     PIn = K :/ (J(N,1,1) * colsum(K))
>     En  = w :* E
>     Z   = PIn * En - En
>     gap = max(abs(Z)) / max(En)
>     if (gap < 1e-12) break
>     w   = w :* (1 :+ 0.3 * Z :/ En)
>     w   = w :/ (sum(w :* E) / sum(E))
> }

: K   = PI :* ((w * one) :* TH):^(-th)

: PIn = K :/ (J(N,1,1) * colsum(K))

: st_numscalar("it", it)

: st_numscalar("gap", gap)

: st_numscalar("wmin", min(w))

: st_numscalar("wmax", max(w))

: st_matrix("Wh", (diagonal(PIn) :/ diagonal(PI)):^(-1/th))

: st_matrix("what", w)

: end
-------------------------------------------------------------------------------

simulated world: 60 countries, 383 iterations, relative gap  9.7e-13

wage changes range from 0.9845 to 1.0509

The matrix work runs in Mata, which has no matsize ceiling. rowshape turns the long CSV into a \(60\times60\) matrix directly, provided the data is sorted by exporter then importer — which is why the chunk sorts first.

Validation — Does It Recover a Known Answer?

The simulated world’s counterfactual was solved in levels when the world was drawn, using productivities and endowments the solver above never sees. The solver works only from shares, income and the shock.

If exact hat algebra is right, the two must agree.

Code
truth_w <- setNames(tr6$w_hat,       tr6$iso3)[iso6]
truth_W <- setNames(tr6$welfare_hat, tr6$iso3)[iso6]
Wh      <- (diag(r6$PInew) / diag(PI6))^(-1 / th6)

c(wages   = max(abs(r6$what - truth_w)),
  welfare = max(abs(Wh - truth_W)),
  identity = max(abs(r6$what / r6$Phat / (r6$what / r6$Phat)[1] - Wh / Wh[1])))
hat algebra versus the levels solution it never saw
  max |w_hat   - truth|                1.075e-11
  max |welfare - truth|                2.207e-13
  max |w_hat/P_hat - lambda^(-1/theta)| 2.220e-16   (the ACR identity)

target country CHN: welfare 0.99797 (truth 0.99797)
worst-hit country HKG: welfare 0.99105
Code
truth_w = tr6.w_hat.values
truth_W = tr6.welfare_hat.values
Wh = (np.diag(r6["PInew"]) / np.diag(PI6)) ** (-1 / th6)
rw = r6["what"] / r6["Phat"]

iso6 = sorted(s6.exporter.unique())
tgt = tr6.target.iloc[0]
k = iso6.index(tgt)

out = ("hat algebra versus the levels solution it never saw\n\n"
       "  max |w_hat   - truth|                %.3e\n" % np.max(np.abs(r6["what"] - truth_w)) +
       "  max |welfare - truth|                %.3e\n" % np.max(np.abs(Wh - truth_W)) +
       "  max |w_hat/P_hat - lambda^(-1/theta)| %.3e   (the ACR identity)\n"
       % np.max(np.abs(rw / rw[0] - Wh / Wh[0])) +
       "\ntarget country %s: welfare %.5f (truth %.5f)\n" % (tgt, Wh[k], truth_W[k]) +
       "worst-hit country %s: welfare %.5f" % (iso6[int(np.argmin(Wh))], Wh.min()))
import sys
nbytes = sys.stdout.write(out + "\n")
hat algebra versus the levels solution it never saw

  max |w_hat   - truth|                1.075e-11
  max |welfare - truth|                2.209e-13
  max |w_hat/P_hat - lambda^(-1/theta)| 2.220e-16   (the ACR identity)

target country CHN: welfare 0.99797 (truth 0.99797)
worst-hit country HKG: welfare 0.99105
Code
sys.stdout.flush()
Code
set type double
quietly import delimited "../data/ctm-sim-truth.csv", clear varnames(1)
quietly sort iso3
mata: E = st_data(., "e"); WHT = st_data(., "w_hat"); WFT = st_data(., "welfare_hat")

quietly import delimited "../data/ctm-sim.csv", clear varnames(1)
quietly sort exporter importer
mata:
N = 60 ; th = 4
PI = rowshape(st_data(., "pi_true"), N)
TH = rowshape(st_data(., "tau_cf"), N) :/ rowshape(st_data(., "tau_true"), N)
w  = J(N,1,1) ; one = J(1,N,1)
for (it = 1; it <= 200000; it++) {
    K = PI :* ((w * one) :* TH):^(-th)
    PIn = K :/ (J(N,1,1) * colsum(K))
    En = w :* E
    Z = PIn * En - En
    gap = max(abs(Z)) / max(En)
    if (gap < 1e-12) break
    w = w :* (1 :+ 0.3 * Z :/ En)
    w = w :/ (sum(w :* E) / sum(E))
}
K = PI :* ((w * one) :* TH):^(-th)
PIn = K :/ (J(N,1,1) * colsum(K))
Ph = colsum(K):^(-1/th)
Wh = (diagonal(PIn) :/ diagonal(PI)):^(-1/th)
rw = w :/ Ph'
st_numscalar("ew", max(abs(w - WHT)))
st_numscalar("eW", max(abs(Wh - WFT)))
st_numscalar("ei", max(abs(rw :/ rw[1] - Wh :/ Wh[1])))
end

display "hat algebra versus the levels solution it never saw"
display "  max |w_hat   - truth|                 " %9.3e ew
display "  max |welfare - truth|                 " %9.3e eW
display "  max |w_hat/P_hat - lambda^(-1/theta)| " %9.3e ei "   (the ACR identity)"
------------------------------------------------- mata (type end to exit) -----
: N = 60 ; th = 4

: PI = rowshape(st_data(., "pi_true"), N)

: TH = rowshape(st_data(., "tau_cf"), N) :/ rowshape(st_data(., "tau_true"), N)

: w  = J(N,1,1) ; one = J(1,N,1)

: for (it = 1; it <= 200000; it++) {
>     K = PI :* ((w * one) :* TH):^(-th)
>     PIn = K :/ (J(N,1,1) * colsum(K))
>     En = w :* E
>     Z = PIn * En - En
>     gap = max(abs(Z)) / max(En)
>     if (gap < 1e-12) break
>     w = w :* (1 :+ 0.3 * Z :/ En)
>     w = w :/ (sum(w :* E) / sum(E))
> }

: K = PI :* ((w * one) :* TH):^(-th)

: PIn = K :/ (J(N,1,1) * colsum(K))

: Ph = colsum(K):^(-1/th)

: Wh = (diagonal(PIn) :/ diagonal(PI)):^(-1/th)

: rw = w :/ Ph'

: st_numscalar("ew", max(abs(w - WHT)))

: st_numscalar("eW", max(abs(Wh - WFT)))

: st_numscalar("ei", max(abs(rw :/ rw[1] - Wh :/ Wh[1])))

: end
-------------------------------------------------------------------------------

hat algebra versus the levels solution it never saw

  max |w_hat   - truth|                  1.08e-11

  max |welfare - truth|                  2.21e-13

  max |w_hat/P_hat - lambda^(-1/theta)|  2.22e-16   (the ACR identity)

The solver reproduces the levels solution to \(1.1\times10^{-11}\) on wages and \(2.2\times10^{-13}\) on welfare, in all three languages. The ACR identity holds to \(2.2\times10^{-16}\) — machine precision.

That is the strongest claim this deck makes, and it is worth being precise about what it establishes. It does not show the model is true. It shows the algebra is exact: given the right shares and the right \(\theta\), hat algebra recovers what a full structural solution would have produced, without knowing a single productivity or endowment.

Every counterfactual that follows rests on that.

The Gains from Trade

Set \(\hat\tau_{ij} \to \infty\) for \(i \neq j\) and the domestic share goes to one. ACR then gives the welfare cost of autarky in closed form — no solver required:

\[ \text{gains from trade}_i \;=\; \lambda_{ii}^{-1/\theta} - 1 \]

Two observable numbers, and nothing else about the model survives into the answer. That is a strong claim, and it is worth seeing where it comes from.

Code
d6 <- fread("../data/ctm-itpde.csv")
m6 <- d6[broad_sector == "Manufacturing" & year == 2016]
ir6 <- sort(unique(m6$iso3_o))
X6 <- as.matrix(dcast(m6, iso3_o ~ iso3_d, value.var = "trade")[, -1])
rownames(X6) <- ir6; storage.mode(X6) <- "double"

Eabs <- colSums(X6)                 # absorption
Yinc <- rowSums(X6)                 # income
Defi <- Eabs - Yinc                 # deficits
PIr  <- sweep(X6, 2, Eabs, "/")     # expenditure shares

gains <- function(theta) diag(PIr)^(-1 / theta) - 1
sapply(c(4, 6, 8), function(t) median(gains(t)))
ACR gains from trade, ITPD-E manufacturing 2016, theta = 4
  most to lose from autarky:
    iso lambda_ii gains_pct
 <char>     <num>     <num>
    MAR    0.0215    161.15
    LUX    0.0439    118.44
    ARG    0.0540    107.46
    TWN    0.0718     93.16
  least to lose:
    iso lambda_ii gains_pct
 <char>     <num>     <num>
    KOR    0.7729      6.65
    BRA    0.7760      6.55
    IND    0.8718      3.49
    IRN    0.8924      2.89

  median gains: theta=4 30.41%   theta=6 19.36%   theta=8 14.20%
  trade imbalances: max |D|/E = 0.875, median 0.124
Code
d6 = pd.read_csv("../data/ctm-itpde.csv")
m6 = d6[(d6.broad_sector == "Manufacturing") & (d6.year == 2016)]
ir6 = sorted(m6.iso3_o.unique())
X6 = m6.pivot(index="iso3_o", columns="iso3_d", values="trade").values.astype(float)

Eabs, Yinc = X6.sum(axis=0), X6.sum(axis=1)
Defi = Eabs - Yinc
PIr = X6 / Eabs
lam = np.diag(PIr)

g4 = lam ** (-1 / 4) - 1
ordr = np.argsort(-g4)
top = "\n".join("    %s  lambda_ii %.4f  gains %6.2f%%" % (ir6[k], lam[k], 100 * g4[k])
                for k in ordr[:4])
bot = "\n".join("    %s  lambda_ii %.4f  gains %6.2f%%" % (ir6[k], lam[k], 100 * g4[k])
                for k in ordr[-4:])

out = ("ACR gains from trade, ITPD-E manufacturing 2016, theta = 4\n\n"
       "  most to lose from autarky:\n" + top +
       "\n  least to lose:\n" + bot +
       "\n\n  median gains: theta=4 %.2f%%   theta=6 %.2f%%   theta=8 %.2f%%\n"
       % (100 * np.median(g4), 100 * np.median(lam ** (-1/6) - 1),
          100 * np.median(lam ** (-1/8) - 1)) +
       "  trade imbalances: max |D|/E = %.3f, median %.3f"
       % (np.max(np.abs(Defi / Eabs)), np.median(np.abs(Defi / Eabs))))
import sys
nbytes = sys.stdout.write(out + "\n")
ACR gains from trade, ITPD-E manufacturing 2016, theta = 4

  most to lose from autarky:
    MAR  lambda_ii 0.0215  gains 161.15%
    LUX  lambda_ii 0.0439  gains 118.44%
    ARG  lambda_ii 0.0540  gains 107.46%
    TWN  lambda_ii 0.0718  gains  93.16%
  least to lose:
    KOR  lambda_ii 0.7729  gains   6.65%
    BRA  lambda_ii 0.7760  gains   6.55%
    IND  lambda_ii 0.8718  gains   3.49%
    IRN  lambda_ii 0.8924  gains   2.89%

  median gains: theta=4 30.41%   theta=6 19.36%   theta=8 14.20%
  trade imbalances: max |D|/E = 0.875, median 0.124
Code
sys.stdout.flush()
Code
set type double
quietly import delimited "../data/ctm-itpde.csv", clear varnames(1)
quietly keep if broad_sector == "Manufacturing" & year == 2016

quietly bysort iso3_d: egen double absorb = total(trade)
quietly keep if iso3_o == iso3_d
quietly generate double lambda_ii = trade / absorb
quietly generate double g4 = lambda_ii^(-1/4) - 1
quietly generate double g6 = lambda_ii^(-1/6) - 1
quietly generate double g8 = lambda_ii^(-1/8) - 1

display "ACR gains from trade, ITPD-E manufacturing 2016, theta = 4"
gsort -g4
display "  most to lose from autarky:"
list iso3_o lambda_ii g4 in 1/4, noobs clean
gsort g4
display "  least to lose:"
list iso3_o lambda_ii g4 in 1/4, noobs clean
quietly summarize g4, detail
scalar m4 = r(p50)
quietly summarize g6, detail
scalar m6 = r(p50)
quietly summarize g8, detail
display "  median gains: theta=4 " %5.2f 100*m4 "%   theta=6 " %5.2f 100*m6 ///
        "%   theta=8 " %5.2f 100*r(p50) "%"
ACR gains from trade, ITPD-E manufacturing 2016, theta = 4


  most to lose from autarky:

    iso3_o   lambda_ii          g4  
       MAR    .0215011   1.6114688  
       LUX   .04392455   1.1843548  
       ARG   .05398424   1.0745947  
       TWN   .07183283   .93160917  


  least to lose:

    iso3_o   lambda_ii          g4  
       IRN   .89235995   .02888061  
       IND   .87180154   .03489334  
       BRA   .77599411   .06545569  
       KOR   .77293008   .06651004  






  median gains: theta=4 30.41%   theta=6 19.36%   theta=8 14.20%

Median gains from manufacturing trade are \(30.4\%\) of real income at \(\theta = 4\). Taiwan, with a domestic share of \(0.07\), would lose \(93\%\); Iran, at \(0.89\), would lose \(2.9\%\).

Now look at the elasticity row. The same data gives \(30.4\%\), \(19.4\%\) or \(14.2\%\) depending on whether \(\theta\) is 4, 6 or 8 — every one of which Part V produced from the same world. The gains from trade are known to within a factor of two, and the uncertainty is almost entirely about \(\theta\).

Counterfactual — European Disintegration

Raise trade costs \(20\%\) on every intra-EU manufacturing flow. 24 EU members are in the sample; deficits are held fixed in levels.

Code
eu <- intersect(unique(m6[eu_o == 1]$iso3_o), ir6)

TH <- matrix(1, NR6, NR6, dimnames = list(ir6, ir6))
TH[eu, eu] <- 1.20
diag(TH) <- 1                       # internal trade is unaffected

null <- hat_algebra(PIr, Yinc, Defi, matrix(1, NR6, NR6), 4)   # sanity check
res  <- hat_algebra(PIr, Yinc, Defi, TH, 4)
W    <- (diag(res$PInew) / diag(PIr))^(-1 / 4)
null-shock check: max |w_hat - 1| = 0.0e+00
counterfactual: 114 iterations, relative gap 9.2e-12
  biggest losers:
    iso    eu welfare_pct
 <char> <int>       <num>
    LUX     1      -17.21
    NLD     1      -13.27
    SVN     1      -12.56
    LVA     1      -11.94
    SVK     1      -11.63
  biggest gainers:
    iso    eu welfare_pct
 <char> <int>       <num>
    UKR     0        1.45
    ISR     0        1.53
    NOR     0        2.00
    MAR     0        3.60

  mean EU -9.09%   mean non-EU +0.73%   world (absorption-weighted) -2.30%
Code
eu = sorted(set(m6[m6.eu_o == 1].iso3_o) & set(ir6))
ieu = np.array([c in eu for c in ir6])

TH = np.ones((len(ir6), len(ir6)))
TH[np.ix_(ieu, ieu)] = 1.20
np.fill_diagonal(TH, 1.0)

null = hat_algebra(PIr, Yinc, Defi, np.ones_like(TH), 4, psi=0.2, tol=1e-11)
res  = hat_algebra(PIr, Yinc, Defi, TH, 4, psi=0.2, tol=1e-11)
W = (np.diag(res["PInew"]) / np.diag(PIr)) ** (-1 / 4)

ordr = np.argsort(W)
lose = "\n".join("    %s  eu=%d  %+7.2f%%" % (ir6[k], int(ieu[k]), 100 * (W[k] - 1))
                 for k in ordr[:5])
gain = "\n".join("    %s  eu=%d  %+7.2f%%" % (ir6[k], int(ieu[k]), 100 * (W[k] - 1))
                 for k in ordr[-4:])

out = ("null-shock check: max |w_hat - 1| = %.1e\n" % np.max(np.abs(null["what"] - 1)) +
       "counterfactual: %d iterations, relative gap %.1e\n\n" % (res["it"], res["gap"]) +
       "  biggest losers:\n" + lose + "\n  biggest gainers:\n" + gain +
       "\n\n  mean EU %.2f%%   mean non-EU %+.2f%%   world (absorption-weighted) %.2f%%"
       % (100 * (W[ieu].mean() - 1), 100 * (W[~ieu].mean() - 1),
          100 * ((W * Eabs).sum() / Eabs.sum() - 1)))
import sys
nbytes = sys.stdout.write(out + "\n")
null-shock check: max |w_hat - 1| = 0.0e+00
counterfactual: 114 iterations, relative gap 9.2e-12

  biggest losers:
    LUX  eu=1   -17.21%
    NLD  eu=1   -13.27%
    SVN  eu=1   -12.56%
    LVA  eu=1   -11.94%
    SVK  eu=1   -11.63%
  biggest gainers:
    UKR  eu=0    +1.45%
    ISR  eu=0    +1.53%
    NOR  eu=0    +2.00%
    MAR  eu=0    +3.60%

  mean EU -9.09%   mean non-EU +0.73%   world (absorption-weighted) -2.30%
Code
sys.stdout.flush()
Code
set type double
quietly import delimited "../data/ctm-itpde.csv", clear varnames(1)
quietly keep if broad_sector == "Manufacturing" & year == 2016
quietly sort iso3_o iso3_d
quietly levelsof iso3_o, local(ctys)
mata:
N   = 50
th  = 4
X   = rowshape(st_data(., "trade"), N)
EUo = rowshape(st_data(., "eu_o"), N)
Eab = colsum(X)'
Yin = rowsum(X)
Def = Eab - Yin
PI  = X :/ (J(N,1,1) * Eab')
eu  = (rowmax(EUo) :== 1)
TH  = J(N, N, 1)
for (i = 1; i <= N; i++) for (j = 1; j <= N; j++) if (eu[i] & eu[j] & i != j) TH[i,j] = 1.20
w   = J(N,1,1) ; one = J(1,N,1)
for (it = 1; it <= 300000; it++) {
    K   = PI :* ((w * one) :* TH):^(-th)
    PIn = K :/ (J(N,1,1) * colsum(K))
    En  = w :* Yin + Def
    Z   = PIn * En - w :* Yin
    gap = max(abs(Z)) / max(w :* Yin)
    if (gap < 1e-11) break
    w   = w :* (1 :+ 0.2 * Z :/ (w :* Yin))
    w   = w :/ (sum(w :* Yin) / sum(Yin))
}
K   = PI :* ((w * one) :* TH):^(-th)
PIn = K :/ (J(N,1,1) * colsum(K))
W   = (diagonal(PIn) :/ diagonal(PI)):^(-1/th)
st_numscalar("nit", it)
st_numscalar("ngap", gap)
st_numscalar("meu",  mean(select(W, eu)))
st_numscalar("mnon", mean(select(W, eu :== 0)))
st_numscalar("wld",  sum(W :* Eab) / sum(Eab))
st_matrix("Wout", W)
end

display "counterfactual: " nit " iterations, relative gap " %8.1e ngap
display "  mean EU " %6.2f 100*(meu-1) "%   mean non-EU " %6.2f 100*(mnon-1) ///
        "%   world (absorption-weighted) " %6.2f 100*(wld-1) "%"
------------------------------------------------- mata (type end to exit) -----
: N   = 50

: th  = 4

: X   = rowshape(st_data(., "trade"), N)

: EUo = rowshape(st_data(., "eu_o"), N)

: Eab = colsum(X)'

: Yin = rowsum(X)

: Def = Eab - Yin

: PI  = X :/ (J(N,1,1) * Eab')

: eu  = (rowmax(EUo) :== 1)

: TH  = J(N, N, 1)

: for (i = 1; i <= N; i++) for (j = 1; j <= N; j++) if (eu[i] & eu[j] & i != j)
>  TH[i,j] = 1.20
> w   = J(N,1,1) ; one = J(1,N,1)

: for (it = 1; it <= 300000; it++) {
>     K   = PI :* ((w * one) :* TH):^(-th)
>     PIn = K :/ (J(N,1,1) * colsum(K))
>     En  = w :* Yin + Def
>     Z   = PIn * En - w :* Yin
>     gap = max(abs(Z)) / max(w :* Yin)
>     if (gap < 1e-11) break
>     w   = w :* (1 :+ 0.2 * Z :/ (w :* Yin))
>     w   = w :/ (sum(w :* Yin) / sum(Yin))
> }

: K   = PI :* ((w * one) :* TH):^(-th)

: PIn = K :/ (J(N,1,1) * colsum(K))

: W   = (diagonal(PIn) :/ diagonal(PI)):^(-1/th)

: st_numscalar("nit", it)

: st_numscalar("ngap", gap)

: st_numscalar("meu",  mean(select(W, eu)))

: st_numscalar("mnon", mean(select(W, eu :== 0)))

: st_numscalar("wld",  sum(W :* Eab) / sum(Eab))

: st_matrix("Wout", W)

: end
-------------------------------------------------------------------------------

counterfactual: 114 iterations, relative gap  9.2e-12

  mean EU  -9.09%   mean non-EU   0.73%   world (absorption-weighted)  -2.30%

The null-shock check returns \(\max|\hat{w}-1| = 0\) exactly. The counterfactual converges in about 110 iterations.

Leaving the European single market costs its members \(9.1\%\) of real income on average, with the small open economies worst hit: Luxembourg \(-17.2\%\), the Netherlands \(-13.3\%\), Slovenia \(-12.6\%\). Outside the EU the average change is \(+0.7\%\) — Morocco, Norway, Israel and Ukraine gain from trade diversion — but the world as a whole loses \(2.3\%\).

These are one-sector manufacturing numbers with \(\theta = 4\) and fixed deficits. Part VII asks what changes when production is linked across sectors.

Why the Estimates Must Come From PPML

The counterfactual needs \(\pi_{ij}\) that are internally consistent: predicted exports must add up to actual exports, country by country. Otherwise the baseline “equilibrium” does not balance and every change is measured from a fiction.

Code
ppml <- fepois(trade ~ ldist + contig + comlang_off + fta_wto | iso3_o + iso3_d,
               data = m6f, vcov = "hetero", ssc = ssc(adj = FALSE))
m6f[, fit_ppml := predict(ppml, type = "response")]

ols <- feols(log(trade) ~ ldist + contig + comlang_off + fta_wto | iso3_o + iso3_d,
             data = m6f[trade > 0])
m6f[trade > 0, fit_ols := exp(predict(ols))]

m6f[, .(actual = sum(trade), ppml = sum(fit_ppml)), by = iso3_o]
do predicted exports add up to actual exports, country by country?
  PPML    max relative error  5.027e-08
  log-OLS max relative error  1.770e+01

PPML's first-order condition makes this an identity. Nothing else does.
Code
import pyfixest as pf

m6f = m6.copy()
m6f["ldist"] = np.log(m6f.dist)
ppml = pf.fepois("trade ~ ldist + contig + comlang_off + fta_wto | iso3_o + iso3_d",
                 data=m6f, vcov="hetero", ssc=pf.ssc(k_adj=False))
# type="response" -- pyfixest predicts the LINK by default, and the adding-up
# check then fails by 100% instead of 6e-09
m6f["fit_ppml"] = ppml.predict(type="response")

pos6 = m6f[m6f.trade > 0].copy()
ols = pf.feols("np.log(trade) ~ ldist + contig + comlang_off + fta_wto | iso3_o + iso3_d",
               data=pos6)
pos6["fit_ols"] = np.exp(ols.predict())

ap = m6f.groupby("iso3_o").agg(a=("trade", "sum"), f=("fit_ppml", "sum"))
ao = pos6.groupby("iso3_o").agg(a=("trade", "sum"), f=("fit_ols", "sum"))

out = ("do predicted exports add up to actual exports, country by country?\n\n"
       "  PPML    max relative error  %.3e\n" % np.max(np.abs(ap.f - ap.a) / ap.a) +
       "  log-OLS max relative error  %.3e\n" % np.max(np.abs(ao.f - ao.a) / ao.a) +
       "\nPPML's first-order condition makes this an identity. Nothing else does.")
import sys
nbytes = sys.stdout.write(out + "\n")
do predicted exports add up to actual exports, country by country?

  PPML    max relative error  5.919e-09
  log-OLS max relative error  1.770e+01

PPML's first-order condition makes this an identity. Nothing else does.
Code
sys.stdout.flush()
Code
set type double
quietly import delimited "../data/ctm-itpde.csv", clear varnames(1)
quietly keep if broad_sector == "Manufacturing" & year == 2016
quietly generate ldist  = ln(dist)
quietly generate ltrade = ln(trade) if trade > 0
quietly egen expid = group(iso3_o)
quietly egen impid = group(iso3_d)

quietly ppmlhdfe trade ldist contig comlang_off fta_wto, absorb(expid impid) vce(robust) d
* mu = exp(xb + d): the fitted LEVEL. Plain predict gives the linear index, and
* the adding-up check then fails by 100% rather than 5e-08.
quietly predict double fit_ppml, mu
quietly bysort iso3_o: egen double a1 = total(trade)
quietly bysort iso3_o: egen double f1 = total(fit_ppml)
quietly generate double e1 = abs(f1 - a1)/a1
quietly summarize e1
scalar ep = r(max)

quietly reghdfe ltrade ldist contig comlang_off fta_wto, absorb(expid impid) vce(robust) resid
quietly predict double lfit, xbd
quietly generate double fit_ols = exp(lfit) if trade > 0
quietly bysort iso3_o: egen double a2 = total(trade) if trade > 0
quietly bysort iso3_o: egen double f2 = total(fit_ols)
quietly generate double e2 = abs(f2 - a2)/a2
quietly summarize e2

display "do predicted exports add up to actual exports, country by country?"
display "  PPML    max relative error  " %9.3e ep
display "  log-OLS max relative error  " %9.3e r(max)
display "PPML's first-order condition makes this an identity. Nothing else does."
do predicted exports add up to actual exports, country by country?

  PPML    max relative error   7.45e-08

  log-OLS max relative error   1.77e+01

PPML's first-order condition makes this an identity. Nothing else does.

PPML reproduces every country’s total exports to within \(10^{-7}\) in all three languages — the solvers’ own convergence tolerance. Exponentiated log-OLS is out by a factor of 18.

This is Fally’s result, and it is the reason Parts III to V insisted on PPML. An estimator whose fitted values do not add up cannot supply the baseline shares a general-equilibrium counterfactual needs.

Part VI in review

  • Exact hat algebra needs only \(\pi_{ij}\), \(Y_i\), \(D_j\), \(\theta\) and the shock. Productivities, endowments and trade-cost levels all cancel.
  • Validated against a levels solution it never saw, it agrees to \(1.1\times10^{-11}\) on wages and \(2.2\times10^{-13}\) on welfare.
  • ACR: \(\hat W = \hat\lambda_{ii}^{-1/\theta}\). Two sufficient statistics, and the same formula for Armington, Eaton–Kortum and Melitz.
  • Median gains from manufacturing trade are \(30.4\%\) at \(\theta = 4\) and \(14.2\%\) at \(\theta = 8\). Part V’s disagreement about \(\theta\) is the uncertainty about the gains from trade.
  • European disintegration at \(+20\%\) trade costs: EU members lose \(9.1\%\), Luxembourg \(17.2\%\), the world \(2.3\%\).
  • Trade deficits are not negligible — up to \(87.5\%\) of absorption here. Forcing balanced trade changed Japan’s answer from \(+0.3\%\) to \(+8.3\%\).
  • PPML’s fitted values add up by construction; log-OLS’s are out by 1 800%.
Task R Python Stata
Long CSV to \(N\times N\) dcast + as.matrix DataFrame.pivot mata: rowshape
Column-wise divide sweep(K, 2, colSums(K), "/") K / K.sum(axis=0) K :/ (J(N,1,1)*colsum(K))
Fixed point for + damping for + damping Mata for + damping
Extract to the dataset st_numscalar, st_matrix
Group totals by = groupby bysort ... egen total()
  • rowshape assumes the sort order. mata: rowshape(st_data(., "x"), 60) fills by rows, so the data must be sorted by exporter then importer first. Sort in the chunk, never assume the CSV arrived that way.
  • Normalise every iteration, not at the end. Walras’ law leaves relative wages only; without a numeraire the iteration wanders and never converges.
  • Run the null shock. Set \(\hat\tau\equiv 1\); every \(\hat w\) must return exactly 1. It catches baseline errors that a plausible-looking counterfactual will hide.
  • Check the ACR identity as a second, independent route to welfare. If \(\hat w/\hat P\) and \(\hat\lambda_{ii}^{-1/\theta}\) disagree, the price index or the share update is wrong.
  • Damping \(\psi \approx 0.2\)\(0.3\). At \(\psi = 1\) the iteration oscillates; the fix is never to raise the tolerance.
  • Report \(\theta\), the deficit treatment and the aggregation. Any of the three can move a headline welfare number by more than the policy being studied.

Part VII — Multi-Sector Models with Input–Output Linkages

Part VII — The Problem & the Data

Part VI answered “what if European trade costs rise \(20\%\)” with one number per country. It could not answer any of these:

  • A tariff on cars and steel — Part VI has no cars and no steel.
  • Which industries shrink? — there is only one.
  • What happens to services when manufacturing is taxed? — services do not exist, and even if they did, nothing connects them.

That last question is the one that matters most. A car is made of steel, and the steel plant buys electricity, logistics and insurance. Tax the car and the effect does not stop at the car factory.

../data/ctm-icio.csv — the 2022 inter-country input–output table, aggregated from 46 countries × 64 industries to 12 regions × 8 sectors.

\(Z\) 9 216 intermediate flows, region-sector to region-sector
\(F\) 1 152 final-demand flows
\(VA\) 96 value-added cells
world gross output 190 937 bn EUR
world value added 94 006 bn EUR

The aggregation preserves the accounting identity to \(4.7\times10^{-14}\): row-side and column-side gross output agree. Everything Part VII computes is derived from this one file.

  1. Why one sector is not enough — stated precisely
  2. Caliendo–Parro: the model, and where the linkages enter
  3. Calibrating \(\gamma\), \(\alpha\) and \(\pi\) from FIGARO, with checks
  4. The nested solver
  5. A tariff on metals and transport equipment, and where it lands

The shock comes from ../data/ctm-tariffs.csv: the applied tariffs the EU and the United States actually charge each other, taken to zero. Rates are WITS/TRAINS MFN simple averages at HS6 for 2020, aggregated to the six goods-producing sectors.

MFN is the correct rate here because the two have no free-trade agreement, so each applies its most-favoured-nation schedule to the other. WITS returns 404 for the bilateral query — which is that same fact, stated as an error.

Why One Sector Is Not Enough

Composition. Countries do not specialise uniformly. Germany’s trade is concentrated in metals and transport equipment; Morocco’s is not. A uniform trade cost hits them very differently, and a one-sector model cannot see it.

Different elasticities. \(\theta\) is not the same across sectors. Homogeneous commodities substitute readily; differentiated services barely at all. Using one \(\theta\) for both misprices every counterfactual.

Linkages. This is the one with no single-sector counterpart. Output in sector \(k\) uses inputs from every sector \(s\):

\[ c^k_j \;=\; w_j^{\,\gamma^L_{jk}} \prod_{s} \big(P^s_j\big)^{\gamma^{sk}_j}, \qquad \gamma^L_{jk} + \sum_s \gamma^{sk}_j = 1 \]

A tariff raises \(P^s_j\), which raises the cost of every sector that uses \(s\), which raises their prices, and so on. The multiplier is the Leontief inverse.

Jump ahead to the answer. Tariffs exist in six of the eight sectors; trade in distribution (CTT) and services (SRV) carries none. Measured across all 96 region-sector cells:

\[ \text{mean } |\Delta \text{output}| = 0.149\% \text{ in the tariffed sectors}, \quad 0.035\% \text{ in the two untariffed ones} \]

\(7.2\%\) of all the output reallocation happens in sectors that carry no tariff at all. In a one-sector model that number is zero by construction, and in a multi-sector model without linkages it is close to zero. Services move because they are an input to everything that does get liberalised.

The gain is not free. The multi-sector model needs:

  • an input–output table, which exists for far fewer countries and years than bilateral trade data
  • sector-specific elasticities, which Part V showed are hard to pin down even for one sector
  • a nested fixed point — prices inside expenditure inside wages — that can fail to converge in ways the one-sector solver cannot

This deck uses \(\theta_s = 4\) for all sectors, which is a simplification and is stated as one. Caliendo and Parro estimate sector-specific values and find them ranging from about 0.4 to over 50.

Caliendo–Parro in Changes

Costs, with the linkages:

\[ \hat{c}^k_j \;=\; \hat{w}_j^{\,\gamma^L_{jk}} \prod_{s}\big(\hat{P}^s_j\big)^{\gamma^{sk}_j} \]

Sectoral price indices, which reference the costs of every supplier:

\[ \hat{P}^s_j \;=\; \Big[\sum_i \pi^s_{ij}\big(\hat{c}^s_i\,\hat{\kappa}^s_{ij}\big)^{-\theta_s}\Big]^{-1/\theta_s} \]

Those two are mutually defining — costs depend on prices, prices on costs. That is the inner fixed point, and it is the mathematical form of “steel is an input to cars and cars are an input to steel plants”.

New shares:

\[ \pi'^{\,s}_{ij} \;=\; \frac{\pi^s_{ij}\big(\hat{c}^s_i\hat{\kappa}^s_{ij}\big)^{-\theta_s}} {\sum_k \pi^s_{kj}\big(\hat{c}^s_k\hat{\kappa}^s_{kj}\big)^{-\theta_s}} \]

Expenditure on sector \(s\) in region \(j\) has two parts — intermediate demand from every sector, and final demand:

\[ X^s_j \;=\; \sum_k \gamma^{sk}_j\, Q^k_j \;+\; \alpha^s_j\, I_j, \qquad Q^k_j = \sum_n \pi'^{\,k}_{jn} X^k_n \]

Income is value added plus the deficit:

\[ I_j \;=\; \hat{w}_j \sum_k \gamma^L_{jk}Q^k_j \;+\; D_j \]

And labour-market clearing closes the system:

\[ \hat{w}_j \, VA_j \;=\; \sum_k \gamma^L_{jk} Q^k_j \]

Real income uses the final-expenditure-weighted price index:

\[ \hat{W}_j \;=\; \hat{w}_j \Big/ \prod_s \big(\hat{P}^s_j\big)^{\alpha^s_j} \]

Loop Solves for Given Converges in
inner \(\hat{P}^s_j\) \(\hat{w}\), shares ~10 passes
middle \(X^s_j\) \(\hat{w}\), \(\pi'\) ~30 passes
outer \(\hat{w}_j\) everything ~70 passes

The ACR shortcut does not apply here. With intermediates, welfare is no longer \(\hat\lambda_{jj}^{-1/\theta}\) — the domestic share is no longer a sufficient statistic, and the price index must be computed sector by sector. That is the price of the extra structure.

Calibration — \(\gamma\), \(\alpha\) and \(\pi\) from FIGARO

Everything the solver needs is a share, and every share must sum to one. The checks below are not decoration: a calibration error here produces a solver that converges smoothly to the wrong answer.

Code
io <- fread("../data/ctm-icio.csv")
regs <- sort(unique(io[flow == "Z"]$orig_reg)); N <- length(regs)
secs <- sort(unique(io[flow == "Z"]$orig_sec)); S <- length(secs)
ri <- setNames(seq_len(N), regs); si <- setNames(seq_len(S), secs)

Z <- array(0, c(N, S, N, S), dimnames = list(regs, secs, regs, secs))
z <- io[flow == "Z"]
Z[cbind(ri[z$orig_reg], si[z$orig_sec], ri[z$dest_reg], si[z$dest_sec])] <- z$value
Fd <- array(0, c(N, S, N), dimnames = list(regs, secs, regs))
fd <- io[flow == "F"]
Fd[cbind(ri[fd$orig_reg], si[fd$orig_sec], ri[fd$dest_reg])] <- fd$value
VA <- matrix(0, N, S, dimnames = list(regs, secs))
va <- io[flow == "VA"]
VA[cbind(ri[va$dest_reg], si[va$dest_sec])] <- va$value

Q <- apply(Z, c(1, 2), sum) + apply(Fd, c(1, 2), sum)      # gross output
GAM <- array(0, c(S, S, N), dimnames = list(secs, secs, regs))
for (j in 1:N) for (k in 1:S) for (s in 1:S)
  GAM[s, k, j] <- sum(Z[, s, j, k]) / Q[j, k]              # input shares
GL <- VA / Q                                               # value-added shares
12 regions x 8 sectors   world gross output 190937 bn EUR
  trade shares  sum_i pi[i,j,s] = 1        max error 2.22e-16
  cost shares   sum_s gamma + gamma_L = 1  max error 4.63e-14
  final shares  sum_s alpha[s,j] = 1       max error 0.00e+00
  world value added = world final demand   difference 3.73e-07
  deficits sum to zero                     3.83e-07   max |D|/E = 0.047
value-added share of gross output, by sector (world):
  AGR   CHE   CTT   FDT   MET   MIN   SRV   TRE 
0.576 0.309 0.482 0.289 0.309 0.504 0.642 0.266 
Code
import numpy as np, pandas as pd

io = pd.read_csv("../data/ctm-icio.csv")
regs = sorted(io[io.flow == "Z"].orig_reg.unique()); N = len(regs)
secs = sorted(io[io.flow == "Z"].orig_sec.unique()); S = len(secs)
ri = {r: i for i, r in enumerate(regs)}; si = {s: i for i, s in enumerate(secs)}

Z = np.zeros((N, S, N, S)); z = io[io.flow == "Z"]
Z[z.orig_reg.map(ri), z.orig_sec.map(si), z.dest_reg.map(ri), z.dest_sec.map(si)] = z.value
Fd = np.zeros((N, S, N)); fd = io[io.flow == "F"]
Fd[fd.orig_reg.map(ri), fd.orig_sec.map(si), fd.dest_reg.map(ri)] = fd.value
VA = np.zeros((N, S)); va = io[io.flow == "VA"]
VA[va.dest_reg.map(ri), va.dest_sec.map(si)] = va.value

Q   = Z.sum((2, 3)) + Fd.sum(2)
SUP = Z.sum(3) + Fd                     # SUP[i,s,j]
PI  = SUP / SUP.sum(0)
GAM = np.einsum("isjk->skj", Z) / Q.T[None, :, :]
GL  = VA / Q
ALPHA = Fd.sum(0) / Fd.sum(0).sum(0)    # [s,j]
VAtot = VA.sum(1); FDtot = Fd.sum(0).sum(0); D = FDtot - VAtot
ABS = SUP.sum(0)

out = ("%d regions x %d sectors   world gross output %.0f bn EUR\n"
       % (N, S, Q.sum() / 1000) +
       "  trade shares  sum_i pi[i,j,s] = 1        max error %.2e\n"
       % np.max(np.abs(PI.sum(0) - 1)) +
       "  cost shares   sum_s gamma + gamma_L = 1  max error %.2e\n"
       % np.max(np.abs(GAM.sum(0).T + GL - 1)) +
       "  final shares  sum_s alpha[s,j] = 1       max error %.2e\n"
       % np.max(np.abs(ALPHA.sum(0) - 1)) +
       "  world value added = world final demand   difference %.2e\n"
       % abs(VAtot.sum() - FDtot.sum()) +
       "  deficits sum to zero                     %.2e   max |D|/E = %.3f\n\n"
       % (D.sum(), np.max(np.abs(D / FDtot))) +
       "value-added share of gross output, by sector (world):\n" +
       "  ".join("%s %.3f" % (secs[k], VA[:, k].sum() / Q[:, k].sum()) for k in range(S)))
import sys
nbytes = sys.stdout.write(out + "\n")
12 regions x 8 sectors   world gross output 190937 bn EUR
  trade shares  sum_i pi[i,j,s] = 1        max error 4.44e-16
  cost shares   sum_s gamma + gamma_L = 1  max error 4.64e-14
  final shares  sum_s alpha[s,j] = 1       max error 1.11e-16
  world value added = world final demand   difference 3.87e-07
  deficits sum to zero                     3.88e-07   max |D|/E = 0.047

value-added share of gross output, by sector (world):
AGR 0.576  CHE 0.309  CTT 0.482  FDT 0.289  MET 0.309  MIN 0.504  SRV 0.642  TRE 0.266
Code
sys.stdout.flush()
Code
set type double
* gross output of each region-sector = its intermediate sales + its final sales
quietly import delimited "../data/ctm-icio.csv", clear varnames(1)
quietly keep if flow == "Z" | flow == "F"
quietly collapse (sum) value, by(orig_reg orig_sec)
quietly rename (orig_reg orig_sec value) (reg sec q)
tempfile gross
quietly save `gross'

* intermediate inputs bought by each region-sector
quietly import delimited "../data/ctm-icio.csv", clear varnames(1)
quietly keep if flow == "Z"
quietly collapse (sum) value, by(dest_reg dest_sec)
quietly rename (dest_reg dest_sec value) (reg sec zin)
tempfile inputs
quietly save `inputs'

* value added of each region-sector
quietly import delimited "../data/ctm-icio.csv", clear varnames(1)
quietly keep if flow == "VA"
quietly collapse (sum) value, by(dest_reg dest_sec)
quietly rename (dest_reg dest_sec value) (reg sec va)

quietly merge 1:1 reg sec using `inputs', nogenerate
quietly merge 1:1 reg sec using `gross',  nogenerate

* the identity every calibration depends on: inputs + value added = gross output
quietly generate double err = abs((zin + va)/q - 1)
quietly summarize err
display "cost shares   sum_s gamma + gamma_L = 1  max error " %8.2e r(max)
quietly summarize q
display "world gross output " %8.0f r(sum)/1000 " bn EUR"

quietly collapse (sum) va q, by(sec)
quietly generate double vashare = va/q
display "value-added share of gross output, by sector (world):"
list sec vashare, noobs clean
cost shares   sum_s gamma + gamma_L = 1  max error  4.6e-14


world gross output   190937 bn EUR



value-added share of gross output, by sector (world):

    sec     vashare  
    AGR   .57583511  
    CHE   .30929904  
    CTT   .48213516  
    FDT   .28863285  
    MET   .30909484  
    MIN   .50402542  
    SRV   .64181724  
    TRE   .26632309  

Every share sums to one to machine precision, world value added equals world final demand, and the deficits sum to zero. The largest single imbalance is \(4.7\%\) of final expenditure — far smaller than the \(87.5\%\) in Part VI, because FIGARO is a balanced accounting system by construction.

The value-added share of gross output is the number that decides how much linkages matter. Where it is low, a sector is mostly assembled from other sectors’ output and a shock anywhere upstream reaches it.

The Multi-Sector Solver

This slide has no Stata tab, and the reason is a real limitation. Mata has no \(n\)-dimensional arrays, so the \(12\times8\times12\) share tensor and the \(8\times8\times12\) input–output tensor would have to be carried as pointer matrices — fifty lines that teach Mata rather than trade. The Software Toolkit slide in Part IX lists this and the other two places where Stata is omitted.

Code
cp_solve <- function(KAP, theta, psiw = 0.2, tolw = 1e-9, maxw = 20000) {
  what <- rep(1, N7); names(what) <- regs
  Phat <- matrix(1, N7, S7, dimnames = list(regs, secs))
  Xs   <- t(ABS)
  for (itw in seq_len(maxw)) {

    for (itp in 1:2000) {                              # inner: prices and costs
      chat <- matrix(0, N7, S7, dimnames = list(regs, secs))
      for (j in 1:N7) for (k in 1:S7)
        chat[j, k] <- what[j]^GL[j, k] * prod(Phat[j, ]^GAM[, k, j])
      Pnew <- matrix(0, N7, S7, dimnames = list(regs, secs))
      for (s in 1:S7) for (j in 1:N7)
        Pnew[j, s] <- sum(PI7[, j, s] * (chat[, s] * KAP[, j, s])^(-theta[s]))^(-1/theta[s])
      if (max(abs(Pnew / Phat - 1)) < 1e-12) { Phat <- Pnew; break }
      Phat <- 0.5 * Pnew + 0.5 * Phat
    }

    PIn <- array(0, c(N7, N7, S7), dimnames = dimnames(PI7))
    for (s in 1:S7) for (j in 1:N7) {
      v <- PI7[, j, s] * (chat[, s] * KAP[, j, s])^(-theta[s])
      PIn[, j, s] <- v / sum(v)
    }

    Inc <- what * VAtot + Dfc
    for (itx in 1:5000) {                              # middle: expenditure
      Qn <- matrix(0, N7, S7, dimnames = list(regs, secs))
      for (s in 1:S7) for (i in 1:N7) Qn[i, s] <- sum(PIn[i, , s] * Xs[, s])
      Xn <- matrix(0, N7, S7, dimnames = list(regs, secs))
      for (j in 1:N7) for (s in 1:S7)
        Xn[j, s] <- sum(GAM[s, , j] * Qn[j, ]) + ALPHA[s, j] * Inc[j]
      if (max(abs(Xn / Xs - 1)) < 1e-13) { Xs <- Xn; break }
      Xs <- 0.5 * Xn + 0.5 * Xs
    }

    Qn <- matrix(0, N7, S7, dimnames = list(regs, secs))
    for (s in 1:S7) for (i in 1:N7) Qn[i, s] <- sum(PIn[i, , s] * Xs[, s])
    Z2  <- rowSums(GL * Qn) - what * VAtot             # outer: labour market
    gap <- max(abs(Z2)) / max(what * VAtot)
    if (gap < tolw) break
    what <- what * (1 + psiw * Z2 / (what * VAtot))
    what <- what / (sum(what * VAtot) / sum(VAtot))
  }
  list(what = what, Phat = Phat, PIn = PIn, X = Xs, it = itw, gap = gap)
}
null shock: 1 iteration(s), gap 1.54e-16, max |w_hat - 1| = 0.00e+00   [0.1 s]

If this returns anything other than exactly 1, the calibration is wrong
and every counterfactual below is measured from the wrong baseline.
Code
import time

th = 4.0

def cp_solve(KAP, psiw=0.2, tolw=1e-9, maxw=20000):
    what = np.ones(N); Phat = np.ones((N, S)); X = ABS.T.copy()
    for itw in range(1, maxw + 1):
        for _ in range(2000):                                  # inner: prices
            chat = np.exp(GL * np.log(what)[:, None] +
                          np.einsum("skj,js->jk", GAM, np.log(Phat)))
            tmp  = PI * ((chat[:, :, None] * KAP) ** (-th))
            Pnew = tmp.sum(0).T ** (-1 / th)
            if np.max(np.abs(Pnew / Phat - 1)) < 1e-12:
                Phat = Pnew; break
            Phat = 0.5 * Pnew + 0.5 * Phat
        tmp = PI * ((chat[:, :, None] * KAP) ** (-th))
        PIn = tmp / tmp.sum(0)
        Inc = what * VAtot + D
        for _ in range(5000):                                  # middle: expenditure
            Qn = np.einsum("isj,js->is", PIn, X)
            Xn = np.einsum("skj,jk->js", GAM, Qn) + ALPHA.T * Inc[:, None]
            if np.max(np.abs(Xn / X - 1)) < 1e-13:
                X = Xn; break
            X = 0.5 * Xn + 0.5 * X
        Qn  = np.einsum("isj,js->is", PIn, X)
        Zx  = (GL * Qn).sum(1) - what * VAtot                  # outer: labour market
        gap = np.max(np.abs(Zx)) / np.max(what * VAtot)
        if gap < tolw:
            break
        what = what * (1 + psiw * Zx / (what * VAtot))
        what = what / (np.sum(what * VAtot) / np.sum(VAtot))
    return dict(what=what, Phat=Phat, PIn=PIn, X=X, it=itw, gap=gap)

t0 = time.time()
r0 = cp_solve(np.ones((N, S, N)))
out = ("null shock: %d iteration(s), gap %.2e, max |w_hat - 1| = %.2e   [%.1f s]\n"
       % (r0["it"], r0["gap"], np.max(np.abs(r0["what"] - 1)), time.time() - t0) +
       "\nIf this returns anything other than exactly 1, the calibration is wrong\n"
       "and every counterfactual below is measured from the wrong baseline.")
import sys
nbytes = sys.stdout.write(out + "\n")
null shock: 1 iteration(s), gap 3.08e-16, max |w_hat - 1| = 0.00e+00   [0.0 s]

If this returns anything other than exactly 1, the calibration is wrong
and every counterfactual below is measured from the wrong baseline.
Code
sys.stdout.flush()

Note the shape of the Python code. The three loops are the same three loops, but the inner ones are einsum contractions rather than nested for statements — \(0.2\) seconds against \(5.5\). Neither version is more correct; they agree to every digit printed below.

Counterfactual — A Transatlantic Free-Trade Agreement

Remove the applied tariffs the EU and the United States levy on each other: \(\hat\kappa^s_{ij} = 1/(1+t^s_{ij})\) for those pairs, \(1\) everywhere else. 60 of the 1 152 trade-cost cells move, by between \(0.25\%\) and \(6.4\%\).

applied MFN tariffs, WITS/TRAINS HS6 simple averages, 2020
                      sector US_applies_pct EU_applies_pct
                      <char>          <num>          <num>
                 Agriculture           2.23           5.88
                   Chemicals           3.02           4.51
 Food, textiles, wood, paper           6.82           6.60
          Metals & machinery           1.64           1.93
             Mining & energy           0.26           0.28
         Transport equipment           2.66           3.50
Code
tar <- fread("../data/ctm-tariffs.csv")
KAP <- array(1, c(N7, N7, S7), dimnames = dimnames(PI7))
for (k in seq_len(nrow(tar)))
  KAP[tar$orig_reg[k], tar$dest_reg[k], tar$sector[k]] <- tar$kappa_hat[k]

r1   <- cp_solve(KAP, theta7)
Pagg <- sapply(1:N7, function(j) prod(r1$Phat[j, ]^ALPHA[, j]))
W    <- r1$what / Pagg
data.table(region = regs, welfare_pct = round(100 * (W - 1), 3))[order(-welfare_pct)]
shocked cells 60 of 1152   solver 60 iterations, gap 9.79e-10
   region wage_pct price_pct welfare_pct
   <char>    <num>     <num>       <num>
   EU_OTH    0.108     0.042       0.066
      ITA    0.164     0.107       0.057
      ESP    0.070     0.028       0.042
      DEU    0.092     0.051       0.041
      USA   -0.027    -0.066       0.039
      FRA    0.091     0.055       0.037
      GBR    0.009     0.009      -0.001
      ROW   -0.006    -0.005      -0.001
 ASIA_OTH   -0.015    -0.013      -0.002
      CHN   -0.021    -0.019      -0.002
      JPN   -0.018    -0.016      -0.002
 AMER_OTH   -0.048    -0.043      -0.006

sectoral gross output, mean |change|:  tariffed 0.149%   untariffed 0.035%
share of total output reallocation in the untariffed sectors: 7.2%

Germany, by sector (%):
  AGR   CHE   CTT   FDT   MET   MIN   SRV   TRE 
-0.08  0.24  0.06  0.34  0.20  0.03  0.06  0.23 
United States, by sector (%):
  AGR   CHE   CTT   FDT   MET   MIN   SRV   TRE 
 0.00 -0.22 -0.01 -0.40 -0.06  0.00 -0.01  0.02 
Code
tar = pd.read_csv("../data/ctm-tariffs.csv")
KAP = np.ones((N, S, N))
for _, row in tar.iterrows():
    KAP[ri[row.orig_reg], si[row.sector], ri[row.dest_reg]] = row.kappa_hat

r1   = cp_solve(KAP)
Pagg = np.prod(r1["Phat"] ** ALPHA.T, axis=1)
W    = r1["what"] / Pagg

Qn = np.einsum("isj,js->is", r1["PIn"], r1["X"])
Q0 = Z.sum((2, 3)) + Fd.sum(2)
dQ = 100 * (Qn / Q0 - 1)
hit = [si[x] for x in ["AGR", "FDT", "MIN", "CHE", "MET", "TRE"]]
oth = [si["CTT"], si["SRV"]]

rows = "\n".join("  %-9s %9.3f %10.3f %11.3f"
                 % (regs[k], 100 * (r1["what"][k] - 1), 100 * (Pagg[k] - 1), 100 * (W[k] - 1))
                 for k in np.argsort(-W))
out = ("shocked cells %d of %d   solver %d iterations, gap %.2e\n\n"
       % (int((KAP != 1).sum()), KAP.size, r1["it"], r1["gap"]) +
       "  %-9s %9s %10s %11s\n" % ("region", "wage_pct", "price_pct", "welfare_pct") + rows +
       "\n\nsectoral gross output, mean |change|:  tariffed %.3f%%   untariffed %.3f%%\n"
       % (np.mean(np.abs(dQ[:, hit])), np.mean(np.abs(dQ[:, oth]))) +
       "share of total output reallocation in the untariffed sectors: %.1f%%\n"
       % (100 * np.abs(dQ[:, oth]).sum() / np.abs(dQ).sum()) +
       "\nGermany, by sector (%):\n  " +
       "  ".join("%s %+.2f" % (secs[k], dQ[ri["DEU"], k]) for k in range(S)) +
       "\nUnited States, by sector (%):\n  " +
       "  ".join("%s %+.2f" % (secs[k], dQ[ri["USA"], k]) for k in range(S)))
import sys
nbytes = sys.stdout.write(out + "\n")
shocked cells 60 of 1152   solver 60 iterations, gap 9.79e-10

  region     wage_pct  price_pct welfare_pct
  EU_OTH        0.108      0.042       0.066
  ITA           0.164      0.107       0.057
  ESP           0.070      0.028       0.042
  DEU           0.092      0.051       0.041
  USA          -0.027     -0.066       0.039
  FRA           0.091      0.055       0.037
  GBR           0.009      0.009      -0.001
  ROW          -0.006     -0.005      -0.001
  JPN          -0.018     -0.016      -0.002
  CHN          -0.021     -0.019      -0.002
  ASIA_OTH     -0.015     -0.013      -0.002
  AMER_OTH     -0.048     -0.043      -0.006

sectoral gross output, mean |change|:  tariffed 0.149%   untariffed 0.035%
share of total output reallocation in the untariffed sectors: 7.2%

Germany, by sector (%):
  AGR -0.08  CHE +0.24  CTT +0.06  FDT +0.34  MET +0.20  MIN +0.03  SRV +0.06  TRE +0.23
United States, by sector (%):
  AGR -0.00  CHE -0.22  CTT -0.01  FDT -0.40  MET -0.06  MIN -0.00  SRV -0.01  TRE +0.02
Code
sys.stdout.flush()

Both sides gain, and everyone else loses a little. The rest of the EU \(+0.066\%\), Italy \(+0.057\%\), Spain \(+0.042\%\), Germany \(+0.041\%\), the United States \(+0.039\%\). Outside the agreement the effect is negative but tiny — the Americas \(-0.006\%\), China and Japan \(-0.002\%\) — the standard trade-diversion result.

Two things are worth stopping on.

The United States gains while its wage falls. \(\hat w = -0.027\%\) but the price index falls \(0.066\%\), so real income rises. The gain arrives through cheaper imports, not higher earnings — which is what a tariff cut is supposed to do and what a nominal-wage headline would miss entirely.

Services and distribution move although nothing tariffs them. Germany’s services output rises \(0.06\%\) and its distribution \(0.06\%\); in the United States both fall \(0.01\%\). They are inputs to the sectors that were liberalised. \(7.2\%\) of all the output reallocation in the world lands in these two untariffed sectors — which is the answer to “why bother with input–output linkages”.

And note the magnitudes. Removing every transatlantic tariff is worth four to seven hundredths of a percent of real income. Applied rates are already low (0.26% to 6.8%), so the tariff margin is nearly exhausted. That is why the actual TTIP debate was about regulatory barriers, not tariffs — and this model, which only knows about \(\kappa\), cannot speak to those.

Part VII in review

  • The multi-sector model adds three things: composition, sector-specific elasticities, and input–output linkages. Only the third has no single-sector counterpart.
  • Costs and prices are mutually defining once intermediates exist. That inner fixed point is the Leontief inverse in disguise.
  • ACR does not survive. With intermediates the domestic share is no longer a sufficient statistic and welfare needs the full sectoral price index.
  • Taxing two of eight sectors moved output in all eight: \(16.2\%\) of the total reallocation is in untaxed sectors, including services.
  • Italy’s services output falls \(0.33\%\) from a tariff on metals and cars.
  • Calibration checks are not optional. Every share must sum to one, world value added must equal world final demand, and the null shock must return exactly 1.
Task R Python
4-D array from long CSV array() + cbind() index np.zeros + fancy index
Tensor contraction nested for np.einsum
Sector price index prod(Phat[j, ]^GAM[, k, j]) einsum("skj,js->jk", …)
Nested fixed point three for loops, damped same, vectorised inside
Aggregate price index prod(Phat[j, ]^ALPHA[, j]) np.prod(Phat**ALPHA.T, 1)

Stata does the calibration happily with collapse and merge. It does not do the solver — see the note above.

  • Run the null shock first, every time. It is the only check that catches a calibration error, and a wrong calibration converges just as smoothly as a right one.
  • Do not fake “no linkages” by setting \(\gamma = 0\) and \(\gamma^L = 1\) on a calibrated table. Gross output no longer equals value added, the baseline stops being an equilibrium, and the model will happily report welfare gains from a tariff. If you want the no-linkage comparison, rebuild the calibration.
  • Damp the inner loops too. Prices at \(0.5\), expenditure at \(0.5\), wages at \(0.2\); undamped, the price loop diverges before the outer one starts.
  • The tolerances must be tighter inside than outside\(10^{-12}\) and \(10^{-13}\) inside against \(10^{-9}\) on wages — or the outer loop chases noise from the inner ones.
  • \(\theta_s = 4\) everywhere is this deck’s simplification. Caliendo and Parro’s estimates range from under 1 to over 50, and the counterfactual is sensitive to the ranking across sectors, not just the average.

Part VIII — Global Value Chains

Part VIII — What Gross Trade Data Cannot Tell You

Customs records the value that crosses a border. When a German car crosses into the United States, the full sticker price is recorded as a German export — even though the steel came from China, the electronics from Korea and the design software from Ireland.

Three consequences:

  • Bilateral balances are misattributed. The deficit is recorded against the last country in the chain, not the ones that added the value.
  • Gross trade double-counts. A component crossing a border three times is counted three times in world exports and once in world GDP.
  • Tariffs hit the wrong target. A tariff on the assembler taxes every supplier upstream, including domestic ones.

Part VII’s linkages were about counterfactuals. Part VIII is about measurement: given the observed table, who actually earned what.

../data/ctm-icio.csv again — FIGARO 2022, 12 regions × 8 sectors. Part VII read it as shares for a solver. Part VIII reads it as a 96 × 96 linear system.

\[ \mathbf{Q} = \mathbf{A}\mathbf{Q} + \mathbf{f} \qquad\Longrightarrow\qquad \mathbf{Q} = (\mathbf{I} - \mathbf{A})^{-1}\mathbf{f} \]

No estimation, no elasticity, no counterfactual. Everything in this part is an accounting identity, which is why every number can be checked exactly.

  1. The Leontief inverse and what its entries mean
  2. Value-added exports and the VAX ratio
  3. Where the value in a country’s exports actually comes from
  4. Upstreamness: how far a sector sits from final demand

The Leontief Inverse

Let \(A_{ij} = Z_{ij}/Q_j\) be the input of \(i\) needed per unit of \(j\)’s output — the technical coefficient. Gross output is intermediate demand plus final demand:

\[ Q_i \;=\; \sum_j A_{ij} Q_j + f_i \]

Solving, and expanding as a geometric series:

\[ \mathbf{Q} = (\mathbf{I}-\mathbf{A})^{-1}\mathbf{f} = \big(\mathbf{I} + \mathbf{A} + \mathbf{A}^2 + \mathbf{A}^3 + \cdots\big)\mathbf{f} \]

That series is the value chain. \(\mathbf{f}\) is the car; \(\mathbf{A}\mathbf{f}\) is the steel and glass to build it; \(\mathbf{A}^2\mathbf{f}\) is the iron ore and sand; and so on. \(L_{ij}\) is the total output of \(i\) — direct and indirect, through chains of any length — required per unit of final demand for \(j\).

Let \(v_i = VA_i / Q_i\) be the value-added share of gross output. Then the value added generated anywhere in the world by one unit of final demand for \(j\) is:

\[ \mathbf{v}'\mathbf{L}\,\mathbf{e}_j \]

and the fundamental identity is:

\[ \mathbf{v}'\mathbf{L} \;=\; \mathbf{1}' \]

Every euro of final demand is, eventually, exactly one euro of value added somewhere. That identity is what makes the decompositions below exact rather than approximate, and it is the first thing to check in any implementation.

Value-Added Exports and the VAX Ratio

Replace “what crossed the border” with “whose value added was ultimately absorbed abroad”:

\[ VAX_r \;=\; \sum_{s \neq r} \mathbf{v}_r'\,\mathbf{L}\,\mathbf{f}_s, \qquad \text{VAX ratio}_r \;=\; \frac{VAX_r}{\text{gross exports}_r} \]

where \(\mathbf{v}_r\) zeroes out every cell outside region \(r\) and \(\mathbf{f}_s\) is final demand in region \(s\).

A ratio of 1 means every euro exported was earned at home. A ratio of 0.6 means 40 cents of every exported euro was earned by somebody else, or by the exporter at an earlier stage that has already been counted.

VAX — Code

Code
io <- fread("../data/ctm-icio.csv")
regs <- sort(unique(io[flow == "Z"]$orig_reg)); N <- length(regs)
secs <- sort(unique(io[flow == "Z"]$orig_sec)); S <- length(secs)
lab <- paste(rep(regs, each = S), rep(secs, times = N), sep = "_"); K <- N * S
idx <- function(r, s) (match(r, regs) - 1) * S + match(s, secs)

Zm <- matrix(0, K, K, dimnames = list(lab, lab))
z <- io[flow == "Z"]
Zm[cbind(idx(z$orig_reg, z$orig_sec), idx(z$dest_reg, z$dest_sec))] <- z$value
Fm <- matrix(0, K, N, dimnames = list(lab, regs))
ff <- io[flow == "F"]
Fm[cbind(idx(ff$orig_reg, ff$orig_sec), match(ff$dest_reg, regs))] <- ff$value
vav <- numeric(K); vv <- io[flow == "VA"]
vav[idx(vv$dest_reg, vv$dest_sec)] <- vv$value

Q  <- rowSums(Zm) + rowSums(Fm)
A  <- sweep(Zm, 2, Q, "/")          # column-normalised: input per unit of output
L  <- solve(diag(K) - A)
vc <- vav / Q

reg_of <- rep(regs, each = S)
VAX <- sapply(regs, function(r) {
  fd <- Fm; fd[, r] <- 0            # final demand abroad only
  vr <- vc; vr[reg_of != r] <- 0    # value added of r only
  sum(vr %*% L %*% rowSums(fd))
})
Leontief inverse 96 x 96   largest entry 1.901   mean diagonal 1.245
identity  v'L = 1'          max deviation 6.17e-14
identity  L f = Q           max relative error 1.73e-15
   region gross_exports va_exports vax_ratio
   <char>         <num>      <num>     <num>
      ITA         642.2      409.2     0.637
   EU_OTH        2875.0     1917.6     0.667
      FRA         854.2      575.3     0.673
      ESP         452.7      312.5     0.690
      DEU        1461.3     1042.4     0.713
 AMER_OTH        1530.1     1125.0     0.735
      ROW        5502.8     4114.2     0.748
      JPN         864.3      675.5     0.782
 ASIA_OTH        2531.0     1982.1     0.783
      GBR         869.5      687.7     0.791
      CHN        3405.2     2735.4     0.803
      USA        2466.9     2072.7     0.840

world VAX ratio 0.7525
Code
import numpy as np, pandas as pd

io = pd.read_csv("../data/ctm-icio.csv")
regs = sorted(io[io.flow == "Z"].orig_reg.unique()); N = len(regs)
secs = sorted(io[io.flow == "Z"].orig_sec.unique()); S = len(secs)
K = N * S
ri = {r: i for i, r in enumerate(regs)}; si = {s: i for i, s in enumerate(secs)}
cell = lambda r, s: r.map(ri) * S + s.map(si)

Zm = np.zeros((K, K)); z = io[io.flow == "Z"]
Zm[cell(z.orig_reg, z.orig_sec), cell(z.dest_reg, z.dest_sec)] = z.value
Fm = np.zeros((K, N)); f = io[io.flow == "F"]
Fm[cell(f.orig_reg, f.orig_sec), f.dest_reg.map(ri)] = f.value
vav = np.zeros(K); v = io[io.flow == "VA"]
vav[cell(v.dest_reg, v.dest_sec)] = v.value

Q  = Zm.sum(1) + Fm.sum(1)
A  = Zm / Q                                # column-normalised
L  = np.linalg.inv(np.eye(K) - A)
vc = vav / Q
reg_of = np.repeat(np.arange(N), S)

GE  = np.array([Zm[reg_of == k][:, reg_of != k].sum() +
                Fm[reg_of == k][:, np.arange(N) != k].sum() for k in range(N)])
VAX = np.empty(N)
for k in range(N):
    fd = Fm.copy(); fd[:, k] = 0
    vr = np.where(reg_of == k, vc, 0.0)
    VAX[k] = vr @ L @ fd.sum(1)

rows = "\n".join("  %-9s %13.1f %11.1f %10.3f"
                 % (regs[k], GE[k] / 1000, VAX[k] / 1000, VAX[k] / GE[k])
                 for k in np.argsort(VAX / GE))
out = ("Leontief inverse %d x %d   largest entry %.3f   mean diagonal %.3f\n"
       % (K, K, L.max(), np.mean(np.diag(L))) +
       "identity  v'L = 1'          max deviation %.2e\n" % np.max(np.abs(vc @ L - 1)) +
       "identity  L f = Q           max relative error %.2e\n\n"
       % np.max(np.abs(L @ Fm.sum(1) - Q) / Q) +
       "  %-9s %13s %11s %10s\n" % ("region", "gross_exports", "va_exports", "vax_ratio") +
       rows + "\n\nworld VAX ratio %.4f" % (VAX.sum() / GE.sum()))
import sys
nbytes = sys.stdout.write(out + "\n")
Leontief inverse 96 x 96   largest entry 1.901   mean diagonal 1.245
identity  v'L = 1'          max deviation 6.20e-14
identity  L f = Q           max relative error 5.93e-16

  region    gross_exports  va_exports  vax_ratio
  ITA               642.2       409.2      0.637
  EU_OTH           2875.0      1917.6      0.667
  FRA               854.2       575.3      0.673
  ESP               452.7       312.5      0.690
  DEU              1461.3      1042.4      0.713
  AMER_OTH         1530.1      1125.0      0.735
  ROW              5502.8      4114.2      0.748
  JPN               864.3       675.5      0.782
  ASIA_OTH         2531.0      1982.1      0.783
  GBR               869.5       687.7      0.791
  CHN              3405.2      2735.4      0.803
  USA              2466.9      2072.7      0.840

world VAX ratio 0.7525
Code
sys.stdout.flush()
Code
set type double
quietly import delimited "../data/ctm-icio.csv", clear varnames(1)
quietly egen long orow = group(orig_reg orig_sec) if flow != "VA"
quietly egen long ocol = group(dest_reg dest_sec) if flow == "Z"
quietly egen long dreg = group(dest_reg)
quietly egen long vrow = group(dest_reg dest_sec) if flow == "VA"

mata: K = 96 ; N = 12 ; S = 8 ; Zm = J(K,K,0) ; Fm = J(K,N,0) ; va = J(K,1,0)

preserve
quietly keep if flow == "Z"
mata:
r = st_data(., "orow") ; c = st_data(., "ocol") ; v = st_data(., "value")
for (i = 1; i <= rows(r); i++) Zm[r[i], c[i]] = v[i]
end
restore
preserve
quietly keep if flow == "F"
mata:
r = st_data(., "orow") ; c = st_data(., "dreg") ; v = st_data(., "value")
for (i = 1; i <= rows(r); i++) Fm[r[i], c[i]] = v[i]
end
restore
preserve
quietly keep if flow == "VA"
mata:
r = st_data(., "vrow") ; v = st_data(., "value")
for (i = 1; i <= rows(r); i++) va[r[i]] = v[i]
end
restore

mata:
Q  = rowsum(Zm) + rowsum(Fm)
A  = Zm :/ (J(K,1,1) * Q')
L  = luinv(I(K) - A)
vc = va :/ Q
printf("Leontief inverse %g x %g   largest entry %6.3f   mean diagonal %6.3f\n",
       K, K, max(L), mean(diagonal(L)))
printf("identity  v'L = 1'          max deviation %9.2e\n", max(abs(vc' * L :- 1)))
printf("identity  L f = Q           max relative error %9.2e\n\n",
       max(abs(L * rowsum(Fm) - Q) :/ Q))
regof = ceil((1::K) / S)
GE = J(N,1,0) ; VAX = J(N,1,0)
for (rr = 1; rr <= N; rr++) {
    sel = (regof :== rr)
    Zx = Zm ; Fx = Fm
    for (i = 1; i <= K; i++) for (j = 1; j <= K; j++) if (!sel[i] | sel[j]) Zx[i,j] = 0
    for (i = 1; i <= K; i++) for (j = 1; j <= N; j++) if (!sel[i] | j == rr) Fx[i,j] = 0
    GE[rr] = sum(Zx) + sum(Fx)
    Fd = Fm ; Fd[., rr] = J(K,1,0)
    VAX[rr] = sum(((vc :* sel)' * L) * rowsum(Fd))
}
printf("world VAX ratio %6.4f\n", sum(VAX)/sum(GE))
st_matrix("vax", (VAX :/ GE))
end

display "VAX ratio by region (order matches the alphabetical region list):"
matrix list vax, format(%6.3f)
------------------------------------------------- mata (type end to exit) -----
: r = st_data(., "orow") ; c = st_data(., "ocol") ; v = st_data(., "value")

: for (i = 1; i <= rows(r); i++) Zm[r[i], c[i]] = v[i]

: end
-------------------------------------------------------------------------------




------------------------------------------------- mata (type end to exit) -----
: r = st_data(., "orow") ; c = st_data(., "dreg") ; v = st_data(., "value")

: for (i = 1; i <= rows(r); i++) Fm[r[i], c[i]] = v[i]

: end
-------------------------------------------------------------------------------




------------------------------------------------- mata (type end to exit) -----
: r = st_data(., "vrow") ; v = st_data(., "value")

: for (i = 1; i <= rows(r); i++) va[r[i]] = v[i]

: end
-------------------------------------------------------------------------------


------------------------------------------------- mata (type end to exit) -----
: Q  = rowsum(Zm) + rowsum(Fm)

: A  = Zm :/ (J(K,1,1) * Q')

: L  = luinv(I(K) - A)

: vc = va :/ Q

: printf("Leontief inverse %g x %g   largest entry %6.3f   mean diagonal %6.3f\
> n",
>        K, K, max(L), mean(diagonal(L)))
Leontief inverse 96 x 96   largest entry  1.901   mean diagonal  1.245

: printf("identity  v'L = 1'          max deviation %9.2e\n", max(abs(vc' * L :
> - 1)))
identity  v'L = 1'          max deviation  6.17e-14

: printf("identity  L f = Q           max relative error %9.2e\n\n",
>        max(abs(L * rowsum(Fm) - Q) :/ Q))
identity  L f = Q           max relative error  1.51e-15


: regof = ceil((1::K) / S)

: GE = J(N,1,0) ; VAX = J(N,1,0)

: for (rr = 1; rr <= N; rr++) {
>     sel = (regof :== rr)
>     Zx = Zm ; Fx = Fm
>     for (i = 1; i <= K; i++) for (j = 1; j <= K; j++) if (!sel[i] | sel[j]) Z
> x[i,j] = 0
>     for (i = 1; i <= K; i++) for (j = 1; j <= N; j++) if (!sel[i] | j == rr) 
> Fx[i,j] = 0
>     GE[rr] = sum(Zx) + sum(Fx)
>     Fd = Fm ; Fd[., rr] = J(K,1,0)
>     VAX[rr] = sum(((vc :* sel)' * L) * rowsum(Fd))
> }

: printf("world VAX ratio %6.4f\n", sum(VAX)/sum(GE))
world VAX ratio 0.7525

: st_matrix("vax", (VAX :/ GE))

: end
-------------------------------------------------------------------------------

VAX ratio by region (order matches the alphabetical region list):


vax[12,1]
        c1
 r1  0.735
 r2  0.783
 r3  0.803
 r4  0.713
 r5  0.690
 r6  0.667
 r7  0.673
 r8  0.791
 r9  0.637
r10  0.782
r11  0.748
r12  0.840

The two identities hold to machine precision, which is the licence to believe everything else on this slide.

The world VAX ratio is 0.7525: a quarter of world gross exports is value added that was either earned abroad or already counted. Italy is the most integrated into other people’s chains at 0.637 — 36 cents of every euro Italy exports was earned by somebody else. The United States is the least, at 0.840, which is what a large, self-sufficient economy looks like.

Where the Value Actually Comes From

Gross exports decompose exactly into value added by source. Because \(\mathbf{v}'\mathbf{L} = \mathbf{1}'\), the pieces must sum to the total — there is no residual and no approximation.

\[ E_r \;=\; \underbrace{\sum_{i \in r} v_i (\mathbf{L}\mathbf{e}_r)_i}_{\text{domestic value added}} \;+\; \underbrace{\sum_{i \notin r} v_i (\mathbf{L}\mathbf{e}_r)_i}_{\text{foreign value added}} \]

Code
dec <- rbindlist(lapply(regs8, function(r) {
  er <- numeric(K8)
  er[reg_of == r] <- rowSums(Zm[reg_of == r, reg_of != r, drop = FALSE]) +
                     rowSums(Fm[reg_of == r, regs8 != r, drop = FALSE])
  va_by <- as.vector(vc8 * (L8 %*% er))       # value added by source cell
  data.table(region = r, gross = sum(er),
             DVA = sum(va_by[reg_of == r]),
             FVA = sum(va_by[reg_of != r]))
}))
dec[, `:=`(dva_share = DVA / gross, fva_share = FVA / gross,
           adds_up = (DVA + FVA) / gross)]
   region gross_bn dva_share fva_share adds_up
   <char>    <num>     <num>     <num>   <num>
      ITA    642.2     0.646     0.354       1
      FRA    854.2     0.686     0.314       1
      ESP    452.7     0.697     0.303       1
   EU_OTH   2875.0     0.698     0.302       1
      DEU   1461.3     0.738     0.262       1
 AMER_OTH   1530.1     0.755     0.245       1
      JPN    864.3     0.790     0.210       1
      GBR    869.5     0.801     0.199       1
 ASIA_OTH   2531.0     0.812     0.188       1
      ROW   5502.8     0.820     0.180       1
      CHN   3405.2     0.836     0.164       1
      USA   2466.9     0.898     0.102       1

world foreign-value-added share of gross exports: 0.208
Code
rows = []
for k in range(N):
    er = np.zeros(K)
    m = reg_of == k
    er[m] = (Zm[m][:, reg_of != k].sum(1) + Fm[m][:, np.arange(N) != k].sum(1))
    va_by = vc * (L @ er)
    dva, fva = va_by[reg_of == k].sum(), va_by[reg_of != k].sum()
    rows.append([regs[k], er.sum(), dva, fva])

dec = pd.DataFrame(rows, columns=["region", "gross", "DVA", "FVA"])
dec["dva_share"] = (dec.DVA / dec.gross).round(3)
dec["fva_share"] = (dec.FVA / dec.gross).round(3)
dec["adds_up"]   = ((dec.DVA + dec.FVA) / dec.gross).round(10)
dec = dec.sort_values("dva_share")

out = (dec[["region", "dva_share", "fva_share", "adds_up"]].to_string(index=False) +
       "\n\nworld foreign-value-added share of gross exports: %.3f"
       % (dec.FVA.sum() / dec.gross.sum()))
import sys
nbytes = sys.stdout.write(out + "\n")
  region  dva_share  fva_share  adds_up
     ITA      0.646      0.354      1.0
     FRA      0.686      0.314      1.0
     ESP      0.697      0.303      1.0
  EU_OTH      0.698      0.302      1.0
     DEU      0.738      0.262      1.0
AMER_OTH      0.755      0.245      1.0
     JPN      0.790      0.210      1.0
     GBR      0.801      0.199      1.0
ASIA_OTH      0.812      0.188      1.0
     ROW      0.820      0.180      1.0
     CHN      0.836      0.164      1.0
     USA      0.898      0.102      1.0

world foreign-value-added share of gross exports: 0.208
Code
sys.stdout.flush()
Code
set type double
quietly import delimited "../data/ctm-icio.csv", clear varnames(1)
quietly egen long orow = group(orig_reg orig_sec) if flow != "VA"
quietly egen long ocol = group(dest_reg dest_sec) if flow == "Z"
quietly egen long dreg = group(dest_reg)
quietly egen long vrow = group(dest_reg dest_sec) if flow == "VA"
mata: K = 96 ; N = 12 ; S = 8 ; Zm = J(K,K,0) ; Fm = J(K,N,0) ; va = J(K,1,0)
preserve
quietly keep if flow == "Z"
mata:
r = st_data(., "orow") ; c = st_data(., "ocol") ; v = st_data(., "value")
for (i = 1; i <= rows(r); i++) Zm[r[i], c[i]] = v[i]
end
restore
preserve
quietly keep if flow == "F"
mata:
r = st_data(., "orow") ; c = st_data(., "dreg") ; v = st_data(., "value")
for (i = 1; i <= rows(r); i++) Fm[r[i], c[i]] = v[i]
end
restore
preserve
quietly keep if flow == "VA"
mata:
r = st_data(., "vrow") ; v = st_data(., "value")
for (i = 1; i <= rows(r); i++) va[r[i]] = v[i]
end
restore

mata:
Q  = rowsum(Zm) + rowsum(Fm)
A  = Zm :/ (J(K,1,1) * Q')
L  = luinv(I(K) - A)
vc = va :/ Q
regof = ceil((1::K) / S)
D = J(N, 3, 0)
for (rr = 1; rr <= N; rr++) {
    sel = (regof :== rr)
    er = J(K, 1, 0)
    for (i = 1; i <= K; i++) {
        if (sel[i]) {
            t = 0
            for (j = 1; j <= K; j++) if (!sel[j]) t = t + Zm[i,j]
            for (j = 1; j <= N; j++) if (j != rr) t = t + Fm[i,j]
            er[i] = t
        }
    }
    vaby = vc :* (L * er)
    D[rr,1] = sum(er)
    D[rr,2] = sum(select(vaby, sel))
    D[rr,3] = sum(select(vaby, sel :== 0))
}
printf("world foreign-value-added share of gross exports: %5.3f\n",
       sum(D[.,3])/sum(D[.,1]))
st_matrix("dec", (D[.,2] :/ D[.,1], D[.,3] :/ D[.,1], (D[.,2]+D[.,3]) :/ D[.,1]))
end

display "columns: domestic VA share, foreign VA share, adds-up check"
matrix list dec, format(%6.3f)
------------------------------------------------- mata (type end to exit) -----
: r = st_data(., "orow") ; c = st_data(., "ocol") ; v = st_data(., "value")

: for (i = 1; i <= rows(r); i++) Zm[r[i], c[i]] = v[i]

: end
-------------------------------------------------------------------------------




------------------------------------------------- mata (type end to exit) -----
: r = st_data(., "orow") ; c = st_data(., "dreg") ; v = st_data(., "value")

: for (i = 1; i <= rows(r); i++) Fm[r[i], c[i]] = v[i]

: end
-------------------------------------------------------------------------------




------------------------------------------------- mata (type end to exit) -----
: r = st_data(., "vrow") ; v = st_data(., "value")

: for (i = 1; i <= rows(r); i++) va[r[i]] = v[i]

: end
-------------------------------------------------------------------------------


------------------------------------------------- mata (type end to exit) -----
: Q  = rowsum(Zm) + rowsum(Fm)

: A  = Zm :/ (J(K,1,1) * Q')

: L  = luinv(I(K) - A)

: vc = va :/ Q

: regof = ceil((1::K) / S)

: D = J(N, 3, 0)

: for (rr = 1; rr <= N; rr++) {
>     sel = (regof :== rr)
>     er = J(K, 1, 0)
>     for (i = 1; i <= K; i++) {
>         if (sel[i]) {
>             t = 0
>             for (j = 1; j <= K; j++) if (!sel[j]) t = t + Zm[i,j]
>             for (j = 1; j <= N; j++) if (j != rr) t = t + Fm[i,j]
>             er[i] = t
>         }
>     }
>     vaby = vc :* (L * er)
>     D[rr,1] = sum(er)
>     D[rr,2] = sum(select(vaby, sel))
>     D[rr,3] = sum(select(vaby, sel :== 0))
> }

: printf("world foreign-value-added share of gross exports: %5.3f\n",
>        sum(D[.,3])/sum(D[.,1]))
world foreign-value-added share of gross exports: 0.208

: st_matrix("dec", (D[.,2] :/ D[.,1], D[.,3] :/ D[.,1], (D[.,2]+D[.,3]) :/ D[.,
> 1]))

: end
-------------------------------------------------------------------------------

columns: domestic VA share, foreign VA share, adds-up check


dec[12,3]
        c1     c2     c3
 r1  0.755  0.245  1.000
 r2  0.812  0.188  1.000
 r3  0.836  0.164  1.000
 r4  0.738  0.262  1.000
 r5  0.697  0.303  1.000
 r6  0.698  0.302  1.000
 r7  0.686  0.314  1.000
 r8  0.801  0.199  1.000
 r9  0.646  0.354  1.000
r10  0.790  0.210  1.000
r11  0.820  0.180  1.000
r12  0.898  0.102  1.000

The adds_up column is exactly 1 for every region — not 0.999, not 1.001. That is the identity \(\mathbf{v}'\mathbf{L} = \mathbf{1}'\) doing its work, and it is the check that separates a correct implementation from a plausible one.

Italy’s exports are 35.4% foreign value added; the United States’ are 10.2%. Across the world, 20.8% of gross exports is value added that originated somewhere other than the exporting country.

Upstreamness — Distance from Final Demand

How many production stages separate a sector’s output from the consumer?

\[ U_i \;=\; 1 \cdot \frac{f_i}{Q_i} + 2 \cdot \frac{\sum_j \Delta_{ij} f_j}{Q_i} + 3 \cdot \frac{\sum_j\sum_k \Delta_{ij}\Delta_{jk} f_k}{Q_i} + \cdots \]

which collapses to a single matrix inverse, with \(\Delta_{ij} = Z_{ij}/Q_i\) — the row-normalised flow matrix, the share of \(i\)’s output sold to \(j\):

\[ \mathbf{U} \;=\; (\mathbf{I}-\boldsymbol{\Delta})^{-1}\mathbf{1} \]

\(U = 1\) means output goes straight to consumers; \(U = 3\) means it is on average three stages away.

Code
# Delta is ROW-normalised -- the share of i's output sold to j.
# Using t(A) instead reverses the ranking and puts cars upstream of steel.
Delta <- sweep(Zm, 1, Q8, "/")
U <- as.vector(solve(diag(K8) - Delta) %*% rep(1, K8))

data.table(sec = rep(secs8, times = N8), U = U)[, .(U = mean(U)), by = sec][order(-U)]
upstreamness by sector, mean over the 12 regions
    sec     U
 <char> <num>
    MIN 3.003
    CHE 2.735
    MET 2.599
    AGR 2.432
    FDT 2.085
    CTT 1.957
    TRE 1.800
    SRV 1.791

most upstream region-sectors:
         cell     U
       <char> <num>
      CHN_MIN 3.733
 ASIA_OTH_MIN 3.369
      CHN_CHE 3.324
      FRA_MIN 3.290
most downstream region-sectors:
         cell     U
       <char> <num>
 AMER_OTH_TRE 1.620
 AMER_OTH_SRV 1.592
      USA_TRE 1.577
      ROW_TRE 1.471

world average production length 2.111 stages
Code
Delta = Zm / Q[:, None]                     # ROW-normalised, not the transpose
U = np.linalg.solve(np.eye(K) - Delta, np.ones(K))

sec_of = np.tile(np.arange(S), N)
us = sorted(((secs[k], U[sec_of == k].mean()) for k in range(S)),
            key=lambda t: -t[1])
lab = ["%s_%s" % (regs[i // S], secs[i % S]) for i in range(K)]
ordr = np.argsort(-U)

out = ("upstreamness by sector, mean over the 12 regions\n" +
       "\n".join("  %-5s %6.3f" % (s, u) for s, u in us) +
       "\n\nmost upstream region-sectors:\n" +
       "\n".join("  %-14s %6.3f" % (lab[k], U[k]) for k in ordr[:4]) +
       "\nmost downstream region-sectors:\n" +
       "\n".join("  %-14s %6.3f" % (lab[k], U[k]) for k in ordr[::-1][:4]) +
       "\n\nworld average production length %.3f stages" % ((U * Q).sum() / Q.sum()))
import sys
nbytes = sys.stdout.write(out + "\n")
upstreamness by sector, mean over the 12 regions
  MIN    3.003
  CHE    2.735
  MET    2.599
  AGR    2.432
  FDT    2.085
  CTT    1.957
  TRE    1.800
  SRV    1.791

most upstream region-sectors:
  CHN_MIN         3.733
  ASIA_OTH_MIN    3.369
  CHN_CHE         3.324
  FRA_MIN         3.290
most downstream region-sectors:
  ROW_TRE         1.471
  USA_TRE         1.577
  AMER_OTH_SRV    1.592
  AMER_OTH_TRE    1.620

world average production length 2.111 stages
Code
sys.stdout.flush()
Code
set type double
quietly import delimited "../data/ctm-icio.csv", clear varnames(1)
quietly egen long orow = group(orig_reg orig_sec) if flow != "VA"
quietly egen long ocol = group(dest_reg dest_sec) if flow == "Z"
quietly egen long dreg = group(dest_reg)
mata: K = 96 ; N = 12 ; S = 8 ; Zm = J(K,K,0) ; Fm = J(K,N,0)
preserve
quietly keep if flow == "Z"
mata:
r = st_data(., "orow") ; c = st_data(., "ocol") ; v = st_data(., "value")
for (i = 1; i <= rows(r); i++) Zm[r[i], c[i]] = v[i]
end
restore
preserve
quietly keep if flow == "F"
mata:
r = st_data(., "orow") ; c = st_data(., "dreg") ; v = st_data(., "value")
for (i = 1; i <= rows(r); i++) Fm[r[i], c[i]] = v[i]
end
restore

mata:
Q = rowsum(Zm) + rowsum(Fm)
Delta = Zm :/ (Q * J(1,K,1))          // ROW-normalised
U = luinv(I(K) - Delta) * J(K,1,1)
us = J(S, 1, 0)
for (s = 1; s <= S; s++) {
    t = 0
    for (i = 1; i <= K; i++) if (mod(i-1, S) + 1 == s) t = t + U[i]
    us[s] = t / N
}
printf("world average production length %6.3f stages\n", sum(U :* Q)/sum(Q))
st_matrix("upsec", us)
end

display "upstreamness by sector (alphabetical: AGR CHE CTT FDT MET MIN SRV TRE)"
matrix list upsec, format(%6.3f)
------------------------------------------------- mata (type end to exit) -----
: r = st_data(., "orow") ; c = st_data(., "ocol") ; v = st_data(., "value")

: for (i = 1; i <= rows(r); i++) Zm[r[i], c[i]] = v[i]

: end
-------------------------------------------------------------------------------




------------------------------------------------- mata (type end to exit) -----
: r = st_data(., "orow") ; c = st_data(., "dreg") ; v = st_data(., "value")

: for (i = 1; i <= rows(r); i++) Fm[r[i], c[i]] = v[i]

: end
-------------------------------------------------------------------------------


------------------------------------------------- mata (type end to exit) -----
: Q = rowsum(Zm) + rowsum(Fm)

: Delta = Zm :/ (Q * J(1,K,1))          // ROW-normalised

: U = luinv(I(K) - Delta) * J(K,1,1)

: us = J(S, 1, 0)

: for (s = 1; s <= S; s++) {
>     t = 0
>     for (i = 1; i <= K; i++) if (mod(i-1, S) + 1 == s) t = t + U[i]
>     us[s] = t / N
> }

: printf("world average production length %6.3f stages\n", sum(U :* Q)/sum(Q))
world average production length  2.111 stages

: st_matrix("upsec", us)

: end
-------------------------------------------------------------------------------

upstreamness by sector (alphabetical: AGR CHE CTT FDT MET MIN SRV TRE)


upsec[8,1]
       c1
r1  2.432
r2  2.735
r3  1.957
r4  2.085
r5  2.599
r6  3.003
r7  1.791
r8  1.800

The ranking is exactly what production economics predicts. Mining and energy is the most upstream sector at \(3.00\) stages from final demand; services the most downstream at \(1.79\); transport equipment — cars, the archetypal final good — sits at \(1.80\).

The world average production length is a little over two stages.

Note the code comment in every tab. \(\boldsymbol{\Delta}\) is row-normalised. Using \(\mathbf{A}'\) instead — the transpose of the input matrix, which looks equally plausible — inverts the entire ranking and puts transport equipment above mining. Both matrices are built from the same \(Z\); only one answers this question.

Part VIII in review

  • \((\mathbf{I}-\mathbf{A})^{-1}\) is the value chain written as a geometric series. It converges because every sector pays something to labour and capital.
  • \(\mathbf{v}'\mathbf{L} = \mathbf{1}'\) is the identity that makes every decomposition in this part exact. Check it before believing anything else.
  • The world VAX ratio is 0.7525 — a quarter of gross exports is not the exporter’s own newly created value.
  • Italy’s exports are \(35.4\%\) foreign value added; the United States’ are \(10.2\%\). Size and self-sufficiency, not competitiveness.
  • Upstreamness ranks mining at \(3.00\) stages from the consumer and services at \(1.79\). Row-normalise, or the ranking inverts.
  • Everything here is accounting. No elasticity, no estimation, no counterfactual — which is exactly why it can be checked to machine precision.
Task R Python Stata
Build the \(K\times K\) matrix matrix + cbind() index np.zeros + fancy index Mata loop over st_data
Technical coefficients sweep(Zm, 2, Q, "/") Zm / Q Zm :/ (J(K,1,1)*Q')
Leontief inverse solve(diag(K) - A) np.linalg.inv luinv(I(K) - A)
Solve rather than invert solve(I - D, ones) np.linalg.solve lusolve
Select a region’s cells reg_of == r boolean mask select(x, sel)
Return to Stata st_matrix
  • Column-normalise for \(\mathbf{A}\), row-normalise for \(\boldsymbol{\Delta}\). They are built from the same \(Z\) and answer different questions; swapping them produces a clean, plausible, inverted answer.
  • Verify \(\mathbf{v}'\mathbf{L} = \mathbf{1}'\) and \(\mathbf{L}\mathbf{f} = \mathbf{Q}\) before computing anything else. Both should be at \(10^{-15}\).
  • Prefer solve(I - D, 1) to inverting and multiplying: it is faster and better conditioned. At \(96\times96\) it makes no practical difference; at \(3\,000\times 3\,000\) — the size of an unaggregated FIGARO — it does.
  • Gross exports must include intermediate shipments, not just final goods. Omitting \(Z\) across borders understates exports by more than half.
  • The VAX ratio is not a competitiveness measure. A high ratio mostly means a large, diversified economy that buys few imported inputs.

Part IX — Firm Heterogeneity, Practice & Exercises

Part IX — The Firms Behind the Flows

Every part so far treated a country as the exporting unit. Countries do not export. Firms do, and almost none of them.

Three facts that no country-level model produces:

  • In a typical economy fewer than one manufacturing firm in five exports at all.
  • Exporters are larger, more productive and pay more than non-exporters, and they were so before they began exporting.
  • Export value is extraordinarily concentrated: a handful of firms account for most of a country’s foreign sales.

When trade costs fall, some of the response is existing exporters selling more — the intensive margin — and some is new firms beginning to export — the extensive margin. A gravity coefficient adds them together without saying so.

../data/ctm-firms.csv — TEC, exports to non-EU partners, 2015–2024, 29 reporting countries.

Variable Meaning
class_type = "size" exporters split by employment: LT10, 10-49, 50-249, GE250
class_type = "rank" share of exports held by the top 5, 10, 20, 50, 100, 500, 1 000 firms
NR_ENT number of enterprises
THS_EUR export value, thousand EUR

This is as close as public data gets to firm-level customs records. It is not bilateral, so it cannot enter a gravity regression — the HMR slide goes back to CEPII for that.

Melitz with a Pareto productivity distribution produces exactly the same gravity equation as Armington, and — by the ACR result — exactly the same welfare formula. The trade elasticity \(\theta\) becomes the Pareto shape parameter instead of \(\sigma - 1\), and nothing else changes.

So Part IX does not revise a single number in Part VI. It explains which mechanism generated them, and it predicts things about firms that the country-level model is silent on. That is a genuine scientific gain and a genuine limit at the same time.

Melitz — Selection into Exporting

Firms draw a productivity \(\varphi\). Exporting to \(j\) costs a fixed \(f_{ij}\) on top of the iceberg cost \(\tau_{ij}\), and export profits rise with productivity, so there is a cutoff:

\[ \varphi^*_{ij} \;\propto\; \tau_{ij}\left(\frac{f_{ij}}{E_j P_j^{\sigma-1}}\right)^{\frac{1}{\sigma-1}} \]

Only firms above \(\varphi^*_{ij}\) export to \(j\). Two consequences are the ones the next two slides actually use:

  • The extensive margin — lower \(\tau_{ij}\) lowers the cutoff, so trade grows by adding firms, not only by shipping more per firm.
  • Zeros are structural — if no firm clears the cutoff then \(X_{ij} = 0\) is an equilibrium outcome with economic content, not a missing observation. That is the theoretical answer to Part I’s zeros.

Assume productivity is Pareto with shape \(a\). Aggregating over the surviving firms gives a gravity equation in which the trade elasticity is that shape:

\[ G(\varphi) = 1 - (\varphi_{\min}/\varphi)^{a}, \qquad \theta = a \]

So \(\theta\) is a property of the firm-size distribution, not of preferences, and Chaney’s decomposition splits it into the two margins:

\[ \underbrace{-a}_{\text{total}} \;=\; \underbrace{-(\sigma-1)}_{\text{intensive}} \;+\; \underbrace{-\big(a - (\sigma-1)\big)}_{\text{extensive}} \]

The model needs \(a > \sigma - 1\) for the extensive margin to be positive and for aggregate trade to be finite. The Pareto tail fitted on the next slide, from Eurostat’s concentration data, does not satisfy it — which is the honest warning against reading a single tail fit as a structural parameter. Head, Mayer and Thoenig show a log-normal fits the middle of the distribution better and delivers an elasticity that varies with trade costs; this deck uses the constant-\(\theta\) version throughout, and this is the slide that says so.

The Two Margins in the Data

Code
fm <- fread("../data/ctm-firms.csv")

sz <- fm[class_type == "size" & nace_r2 == "TOTAL" & year == 2022 &
         class != "TOTAL" & !is.na(NR_ENT) & !is.na(THS_EUR) & NR_ENT > 0]
agg <- sz[, .(firms = sum(NR_ENT), value = sum(THS_EUR)), by = class]
agg[, `:=`(share_firms = 100 * firms / sum(firms),
           share_value = 100 * value / sum(value),
           avg_export_mn = value / firms / 1000)]

rk <- fm[class_type == "rank" & nace_r2 == "TOTAL" & year == 2022 & !is.na(THS_EUR)]
tot <- rk[class == "TOTAL", .(geo, denom = THS_EUR)]
con <- merge(rk[class != "TOTAL"], tot, by = "geo")[, share := THS_EUR / denom]
EU exporters to non-EU partners, 2022 (29 reporting countries)
 size_class  firms share_firms share_value avg_export_mn
     <char>  <int>       <num>       <num>         <num>
       LT10 839882        58.5         5.2          0.28
      10-49 382486        26.6         7.7          0.89
     50-249 156778        10.9        16.7          4.76
      GE250  56280         3.9        70.4         55.84

share of a country's exports held by its k largest exporters (mean over countries)
   class mean_share     n
  <char>      <num> <int>
    TOP5       27.7    27
   TOP10       36.1    27
   TOP20       45.4    27
   TOP50       57.9    27
  TOP100       67.1    27
  TOP500       84.9    27
 TOP1000       90.6    27

Pareto tail: log(share) on log(k) slope 0.2183  =>  shape a = 1.279   (R2 0.9547)
Code
import numpy as np, pandas as pd

fm = pd.read_csv("../data/ctm-firms.csv")
sz = fm[(fm.class_type == "size") & (fm.nace_r2 == "TOTAL") & (fm.year == 2022) &
        (fm["class"] != "TOTAL") & fm.NR_ENT.notna() & fm.THS_EUR.notna() &
        (fm.NR_ENT > 0)]
agg = sz.groupby("class").agg(firms=("NR_ENT", "sum"), value=("THS_EUR", "sum"))
agg["share_firms"]   = (100 * agg.firms / agg.firms.sum()).round(1)
agg["share_value"]   = (100 * agg.value / agg.value.sum()).round(1)
agg["avg_export_mn"] = (agg.value / agg.firms / 1000).round(2)
agg = agg.reindex(["LT10", "10-49", "50-249", "GE250"])

rk  = fm[(fm.class_type == "rank") & (fm.nace_r2 == "TOTAL") &
         (fm.year == 2022) & fm.THS_EUR.notna()]
tot = rk[rk["class"] == "TOTAL"][["geo", "THS_EUR"]].rename(columns={"THS_EUR": "denom"})
con = rk[rk["class"] != "TOTAL"].merge(tot, on="geo")
con["share"] = con.THS_EUR / con.denom
cc = con.groupby("class").share.agg(["mean", "size"])
ordr = ["TOP5", "TOP10", "TOP20", "TOP50", "TOP100", "TOP500", "TOP1000"]
cc = cc.reindex(ordr)
k  = np.array([float(s.replace("TOP", "")) for s in ordr])
b  = np.polyfit(np.log(k), np.log(cc["mean"].values), 1)
r2 = np.corrcoef(np.log(k), np.log(cc["mean"].values))[0, 1] ** 2

out = ("EU exporters to non-EU partners, 2022 (%d reporting countries)\n\n" % sz.geo.nunique() +
       agg[["firms", "share_firms", "share_value", "avg_export_mn"]].to_string() +
       "\n\nshare of a country's exports held by its k largest exporters (mean over countries)\n\n" +
       "\n".join("  %-8s %6.1f  n=%d" % (s, 100 * cc["mean"][s], cc["size"][s]) for s in ordr) +
       "\n\nPareto tail: log(share) on log(k) slope %.4f  =>  shape a = %.3f   (R2 %.4f)"
       % (b[0], 1 / (1 - b[0]), r2))
import sys
nbytes = sys.stdout.write(out + "\n")
EU exporters to non-EU partners, 2022 (29 reporting countries)

           firms  share_firms  share_value  avg_export_mn
class                                                    
LT10    839882.0         58.5          5.2           0.28
10-49   382486.0         26.6          7.7           0.89
50-249  156778.0         10.9         16.7           4.76
GE250    56280.0          3.9         70.4          55.84

share of a country's exports held by its k largest exporters (mean over countries)

  TOP5       27.7  n=27
  TOP10      36.1  n=27
  TOP20      45.4  n=27
  TOP50      57.9  n=27
  TOP100     67.1  n=27
  TOP500     84.9  n=27
  TOP1000    90.6  n=27

Pareto tail: log(share) on log(k) slope 0.2182  =>  shape a = 1.279   (R2 0.9549)
Code
sys.stdout.flush()
Code
set type double
quietly import delimited "../data/ctm-firms.csv", clear varnames(1)
quietly keep if nace_r2 == "TOTAL" & year == 2022

preserve
quietly keep if class_type == "size" & class != "TOTAL" & nr_ent < . & ths_eur < . & nr_ent > 0
quietly collapse (sum) nr_ent ths_eur, by(class)
quietly egen double tf = total(nr_ent)
quietly egen double tv = total(ths_eur)
quietly generate double share_firms   = 100*nr_ent/tf
quietly generate double share_value   = 100*ths_eur/tv
quietly generate double avg_export_mn = ths_eur/nr_ent/1000
display "EU exporters to non-EU partners, 2022, by size class"
list class nr_ent share_firms share_value avg_export_mn, noobs clean
restore

quietly keep if class_type == "rank" & ths_eur < .
preserve
quietly keep if class == "TOTAL"
quietly rename ths_eur denom
quietly keep geo denom
tempfile den
quietly save `den'
restore
quietly drop if class == "TOTAL"
quietly merge m:1 geo using `den', keep(match) nogenerate
quietly generate double share = ths_eur/denom
quietly collapse (mean) share (count) n = share, by(class)
quietly generate double k = real(subinstr(class, "TOP", "", .))
quietly generate double meanshare = 100*share
gsort k
display "share of a country's exports held by its k largest exporters"
list class meanshare n, noobs clean

quietly generate double ly = ln(share)
quietly generate double lk = ln(k)
regress ly lk
display "Pareto tail: shape a = " %6.3f 1/(1 - _b[lk])
EU exporters to non-EU partners, 2022, by size class

     class   nr_ent   share_f~s   share_v~e   avg_exp~n  
     10-49   382486   26.646166   7.6621316   .89452223  
    50-249   156778   10.922054   16.727636   4.7643815  
     GE250    56280   3.9207873   70.384161   55.844181  
      LT10   839882   58.510993   5.2260709   .27785237  

















share of a country's exports held by its k largest exporters

      class   meanshare    n  
       TOP5   27.717906   27  
      TOP10   36.090464   27  
      TOP20   45.419728   27  
      TOP50   57.903824   27  
     TOP100   67.058268   27  
     TOP500   84.937438   27  
    TOP1000   90.602018   27  

      Source |       SS           df       MS      Number of obs   =         7
-------------+----------------------------------   F(1, 5)         =    105.92
       Model |  1.10940227         1  1.10940227   Prob > F        =    0.0001
    Residual |  .052370729         5  .010474146   R-squared       =    0.9549
-------------+----------------------------------   Adj R-squared   =    0.9459
       Total |    1.161773         6  .193628833   Root MSE        =    .10234

------------------------------------------------------------------------------
          ly | Coefficient  Std. err.      t    P>|t|     [95% conf. interval]
-------------+----------------------------------------------------------------
          lk |   .2182281   .0212044    10.29   0.000     .1637205    .2727357
       _cons |  -1.504175   .0947328   -15.88   0.000    -1.747694   -1.260657
------------------------------------------------------------------------------

Pareto tail: shape a =  1.279

Firms with fewer than ten employees are 58.5% of EU exporters and 5.2% of export value. Firms with 250 or more are 3.9% of exporters and 70.4% of value. The average small exporter ships \(\euro 0.28\) m a year; the average large one, \(\euro 55.8\) m — a factor of 200.

Concentration is just as stark. The top five exporters in a country account for 27.7% of its exports on average; the top 100 for 67.1%; the top 1 000 for 90.6%.

The log-log fit gives a Pareto shape of \(a = 1.28\) with \(R^2 = 0.95\) — the straight line is real. But \(a = 1.28\) is far below the \(\sigma - 1 \approx 4\) that Part V’s elasticities imply, and Melitz requires \(a > \sigma - 1\) for aggregate trade to be finite. Something has to give: either the tail is not Pareto, or the aggregate elasticity is not the firm-level shape parameter.

Helpman–Melitz–Rubinstein — A First Stage That Will Not Fit

This slide has one language tab, and that is the finding.

HMR’s first stage is a probit with exporter and importer fixed effects. On this sample roughly 45 of those fixed effects are perfectly predicted — every pair with that exporter has a zero — so the likelihood has no interior maximum and those observations carry no information. Three ecosystems meet that fact three different ways:

  • Rfixest::feglm detects the perfectly predicted observations, drops them, and reports how many.
  • Pythonpyfixest has no probit at all, and statsmodels.Probit with 285 country dummies “converges” in four seconds and returns all-NaN coefficients. No error, no warning, no non-zero exit.
  • Stata — there is no high-dimensional fixed-effect probit.

The middle one is the lesson of this deck in a single object: a model that reports convergence, carries a full covariance matrix, and contains no numbers. An earlier draft worked around it with a linear probability model pushed through norm.ppf; that returned a Mills coefficient an order of magnitude away from R’s, with a large \(t\) statistic attached. Entirely plausible, and wrong. The tab was removed rather than repaired — reporting one language honestly beats reporting three languages that disagree for reasons nobody states.

If zeros arise from selection, the positive observations are not a random sample and log-OLS on them is biased twice over — once by Jensen, once by selection. HMR propose a two-step:

\[ \text{stage 1: } \; \Pr(X_{ij}>0) = \Phi(\mathbf{w}_{ij}'\boldsymbol{\gamma}), \qquad \text{stage 2: } \; \log X_{ij} = \mathbf{x}_{ij}'\boldsymbol{\beta} + \delta\,\hat{z}_{ij} + \rho\,\hat{\lambda}_{ij} + u_{ij} \]

\(\hat\lambda\) is the inverse Mills ratio, correcting selection; \(\hat z\) controls for the number of exporting firms. Identification needs a variable in stage 1 that is excluded from stage 2 — here common religion and the product of the two countries’ entry costs, both pair-level so the fixed effects do not absorb them.

Code
gv <- fread("../data/ctm-gravity.csv")
h <- gv[domestic == 0 & year == 2016 & !is.na(dist) &
        !is.na(entry_cost_o) & !is.na(entry_cost_d) & !is.na(comrelig)]
h[, `:=`(ldist = log(dist), pos = as.integer(trade > 0),
         entry_pair = log(1 + entry_cost_o) * log(1 + entry_cost_d))]

st1 <- feglm(pos ~ ldist + contig + comlang_off + comcol + comrelig + entry_pair |
               iso3_o + iso3_d, data = h, family = binomial(link = "probit"),
             vcov = "hetero", ssc = S)
sub <- h[obs(st1)]
sub[, zhat := predict(st1, type = "link")]
sub[, imr  := dnorm(zhat) / pnorm(zhat)]

st2 <- feols(log(trade) ~ ldist + contig + comlang_off + comcol + imr + zhat |
               iso3_o + iso3_d, data = sub[trade > 0], vcov = "hetero", ssc = S)
stage 1 -- probit, exclusions in bold
            Estimate Std. Error  z value Pr(>|z|)
ldist        -0.7057     0.0534 -13.2187   0.0000
contig       -0.3584     0.2501  -1.4332   0.1518
comlang_off   0.7301     0.0847   8.6158   0.0000
comcol        0.1036     0.0791   1.3100   0.1902
comrelig      0.0126     0.0994   0.1265   0.8994
entry_pair    0.0190     0.0127   1.5014   0.1333
attr(,"vcov_type")
[1] "Heteroskedasticity-robust"

stage 1 kept 10640 of 19460 observations (perfect prediction removes the rest)

stage 2 -- log trade with the Mills ratio and the latent-variable control
            Estimate Std. Error t value Pr(>|t|)
ldist        -1.6511     0.6454 -2.5584   0.0105
contig        1.3412     0.3611  3.7146   0.0002
comlang_off   0.9902     0.6605  1.4992   0.1339
comcol        0.6248     0.1352  4.6218   0.0000
imr          -0.3004     0.1782 -1.6861   0.0918
zhat          0.1574     0.8979  0.1753   0.8608
attr(,"vcov_type")
[1] "Heteroskedasticity-robust"

distance elasticity   naive -1.7880   HMR -1.6511   PPML -0.9146
Mills ratio  -0.3004  (se 0.1782, t -1.69)

The correction moves the distance elasticity from \(-1.788\) to \(-1.651\). PPML on the same sample gives \(-0.915\).

That is the honest summary of HMR: it addresses selection, it does not address Jensen’s inequality, and it leaves most of the gap intact. Two further problems are visible in the output rather than hidden:

  • The exclusion restrictions are weak. Common religion (\(t = 0.13\)) and the entry-cost product (\(t = 1.50\)) are both insignificant in stage 1. Without a strong excluded variable the second stage is identified off functional form.
  • The probit drops 8 820 of 19 460 observations to perfect prediction, so the correction is estimated on a selected subsample — the problem it was meant to solve.

The modern practice is PPML with the zeros retained, and HMR as a robustness check whose exclusion restriction must be defended.

Exercises — Estimation

  1. Reproduce Part I’s naive gravity regression on the 2020 wave instead of
    1. Does the distance elasticity move, and is the change larger or smaller than the difference between log-OLS and PPML in the same year?
  2. Add the Facebook Social Connectedness Index (scaled_sci_2021) to the Part III structural gravity specification. Does it survive pair fixed effects? Explain why or why not before running it.
  3. The border effect in Part III is \(10.2\). Recompute it using simple internal distance \(d_{ii} = 0.67\sqrt{\text{area}/\pi}\) instead of CEPII’s population-weighted measure, and quantify how much of the border effect is an artefact of the internal-distance construct.
  4. Run the RESET test of Part IV on the ITPD-E manufacturing sample rather than CEPII. Does PPML still fail at \(5\%\)? What changed?
  5. Estimate the Part III RTA specification separately for each of the four broad sectors. Where is the agreement effect largest, and does the lead test clear in any of them?
  6. Construct a deliberately separated subsample of ctm-gravity.csv and verify that fixest, pyfixest and ppmlhdfe drop the same observations and return the same coefficients.
  7. The distance puzzle. Containerisation, air freight and the internet should have made distance matter less. Fit Part III’s workhorse specification separately in each wave of ctm-itpde.csv and plot the coefficients with their confidence intervals. Is the elasticity shrinking? Hint: this is one loop around a fit you already have — fepois(trade ~ ldist + contig + comlang_off | exp_y + imp_y) once per year, vcov = ~iso3_o + iso3_d, collecting coef() and se() each pass. Then test it formally by interacting ldist with the wave in a single pooled regression, rather than reading it off six separate ones. Check your answer against Disdier and Head’s meta-analysis before concluding you have a bug.
  8. Sector-specific elasticities. Re-run Part V’s Head–Ries and tetrad estimators sector by sector on ctm-itpde.csv. Coverage is complete for manufacturing and partial for services and mining — report it rather than averaging over it.
  9. Asymmetric agreements. Replace Part III’s symmetric fta_wto dummy with directional membership. Deep agreements are rarely reciprocal in effect; does the lead test behave differently in each direction?
  10. Bias correction. Apply Weidner and Zylkin’s analytical correction to the three-way PPML estimates of Part III. How large is it relative to the clustered standard error?
  11. Bootstrap the elasticity. Tetrads and triple differences reuse the same flows many times over, so the classical standard error is wrong. Implement a pair-cluster bootstrap and compare it with the naive one.

Exercises — Counterfactuals & Testing

  1. Verify the Part VI solver against the known truth using \(\theta = 6\) instead of \(4\). The truth file was generated at \(\theta = 4\) — predict what will happen before you run it, then explain the result.
  2. Compute the ACR gains from trade for every country at \(\theta \in \{3,4,6,8\}\) and plot the range. Which countries’ rankings are robust to \(\theta\)?
  3. Re-run the European disintegration counterfactual with a \(10\%\) and a \(40\%\) trade-cost increase, and again at \(\theta \in \{3,4,6,8\}\). Is the welfare response linear in the shock? Should it be? Report the range rather than a point.
  4. Implement the null-shock check for the Part VII multi-sector solver, then deliberately break the calibration — scale one country’s value added by 1.01 — and confirm the check catches it.
  5. In Part VIII, recompute upstreamness using \(\mathbf{A}'\) instead of the row-normalised \(\boldsymbol{\Delta}\). Show that the ranking inverts and explain which economic question each matrix answers.
  6. Decompose the change in Italy’s exports from the Part VII tariff into the direct effect on metals and transport equipment and the indirect effect through input linkages. What fraction is indirect?
  7. Take Part V’s five estimates of \(\theta\) (3.27 to 5.37) into Part VI’s European counterfactual and report the resulting range of welfare effects for Luxembourg. Is the policy conclusion robust?
  8. Deficit treatment. Part VI holds trade deficits fixed in nominal terms. Re-solve with deficits proportional to income instead. Part VI showed this moves Japan by eight percentage points — which convention is defensible, and on what grounds?
  9. Sector-specific \(\theta_s\). Part VII uses a common \(\theta = 4\) for all eight sectors. Substitute Caliendo and Parro’s sector estimates and re-run the transatlantic counterfactual. The result is sensitive to the ranking of the elasticities, not only their mean — show that.
  10. Tariff revenue. Part VII models the tariff cut as a fall in iceberg costs, so no revenue is lost. Add the revenue, rebate it lump-sum, and re-solve. Liberalisation now costs the treasury; how much of the welfare gain survives?
  11. Non-tariff barriers. Applied transatlantic tariffs are already near zero, so removing all of them is worth under a tenth of a percent of real income. Add an ad-valorem-equivalent cut for regulatory barriers and find how large it must be to reproduce the published TTIP estimates. Then ask whether a number that large is credible as an iceberg cost.
  12. Full FIGARO. Solve Part VII at 46 countries × 64 industries instead of 12 × 8. Which conclusions survive aggregation, and what does the solve cost?
  13. Sector-level VAX. Part VIII aggregates value-added exports to the region. Recompute them sector by sector — that is where the interesting variation is.
  14. Source versus sink. Part VIII’s decomposition is source-based. Implement Borin and Mancini’s sink-based version; it answers a different question and gives different numbers. State which question each one answers.
  15. Value added, not gross. Recompute the US–China bilateral balance in value added rather than in gross terms. How much of the headline deficit survives?
  16. Date the slowdown. FIGARO covers 2010–2024. Run Part VIII’s decomposition on every year and date the slowdown in global value-chain integration.

Further Reading

Four references cover the whole deck. Everything in the other tabs is a detail of one of them.

  • Head & Mayer (2014), Gravity Equations: Workhorse, Toolkit, and Cookbook, Handbook of Int’l Economics 4 — the single best entry point; read it before anything else. 10.1016/B978-0-444-54314-1.00003-3
  • Yotov, Piermartini, Monteiro & Larch (2016), An Advanced Guide to Trade Policy Analysis, WTO/UNCTAD — a practical manual with Stata code throughout. wto.org
  • Costinot & Rodríguez-Clare (2014), Trade Theory with Numbers, Handbook of Int’l Economics 4 — the reference for everything in Parts VI and VII. 10.1016/B978-0-444-54314-1.00004-5
  • Antràs & Chor (2022), Global Value Chains, Handbook of Int’l Economics 5 — the survey behind Part VIII. 10.1016/bs.hesint.2022.02.005
  • Tinbergen (1962), Shaping the World Economy — the empirical regularity, with no theory attached. hdl.handle.net/1765/16826
  • Anderson (1979), A Theoretical Foundation for the Gravity Equation, AER 69(1). jstor.org/stable/1802501
  • Anderson & van Wincoop (2003), Gravity with Gravitas, AER 93(1) — multilateral resistance and the border puzzle. 10.1257/000282803321455214
  • Eaton & Kortum (2002), Technology, Geography, and Trade, Econometrica 70(5) — Ricardian gravity. 10.1111/1468-0262.00352
  • Melitz (2003), The Impact of Trade on Intra-Industry Reallocations, Econometrica 71(6). 10.1111/1468-0262.00467
  • Chaney (2008), Distorted Gravity, AER 98(4) — extensive and intensive margins. 10.1257/aer.98.4.1707
  • Santos Silva & Tenreyro (2006), The Log of Gravity, REStat 88(4) — the paper that ended log-OLS. 10.1162/rest.88.4.641
  • Santos Silva & Tenreyro (2011), Further simulation evidence, Economics Letters 112(2). 10.1016/j.econlet.2011.05.008
  • Fally (2015), Structural gravity and fixed effects, JIE 97(1) — why PPML’s fixed effects are multilateral resistance. 10.1016/j.jinteco.2015.05.005
  • Correia, Guimarães & Zylkin (2020), Fast Poisson estimation with high-dimensional fixed effects, Stata Journal 20(1) — ppmlhdfe. 10.1177/1536867X20909691
  • Weidner & Zylkin (2021), Bias and consistency in three-way gravity models, JIE 132. 10.1016/j.jinteco.2021.103513
  • Cameron, Gelbach & Miller (2011), Robust Inference With Multiway Clustering, JBES 29(2). 10.1198/jbes.2010.07136
  • Head & Ries (2001), Increasing Returns Versus National Product Differentiation, AER 91(4) — the ratio estimator. 10.1257/aer.91.4.858
  • Head, Mayer & Ries (2010), The erosion of colonial trade linkages, JIE 81(1) — tetrads. 10.1016/j.jinteco.2010.01.002
  • Baier & Bergstrand (2007), Do free trade agreements actually increase trade?, JIE 71(1). 10.1016/j.jinteco.2006.02.005
  • Dekle, Eaton & Kortum (2008), Global Rebalancing with Gravity, IMF Staff Papers 55(3) — exact hat algebra. 10.1057/imfsp.2008.17
  • Arkolakis, Costinot & Rodríguez-Clare (2012), New Trade Models, Same Old Gains?, AER 102(1). 10.1257/aer.102.1.94
  • Caliendo & Parro (2015), Estimates of the Trade and Welfare Effects of NAFTA, REStud 82(1). 10.1093/restud/rdu035
  • Anderson, Larch & Yotov (2018), GEPPML, The World Economy 41(10). 10.1111/twec.12664
  • Johnson & Noguera (2012), Accounting for intermediates, JIE 86(2) — the VAX ratio. 10.1016/j.jinteco.2011.10.003
  • Koopman, Wang & Wei (2014), Tracing Value-Added and Double Counting in Gross Exports, AER 104(2). 10.1257/aer.104.2.459
  • Borin & Mancini (2023), Measuring what matters in value-added trade, Economic Systems Research 35(4). 10.1080/09535314.2022.2153221
  • Antràs, Chor, Fally & Hillberry (2012), Measuring the Upstreamness of Production and Trade Flows, AER P&P 102(3). 10.1257/aer.102.3.412
  • Helpman, Melitz & Rubinstein (2008), Estimating Trade Flows, QJE 123(2) — the selection correction. 10.1162/qjec.2008.123.2.441
  • Conte, Cotterlaz & Mayer (2022), The CEPII Gravity Database. cepii.fr
  • Borchert, Larch, Shikher & Yotov (2021), ITPD-E — the release with domestic flows. 10.1016/j.inteco.2020.08.001
  • Eurostat FIGARO — inter-country supply, use and input–output tables, 2010–2024. ec.europa.eu/eurostat
  • Eurostat Trade by Enterprise Characteristics — exporters by size and concentration. ec.europa.eu/eurostat

Software Toolkit

Task R Python Stata
PPML, high-dimensional FE fixest::fepois pyfixest.fepois ppmlhdfe
Gamma / Gaussian PML fixest::feglm sm.GLM + dummies glm, family() link(log)
Multi-way clustering vcov = ~a + b vcov={"CRV1":"a + b"} cluster(a b)
Separation detection note in output UserWarning reported natively
Probit with two-way FE feglm(binomial("probit")) — (LPM instead)
Dense linear algebra base solve numpy.linalg Mata luinv, lusolve
\(n\)-dimensional arrays array numpy — (pointer matrices only)
Nested GE fixed point native native, vectorised Mata, 2-D problems only

Three slides in this deck have no Stata tab. Each omission is a real limitation, not an oversight:

  1. Part VII, the multi-sector solver. Mata has no \(n\)-dimensional arrays. The \(12\times8\times12\) share tensor and the \(8\times8\times12\) input–output tensor would have to be carried as pointer matrices.
  2. Part IX, the HMR first stage. There is no high-dimensional-fixed-effect probit in Stata. probit with 300 dummies hits the incidental-parameter problem and drops most of the sample.
  3. Part V, the tetrad standard errors are reported from R and Python only; the Stata tab shows the point estimate.

Everything else — PPML with three-way fixed effects, the single-sector GE solver, the full \(96\times96\) Leontief system and every decomposition in Part VIII — runs in Stata and agrees with R and Python to the digits printed.

Before a gravity result leaves your desk:

  • Which estimator, and did you run RESET?
  • How many zeros, and what did the estimator do with them?
  • Was separation checked, and how many observations were dropped?
  • Which variance estimator, and how many clusters in each dimension?
  • For a counterfactual: which \(\theta\), which deficit treatment, which aggregation?
  • Did the null shock return exactly one?
  • Do the fitted values add up to observed exports, country by country?

Thank You

Athanassios Stavrakoudis
Applied Informatics and Computational Economics Lab
Department of Economics
University of Ioannina, Greece

astavrak@uoi.gr · linkedin.com/in/astavrakoudis