Session 7 · Four Papers, One Toolkit

The course’s models on the papers’ data — three hours, in autonomy

Thierry Warin, PhD

HEC Montréal

Outline · The hour ahead

  1. How the afternoon works
  2. Paper 1 · news sentiment and stock returns — OLS and the three logits
  3. Paper 2 · social groups — binary, ordinal and multinomial logits
  4. Paper 3 · foreign direct investment — OLS, ridge, lasso, elastic net
  5. Paper 4 · tariffs and productivity — OLS, the three logits, ridge, lasso, elastic net
  6. Before the midterm

What session 7 is for

The mathematics

  • Choose which of the course’s models fits a new outcome — a number, a yes/no, ordered or unordered categories
  • Read every result as a sentence, in units, and say what it does not license
  • Compare OLS with ridge, the lasso and the elastic net on data the models did not see

The Python

  • Load a paper’s data into pandas straight from a web address
  • Run the code cells of sessions 3 to 5 on new data, changing only the variable names
  • Run a whole analysis as one script in VS Codium, step by step, in a folder of your own

The international business

  • Use international-business data from published papers
  • Explain a result to someone who will not read the code
  • Read a result the way a referee would, before trusting it

Your Python so far

Everything you need to type is on one sheet — how to start Python in VS Codium on your platform, how to make a file and run it, the DataFrame basics, and every name from sessions 1 to 7, each with a line showing it in use.

Python Cheatsheet · Session 07 (PDF) · in the repository at 07-…/PYTHON-CHEATSHEET.pdf

One sheet per package, functions explained with pictures: pandas · NumPy · matplotlib · statsmodels · scikit-learn

Print it, or keep it open beside the editor. Nothing on it is examinable; all of it is assumed.

How the afternoon works

No new method

For three hours you take the data of four papers you read before sessions 2 to 6 and plug it into the models of the course — with exactly the code you typed in class, only the variable names changed: OLS, the logits (binary, ordinal, multinomial), ridge, the lasso and the elastic net.

  • One script per paper, cut into numbered steps, run in a folder of your own: all five in one zip
  • One guide per paper, with the same steps and the numbers you should see: 02-practice/replications/

You are not following the papers. Each step is a code cell you already know from sessions 3 to 5, run on new data: the work is to read the output in words, with units.

It is also the best revision there is for the midterm on 28 October: every model comes back once, on real data.

The three hours

When What
0:00 – 0:15 Part 0 · Set up — a folder of your own, four packages, the scripts, check_setup.py
0:15 – 0:55 Paper 1 · Session 02 data · news sentiment and stock returns — OLS, the three logits
0:55 – 1:35 Paper 2 · Session 04 data · social groups — binary, ordinal, multinomial logits
1:35 – 1:45 Break
1:45 – 2:15 Paper 3 · Session 05 data · foreign direct investment — OLS, ridge, lasso, elastic net
2:15 – 3:00 Paper 4 · Session 06 data · tariffs and productivity — OLS, the three logits, ridge, lasso, elastic net

Fall behind? Move on at the half-hour mark anyway. Three papers read well beat four rushed.

Alone, or as a team

Alone

Do the papers in order. Guide open on one side of the screen, script on the other.

As a team of 2 to 5

  • Split and teach. Each person takes one or two papers. At 2:15, five minutes each to walk the others through the data, the models, and the results in words.
  • Pair at one screen. One types, one reads the guide and checks the numbers. Swap every paper.

Either way, every member runs at least one script on their own machine.

Part 0 · Set up, in a folder of your own

Not the course repository, and no requirements.txt: an empty folder — Desktop/session-07 — opened in VS Codium (File → Open Folder…), plain Python, four packages by name. In its terminal:

open /Applications/Python\ 3*/Install\ Certificates.command   # python.org Python only, once
python3 -m venv .venv
source .venv/bin/activate
pip install pandas statsmodels scikit-learn matplotlib
curl -LO https://warint.github.io/quantitative-methods/session-07-scripts.zip
unzip session-07-scripts.zip
python check_setup.py
python3 -m venv .venv
source .venv/bin/activate
pip install pandas statsmodels scikit-learn matplotlib
curl -LO https://warint.github.io/quantitative-methods/session-07-scripts.zip
unzip session-07-scripts.zip
python check_setup.py
py -m venv .venv
.venv\Scripts\Activate.ps1
pip install pandas statsmodels scikit-learn matplotlib
$zip = "https://warint.github.io/quantitative-methods/session-07-scripts.zip"
Invoke-WebRequest $zip -OutFile session-07-scripts.zip
Expand-Archive session-07-scripts.zip -DestinationPath .
python check_setup.py

Every line of check_setup.py should say OK. The scripts read the papers’ data from the course site, so all you need is the internet.

Running a script one step at a time

Every script is cut into blocks that begin # Step 1 · …, # Step 2 · … — the guide has the same steps, in the same order.

  • One step: select its lines, press Shift+Enter. The result prints in the terminal, and the variables stay in memory for the next step.
  • The whole script: the Run triangle ▷, top right. Each takes a few seconds.
  • Figures open in a window; paper 4 also saves its residual plot in your folder.

After every step, compare what you got with You should see. If a number differs, stop and find out why. That is the exercise, not a detour from it.

Every guide has the same shape

1 The data one line on the paper, what one row is, the variables
2 The models, and where the code comes from which session’s slide each step copies
3 Steps each with its code and You should see
4 Interpretation the result as a sentence, in units
5 What this does not license the midterm’s Part D
6 Pitfalls what tripped us, so it does not trip you

Paper 1 · Session 02 · News sentiment and stock returns

The data, and the models

The data of Fraiberger, Lee, Puy & Ranciere (2021), Media sentiment and international asset prices — plugged into the course’s models, not the paper’s design.

One row = one country on one calendar day: 25 countries, 1991–2017, 135,082 complete rows. F0_index today’s return (%) · L1_sent1_best yesterday’s news sentiment (sd) · L1_vix yesterday’s VIX · up = 1 if the market rose.

OLS — session 03

  1. Load the data
  2. Return on sentiment
  3. How much did it explain?
    • yesterday’s VIX
  4. The residual plot

Logits — session 04

  • 6–8 · Binary: does the market go up? log-odds → odds → probabilities
  • 9–11 · Ordinal: the day in thirds, down < flat < up
  • 12–14 · Multinomial: the same three days, without their order

Each cell, line for line

The session 03 and 04 lectures’ code, with only the data and the variable names changed.

The solutions, step by step

Each tab is one step, run when these slides were built: the code, then what it prints. Compare your own output with it line by line.

The lines that open every script: import what the steps use, and the address of the data on the course site.

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import statsmodels.formula.api as smf
import statsmodels.api as sm
from statsmodels.miscmodels.ordinal_model import OrderedModel

# The data: one CSV per paper on the course site, read over the internet
DATA = "https://warint.github.io/quantitative-methods/data/session-07/"

Step 1 · Load the data: one row is a country-day

# Step 1 · Load the data: one row is a country-day
d = pd.read_csv(DATA + "paper1_fraiberger_2021.csv")
d = d.dropna(subset=["F0_index", "L1_sent1_best", "L1_vix"])
d["up"] = (d["F0_index"] > 0).astype(int)            # 1 if the market rose that day
print(f"{len(d):,} country-days, {d['iso'].nunique()} countries, share of up days {d['up'].mean():.3f}")
135,082 country-days, 25 countries, share of up days 0.519

Step 2 · Three lines of Python (session 03): today’s return on yesterday’s news

# Step 2 · Three lines of Python (session 03): today's return on yesterday's news
# The formula reads "F0_index explained by L1_sent1_best". statsmodels
# adds the intercept for you; write ~ 0 + x if you ever want it suppressed,
# and know that R-squared stops being interpretable when you do.
model = smf.ols("F0_index ~ L1_sent1_best", data=d).fit()

# .params holds the two coefficients. The slope is in percentage points of
# return per one standard deviation of sentiment — always say the units.
print(model.params)
Intercept        0.056254
L1_sent1_best    0.071903
dtype: float64

Step 3 · How much did the line explain? (session 03)

# Step 3 · How much did the line explain? (session 03)
# R-squared is the share of variance the line accounts for — and nothing about
# whether the line is the right shape. The rest of this hour is that difference.
print(f"R-squared = {model.rsquared:.3f}")
R-squared = 0.001

Step 4 · Two predictors: what “controlling for” means (session 03)

# Step 4 · Two predictors: what "controlling for" means (session 03)
# Adding a second predictor changes what the first coefficient means: it is now
# the association holding global risk (yesterday's VIX) fixed, not the raw
# association. The two answer different questions, and the number will move.
model2 = smf.ols("F0_index ~ L1_sent1_best + L1_vix", data=d).fit()
print(model2.params)
Intercept        0.156209
L1_sent1_best    0.068528
L1_vix          -0.005005
dtype: float64

Step 5 · The residual plot is the instrument (session 03)

# Step 5 · The residual plot is the instrument (session 03)
# Residuals vs Fitted: is there structure the straight line missed?
plt.scatter(model2.fittedvalues, model2.resid, s=12, alpha=0.6)
plt.axhline(0, color="black", lw=1)
plt.xlabel("Fitted values"); plt.ylabel("Residuals")
plt.show()

Step 6 · Binary logit: log-odds (session 04, LR 6) — does the market go up today?

# Step 6 · Binary logit: log-odds (session 04, LR 6) — does the market go up today?
# Fit the logistic regression. The coefficients come out as log-odds.
m2 = smf.logit("up ~ L1_sent1_best", data=d).fit(disp=0)
print(m2.params.round(4))
Intercept        0.0747
L1_sent1_best    0.0773
dtype: float64

Step 7 · Binary logit: odds (session 04, LR 7)

# Step 7 · Binary logit: odds (session 04, LR 7)
# Log-odds -> odds ratio: take the exponential of each coefficient
print(np.exp(m2.params["L1_sent1_best"]).round(4))
1.0804

Step 8 · Binary logit: predicted probability (session 04, LR 8)

# Step 8 · Binary logit: predicted probability (session 04, LR 8)
# predict() does the same calculation for you: a bad, an ordinary and a good news day
print(m2.predict(pd.DataFrame({"L1_sent1_best": [-1, 0, 1]})).round(3))
0    0.499
1    0.519
2    0.538
dtype: float64

Step 9 · Ordinal logit (session 04, OLR 3) — the day’s return in thirds: down < flat < up

# Step 9 · Ordinal logit (session 04, OLR 3) — the day's return in thirds: down < flat < up
d["day"] = pd.qcut(d["F0_index"], 3, labels=["down", "flat", "up"])
# Keep the order: down < flat < up
order = ["down", "flat", "up"]
d["day_c"] = pd.Categorical(d["day"], categories=order, ordered=True)
om = OrderedModel(d["day_c"], d[["L1_sent1_best", "L1_vix"]], distr="logit").fit(method="bfgs", disp=0)
print(om.summary())
                             OrderedModel Results                             
==============================================================================
Dep. Variable:                  day_c   Log-Likelihood:            -1.4825e+05
Model:                   OrderedModel   AIC:                         2.965e+05
Method:            Maximum Likelihood   BIC:                         2.965e+05
Date:                Thu, 05 Nov 2026                                         
Time:                        08:56:50                                         
No. Observations:              135082                                         
Df Residuals:                  135078                                         
Df Model:                           2                                         
=================================================================================
                    coef    std err          z      P>|z|      [0.025      0.975]
---------------------------------------------------------------------------------
L1_sent1_best     0.0761      0.005     14.372      0.000       0.066       0.086
L1_vix           -0.0057      0.001     -8.969      0.000      -0.007      -0.004
down/flat        -0.8051      0.014    -58.212      0.000      -0.832      -0.778
flat/up           0.3285      0.004     78.872      0.000       0.320       0.337
=================================================================================

Step 10 · Ordinal logit: log-odds and odds (session 04, OLR 4)

# Step 10 · Ordinal logit: log-odds and odds (session 04, OLR 4)
# Log-odds: the slopes as fitted (the cutpoints are left out)
print(om.params[["L1_sent1_best", "L1_vix"]].round(3))
# Proportional-odds ratios
print(np.exp(om.params[["L1_sent1_best", "L1_vix"]]).round(3))
L1_sent1_best    0.076
L1_vix          -0.006
dtype: float64
L1_sent1_best    1.079
L1_vix           0.994
dtype: float64

Step 11 · Ordinal logit: probabilities (session 04, OLR 5)

# Step 11 · Ordinal logit: probabilities (session 04, OLR 5)
# A bad and a good news day, with the VIX at 20. Columns: down, flat, up.
new = pd.DataFrame({"L1_sent1_best": [-1, 1], "L1_vix": [20, 20]})
print(pd.DataFrame(np.asarray(om.predict(new)), columns=order, index=["bad news", "good news"]).round(3))
            down   flat     up
bad news   0.351  0.333  0.316
good news  0.317  0.334  0.349

Step 12 · Multinomial logit (session 04, MLR 3) — the same three days, without their order

# Step 12 · Multinomial logit (session 04, MLR 3) — the same three days, without their order
# Code 0 (flat) is the baseline: every coefficient reads "relative to a flat day"
levels = ["flat", "down", "up"]
d["day_m"] = pd.Categorical(d["day"], categories=levels).codes
X = d[["L1_sent1_best"]].astype(float)
X["L1_vix"] = d["L1_vix"].astype(float)
X = sm.add_constant(X)
# Two sets of coefficients at once, each against the flat-day baseline
mn = sm.MNLogit(d["day_m"], X).fit(disp=0)
print(mn.summary().tables[1])          # the coefficient table
=================================================================================
      day_m=1       coef    std err          z      P>|z|      [0.025      0.975]
---------------------------------------------------------------------------------
const            -0.7902      0.019    -41.680      0.000      -0.827      -0.753
L1_sent1_best    -0.0947      0.007    -13.395      0.000      -0.109      -0.081
L1_vix            0.0403      0.001     44.094      0.000       0.038       0.042
---------------------------------------------------------------------------------
      day_m=2       coef    std err          z      P>|z|      [0.025      0.975]
---------------------------------------------------------------------------------
const            -0.6648      0.019    -34.911      0.000      -0.702      -0.628
L1_sent1_best     0.0065      0.007      0.921      0.357      -0.007       0.020
L1_vix            0.0343      0.001     37.069      0.000       0.032       0.036
=================================================================================

Step 13 · Multinomial logit: log-odds and relative risk ratios (session 04, MLR 4)

# Step 13 · Multinomial logit: log-odds and relative risk ratios (session 04, MLR 4)
# Log-odds and RRR, for down vs flat and up vs flat
print(pd.concat({"log-odds": mn.params, "RRR": np.exp(mn.params)}, axis=1)
      .set_axis(["log-odds: down", "log-odds: up", "RRR: down", "RRR: up"], axis=1).round(3))
               log-odds: down  log-odds: up  RRR: down  RRR: up
const                  -0.790        -0.665      0.454    0.514
L1_sent1_best          -0.095         0.007      0.910    1.007
L1_vix                  0.040         0.034      1.041    1.035

Step 14 · Multinomial logit: probabilities (session 04, MLR 5)

# Step 14 · Multinomial logit: probabilities (session 04, MLR 5)
# The VIX at 20, across news days from bad to good
grid = pd.DataFrame({"const": 1.0, "L1_sent1_best": [-2, -1, 0, 1, 2], "L1_vix": 20.0})
out = pd.DataFrame(mn.predict(grid).values, columns=levels, index=grid["L1_sent1_best"])
print(out.round(3))
                flat   down     up
L1_sent1_best                     
-2             0.309  0.379  0.311
-1             0.319  0.357  0.324
 0             0.329  0.335  0.336
 1             0.339  0.313  0.348
 2             0.348  0.292  0.360

What you should find

Model In words
OLS one sd better news → a return 0.07 points higher the next day; R² 0.001
Binary logit odds of an up day × 1.08 per sd; P(up) 0.50 → 0.54 from bad to good news
Ordinal logit odds of a higher third × 1.08 per sd of sentiment, × 0.994 per point of VIX
Multinomial logit good news mostly makes a down day less likely (RRR 0.91); a higher VIX makes both down and up days more likely than a flat one

The multinomial shows what the ordinal cannot: global risk does not push days down the scale, it pushes them away from flat — big moves either way.

What it does not license

  • No causal claim. The same event can move both the news and the market; a one-day lag does not rule that out.
  • No trading rule. 0.07 points a day, with \(R^2 = 0.001\), is small next to the day-to-day noise — and before costs.
  • Significant is not large. With 135,082 rows, almost any slope is significant: read its size in units, and the R².

Paper 2 · Session 04 · What happens next to a social group

The data, and the models

Saganowski et al. (2019) · one row = one group of students messaging each other at UC Irvine, in one week (498 groups). Outcome: what happened next — dissolving 35.5%, shrinking 25.9%, growing 25.1%, continuing 10.0%. Features: log2_size (+1 = twice as big), avg_degree, aging.

Steps Model Session-04 tabs, copied line for line
3–5 binary: does it dissolve? LR 6 · LR 7 · LR 8
6–8 ordinal: shrinking < continuing < growing OLR 3 · OLR 4 · OLR 5
9–11 multinomial: four events vs dissolving MLR 3 · MLR 4 · MLR 5

Only the data and the variable names change.

The solutions, step by step

Each tab is one step, run when these slides were built: the code, then what it prints. Compare your own output with it line by line.

The lines that open every script: import what the steps use, and the address of the data on the course site.

import warnings
warnings.filterwarnings("ignore")            # convergence notes are not part of the answer

import numpy as np
import pandas as pd
import statsmodels.api as sm
import statsmodels.formula.api as smf
from statsmodels.miscmodels.ordinal_model import OrderedModel

pd.set_option("display.width", 130)       # as in the session-04 slides, a little wider
pd.set_option("display.max_columns", 10)

# The data: one CSV per paper on the course site, read over the internet
DATA = "https://warint.github.io/quantitative-methods/data/session-07/"

Step 1 · Load the groups, and what happened to each one next week

# Step 1 · Load the groups, and what happened to each one next week
FILE = DATA + "paper2_saganowski_2019.csv"     # the authors' file, unchanged
raw = pd.read_csv(FILE)
print(raw.shape)
print(raw["event_type"].value_counts(normalize=True).round(3))   # the base rates
(498, 89)
event_type
dissolving    0.355
shrinking     0.259
growing       0.251
continuing    0.100
splitting     0.018
merging       0.016
Name: proportion, dtype: float64

Step 2 · Three features a person can read

# Step 2 · Three features a person can read
groups = pd.DataFrame({
    "event": raw["event_type"],
    "log2_size": np.log2(raw["g1_size"]),            # +1 = the group is twice as big
    "avg_degree": raw["g1_avg_group_degree_total"],  # ties per member, inside the group
    "aging": raw["g1_IlhanAging"]})                  # how long its members have been around
print(groups[["log2_size", "avg_degree", "aging"]].median().round(2))
log2_size     2.32
avg_degree    2.67
aging         8.46
dtype: float64

Step 3 · Binary logit: log-odds (session 04, LR 6)

# Step 3 · Binary logit: log-odds (session 04, LR 6)
groups["dissolve"] = (groups["event"] == "dissolving").astype(int)
# Fit the logistic regression. The coefficients come out as log-odds.
m2 = smf.logit("dissolve ~ log2_size", data=groups).fit(disp=0)
print(m2.params.round(4))
Intercept    0.7784
log2_size   -0.5716
dtype: float64

Step 4 · Binary logit: odds (session 04, LR 7)

# Step 4 · Binary logit: odds (session 04, LR 7)
# Log-odds -> odds ratio: take the exponential of each coefficient
print(np.exp(m2.params["log2_size"]).round(4))
0.5646

Step 5 · Binary logit: predicted probability (session 04, LR 8)

# Step 5 · Binary logit: predicted probability (session 04, LR 8)
# predict() does the same calculation for you: groups of 4, 8 and 16 members
print(m2.predict(pd.DataFrame({"log2_size": [2, 3, 4]})).round(3))
0    0.410
1    0.282
2    0.181
dtype: float64

Step 6 · Ordinal logit: the code (session 04, OLR 3)

# Step 6 · Ordinal logit: the code (session 04, OLR 3)
# Keep the order: shrinking < continuing < growing
order = ["shrinking", "continuing", "growing"]
dat = groups[groups["event"].isin(order)].copy()
dat["event_c"] = pd.Categorical(dat["event"], categories=order, ordered=True)
om = OrderedModel(dat["event_c"], dat[["log2_size", "avg_degree", "aging"]], distr="logit").fit(method="bfgs", disp=0)
print(om.summary())
                             OrderedModel Results                             
==============================================================================
Dep. Variable:                event_c   Log-Likelihood:                -275.91
Model:                   OrderedModel   AIC:                             561.8
Method:            Maximum Likelihood   BIC:                             580.4
Date:                Thu, 05 Nov 2026                                         
Time:                        08:56:51                                         
No. Observations:                 304                                         
Df Residuals:                     299                                         
Df Model:                           3                                         
========================================================================================
                           coef    std err          z      P>|z|      [0.025      0.975]
----------------------------------------------------------------------------------------
log2_size               -1.3384      0.217     -6.170      0.000      -1.764      -0.913
avg_degree              -0.0755      0.222     -0.340      0.734      -0.511       0.360
aging                   -0.0154      0.011     -1.353      0.176      -0.038       0.007
shrinking/continuing    -4.0540      0.605     -6.698      0.000      -5.240      -2.868
continuing/growing      -0.1979      0.132     -1.497      0.134      -0.457       0.061
========================================================================================

Step 7 · Ordinal logit: log-odds -> odds (session 04, OLR 4)

# Step 7 · Ordinal logit: log-odds -> odds (session 04, OLR 4)
# Log-odds: the slopes as fitted (the cutpoints are left out)
print(om.params[["log2_size", "avg_degree", "aging"]].round(3))
# Proportional-odds ratios
print(np.exp(om.params[["log2_size", "avg_degree", "aging"]]).round(3))
log2_size    -1.338
avg_degree   -0.075
aging        -0.015
dtype: float64
log2_size     0.262
avg_degree    0.927
aging         0.985
dtype: float64

Step 8 · Ordinal logit: probabilities (session 04, OLR 5)

# Step 8 · Ordinal logit: probabilities (session 04, OLR 5)
# A 4-member group, and an otherwise identical 16-member one.
# Columns: shrinking, continuing, growing.
new = pd.DataFrame({"log2_size": [2, 4], "avg_degree": [2.7, 2.7], "aging": [8.5, 8.5]})
print(pd.DataFrame(np.asarray(om.predict(new)), columns=order, index=["4 members", "16 members"]).round(3))
            shrinking  continuing  growing
4 members       0.261       0.184    0.555
16 members      0.837       0.084    0.079

Step 9 · Multinomial logit: the code (session 04, MLR 3)

# Step 9 · Multinomial logit: the code (session 04, MLR 3)
# Code 0 (dissolving) is the baseline: every coefficient reads "relative to dissolving"
levels = ["dissolving", "shrinking", "growing", "continuing"]
ml = groups[groups["event"].isin(levels)].copy()
ml["event_c"] = pd.Categorical(ml["event"], categories=levels).codes
X = ml[["log2_size", "avg_degree", "aging"]].astype(float)
X = sm.add_constant(X)
# Three sets of coefficients at once, each against the dissolving baseline
mn = sm.MNLogit(ml["event_c"], X).fit(disp=0)
print(mn.summary().tables[1])          # the coefficient table

Step 9 · Multinomial logit: the code (session 04, MLR 3) — what it prints

==============================================================================
 event_c=1       coef    std err          z      P>|z|      [0.025      0.975]
------------------------------------------------------------------------------
const         -6.0011      0.763     -7.869      0.000      -7.496      -4.506
log2_size      1.1698      0.209      5.601      0.000       0.760       1.579
avg_degree     0.8228      0.254      3.242      0.001       0.325       1.320
aging          0.0349      0.014      2.488      0.013       0.007       0.062
------------------------------------------------------------------------------
 event_c=2       coef    std err          z      P>|z|      [0.025      0.975]
------------------------------------------------------------------------------
const         -1.2634      0.580     -2.179      0.029      -2.400      -0.127
log2_size     -0.1244      0.214     -0.581      0.562      -0.544       0.296
avg_degree     0.4292      0.227      1.890      0.059      -0.016       0.874
aging          0.0115      0.013      0.916      0.359      -0.013       0.036
------------------------------------------------------------------------------
 event_c=3       coef    std err          z      P>|z|      [0.025      0.975]
------------------------------------------------------------------------------
const         -0.4039      0.812     -0.498      0.619      -1.995       1.187
log2_size     -0.7569      0.357     -2.118      0.034      -1.457      -0.056
avg_degree     0.1105      0.314      0.352      0.725      -0.505       0.726
aging          0.0351      0.016      2.223      0.026       0.004       0.066
==============================================================================

Step 10 · Multinomial logit: log-odds -> relative risk ratios (session 04, MLR 4)

# Step 10 · Multinomial logit: log-odds -> relative risk ratios (session 04, MLR 4)
# Log-odds and RRR, for shrinking, growing and continuing vs dissolving
print(pd.concat({"log-odds": mn.params, "RRR": np.exp(mn.params)}, axis=1)
      .set_axis(["log-odds: shrinking", "log-odds: growing", "log-odds: continuing",
                 "RRR: shrinking", "RRR: growing", "RRR: continuing"], axis=1).round(3))
            log-odds: shrinking  log-odds: growing  log-odds: continuing  RRR: shrinking  RRR: growing  RRR: continuing
const                    -6.001             -1.263                -0.404           0.002         0.283            0.668
log2_size                 1.170             -0.124                -0.757           3.221         0.883            0.469
avg_degree                0.823              0.429                 0.110           2.277         1.536            1.117
aging                     0.035              0.012                 0.035           1.035         1.012            1.036

Step 11 · Multinomial logit: probabilities (session 04, MLR 5)

# Step 11 · Multinomial logit: probabilities (session 04, MLR 5)
# Typical groups (median degree and aging), from 4 to 32 members
grid = pd.DataFrame({"const": 1.0, "log2_size": [2, 3, 4, 5],
                     "avg_degree": 2.7, "aging": 8.5})
out = pd.DataFrame(mn.predict(grid).values, columns=levels, index=grid["log2_size"])
print(out.round(3))
           dissolving  shrinking  growing  continuing
log2_size                                            
2               0.424      0.135    0.328       0.113
3               0.353      0.362    0.241       0.044
4               0.201      0.665    0.122       0.012
5               0.082      0.872    0.044       0.002

What you should find

Model Size effect In probabilities
binary odds ratio 0.565 per doubling P(dissolve): 0.41 at 4 members, 0.18 at 16
ordinal odds ratio 0.262 per doubling P(shrink): 0.26 at 4 members, 0.84 at 16
multinomial RRR 3.22, shrinking vs dissolving 4 → 32 members: P(dissolve) 0.42 → 0.08, P(shrink) 0.14 → 0.87

Large groups rarely vanish in one week — but they lose members. Three models, three questions, one story.

What it does not license

  • No causal reading. Adding members would not by itself keep a group alive.
  • No claim beyond these groups. One university, one tracking method, weekly windows.
  • Nothing on splitting or merging. At 3.4% together, they are too rare to model here.

Paper 3 · Session 05 · What attracts foreign direct investment

The data, and the models

Blonigen & Piger (2014), Determinants of foreign direct investment · the course’s rebuild of its data for 2019, read from the course site. One row = one parent → host country pair; 765 pairs with positive FDI and all 56 candidate predictors. Outcome: log_fdi.

OLS — the code of session 03

  1. Load: logs, complete rows
  2. Three lines of Python: FDI on host GDP
  3. How much did the line explain?
  4. Two predictors: + distance

Penalties — the code of session 05

  1. Ridge, λ by cross-validation
  2. Lasso: how many kept?
  3. Elastic net, mix 0.5
  4. Which predicts best on held-out pairs?

Same code as in class; only the data and the variable names change. We do not follow the paper.

The solutions, step by step

Each tab is one step, run when these slides were built: the code, then what it prints. Compare your own output with it line by line.

The lines that open every script: import what the steps use, and the address of the data on the course site.

import warnings
warnings.filterwarnings("ignore")       # convergence notes are not part of the answer
import numpy as np
import pandas as pd
import statsmodels.formula.api as smf
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error

# The data: one CSV per paper on the course site, read over the internet
DATA = "https://warint.github.io/quantitative-methods/data/session-07/"

Step 1 · Load the data: one row per country pair, log FDI, 56 candidates

# Step 1 · Load the data: one row per country pair, log FDI, 56 candidates
fdi = pd.read_csv(DATA + "paper3_blonigen_piger_2014.csv")
YCOL = "log_fdi"
XCOLS = [c for c in fdi.columns if c not in ("parent", "host", "year", "fdi_stock_musd")]
d = fdi[fdi.fdi_stock_musd > 0].dropna(subset=XCOLS).copy()   # positive FDI, complete rows
d["log_fdi"] = np.log(d["fdi_stock_musd"])
for v in ["parent_gdp", "host_gdp", "distance"]:                # gravity variables in logs
    d[v] = np.log(d[v])
print(f"country pairs: {len(d)}   candidate predictors: {len(XCOLS)}")
country pairs: 765   candidate predictors: 56

Step 2 · OLS: three lines of Python (session 3)

# Step 2 · OLS: three lines of Python (session 3)
# The formula reads "log_fdi explained by host_gdp" (both in logs).
model = smf.ols("log_fdi ~ host_gdp", data=d).fit()

# .params holds the two coefficients. The slope is an elasticity: % more FDI
# per 1% more host GDP — always say the units.
print(model.params)
Intercept   -3.254479
host_gdp     0.473526
dtype: float64

Step 3 · How much did the line explain?

# Step 3 · How much did the line explain?
# R-squared is the share of variance the line accounts for — and nothing about
# whether the line is the right shape.
print(f"R-squared = {model.rsquared:.3f}")
R-squared = 0.051

Step 4 · Two predictors: what “controlling for” means

# Step 4 · Two predictors: what "controlling for" means
# Adding distance changes what the host-GDP coefficient means: it is now the
# association holding distance fixed, not the raw association.
model2 = smf.ols("log_fdi ~ host_gdp + distance", data=d).fit()
print(model2.params)
print(f"R-squared = {model2.rsquared:.3f}")
Intercept   -0.322392
host_gdp     0.511916
distance    -0.455005
dtype: float64
R-squared = 0.078

Step 5 · Ridge (session 5)

# Step 5 · Ridge (session 5)
from sklearn.linear_model import RidgeCV

Xc, yc = d[XCOLS].astype(float), d[YCOL].astype(float)
ridge = make_pipeline(StandardScaler(),                        # same scale for all
                      RidgeCV(alphas=np.logspace(-3, 3, 60))   # try 60 values of λ
                      ).fit(Xc, yc)
print(f"λ = {ridge[-1].alpha_:.2f}")
print(f"ridge kept {np.sum(ridge[-1].coef_ != 0)} of {len(XCOLS)} — it never drops a predictor")
λ = 9.25
ridge kept 56 of 56 — it never drops a predictor

Step 6 · Lasso

# Step 6 · Lasso
from sklearn.linear_model import LassoCV

lasso = make_pipeline(StandardScaler(),
                      LassoCV(cv=5, random_state=0, max_iter=50_000)   # λ by 5-fold CV
                      ).fit(Xc, yc)
kept = [c for c, b in zip(XCOLS, lasso[-1].coef_) if b != 0]
print(f"λ = {lasso[-1].alpha_:.4f}")
print(f"lasso kept {len(kept)} of {len(XCOLS)}")
λ = 0.0312
lasso kept 34 of 56

Step 7 · Elastic net

# Step 7 · Elastic net
from sklearn.linear_model import ElasticNetCV

enet = make_pipeline(StandardScaler(),
                     ElasticNetCV(l1_ratio=0.5,            # half lasso, half ridge
                                  cv=5, random_state=0, max_iter=50_000)
                     ).fit(Xc, yc)
kept = [c for c, b in zip(XCOLS, enet[-1].coef_) if b != 0]
print(f"λ = {enet[-1].alpha_:.4f}")
print(f"elastic net kept {len(kept)} of {len(XCOLS)}")
λ = 0.0270
elastic net kept 46 of 56

Step 8 · Which one predicts best? Fit on 70% of the pairs, score on 30%

# Step 8 · Which one predicts best? Fit on 70% of the pairs, score on 30%
Xtr, Xte, ytr, yte = train_test_split(d[XCOLS], d[YCOL], test_size=0.3, random_state=7)

def test_rmse(model):                  # fit on 70% of rows, score on the other 30%
    pipe = make_pipeline(StandardScaler(), model).fit(Xtr, ytr)   # standardise first
    kept = int(np.sum(np.abs(pipe[-1].coef_) > 1e-8))             # non-zero coefficients
    return kept, mean_squared_error(yte, pipe.predict(Xte)) ** 0.5

results = {
    "OLS, all predictors": test_rmse(LinearRegression()),
    "ridge":       test_rmse(RidgeCV(alphas=np.logspace(-3, 3, 60))),   # λ by CV
    "lasso":       test_rmse(LassoCV(cv=5, random_state=0, max_iter=50_000)),
    "elastic net": test_rmse(ElasticNetCV(l1_ratio=0.5, cv=5, random_state=0, max_iter=50_000)),
}
res = pd.DataFrame(results, index=["predictors kept", "test RMSE"]).T.astype({"predictors kept": int})
print(res.round(3))
                     predictors kept  test RMSE
OLS, all predictors               55      1.631
ridge                             55      1.606
lasso                             43      1.596
elastic net                       47      1.595

What you should find

OLS, all 56 ridge lasso elastic net
predictors kept (all rows) 56 56 34 46
test RMSE (held-out 30%) 1.631 1.606 1.596 1.595

OLS: a host 1% larger holds about 0.5% more FDI, and a partner 1% farther away about 0.5% less, holding the other fixed. Host size alone explains 5% of the variation.

The penalties gain little here: 765 pairs for 56 candidates is not a square table. The elastic net keeps 46 where the lasso keeps 34, because it lets correlated candidates in together.

What it does not license

  • No causal claim. A kept variable predicts FDI here; changing it need not attract any.
  • No “no effect” for a zeroed candidate: among correlated ones, the lasso keeps one almost at random.
  • No p-values after selection, and no claim beyond 765 data-rich, mostly OECD pairs.

Paper 4 · Session 06 · Tariffs and firm productivity

The data, and the models

Topalova & Khandelwal (2011), Trade liberalization and firm productivity — the authors’ data: one row = one Indian manufacturing firm in one year, 1989–1996: 14,648 firm-years, 3,077 firms. Outcome: log productivity tfp1000.

OLS — session 03

  1. Load the firms
  2. Productivity on the output tariff
  3. How much did it explain?
    • the input tariff
  4. Residuals versus fitted

Logits — session 04

  • 6–8 · Binary: above the median? log-odds → odds → probabilities
  • 9–11 · Ordinal: productivity in thirds, low < middle < high
  • 12–14 · Multinomial: who owns the firm — stand-alone, group, state, foreign

Penalties — session 05

  • 15 · 16 candidates: tariffs, ownership, size, age, years
  • 16–18 · Ridge, lasso, elastic net
  • 19 · The four, on rows they never saw

The solutions, step by step

Each tab is one step, run when these slides were built: the code, then what it prints. Compare your own output with it line by line.

The lines that open every script: import what the steps use, and the address of the data on the course site.

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import statsmodels.formula.api as smf
import statsmodels.api as sm
from statsmodels.miscmodels.ordinal_model import OrderedModel
from sklearn.linear_model import LinearRegression
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error

# The data: one CSV per paper on the course site, read over the internet
DATA = "https://warint.github.io/quantitative-methods/data/session-07/"

Step 1 · Load the firms: one row is one firm in one year, 1989-1996

# Step 1 · Load the firms: one row is one firm in one year, 1989-1996
firms = pd.read_csv(DATA + "paper4_topalova_khandelwal_2011.csv")
cols = ["tfp1000", "lagtariff", "laginputariff", "prgroup", "govown",
        "foreign", "medium", "small", "age"]
d = firms[firms["yr"] < 1997].dropna(subset=cols)       # no gaps in what we use
print(f"{len(d):,} firm-years, {d['companyname'].nunique():,} firms")
print(d[["tfp1000", "lagtariff", "laginputariff"]].describe().round(3))
14,648 firm-years, 3,077 firms
         tfp1000  lagtariff  laginputariff
count  14648.000  14648.000      14648.000
mean       0.051      0.695          0.261
std        0.303      0.238          0.091
min       -4.159      0.125          0.025
25%       -0.102      0.500          0.197
50%        0.034      0.650          0.249
75%        0.193      0.850          0.326
max        3.596      2.043          0.546

Step 2 · Three lines of Python (session 3): productivity on the output tariff

# Step 2 · Three lines of Python (session 3): productivity on the output tariff
# The formula reads "tfp1000 explained by lagtariff". statsmodels adds the
# intercept for you. The slope is in log points of productivity per one unit
# of tariff (1.0 = 100 percentage points) — always say the units.
model = smf.ols("tfp1000 ~ lagtariff", data=d).fit()
print(model.params)
Intercept    0.108335
lagtariff   -0.083159
dtype: float64

Step 3 · How much did the line explain? (session 3)

# Step 3 · How much did the line explain? (session 3)
# R-squared is the share of variance the line accounts for — and nothing about
# whether the line is the right shape.
print(f"R-squared = {model.rsquared:.3f}")
R-squared = 0.004

Step 4 · Two predictors: what “controlling for” means (session 3)

# Step 4 · Two predictors: what "controlling for" means (session 3)
# Adding a second predictor changes what the first coefficient means: it is now
# the association holding the input tariff fixed, not the raw association.
model2 = smf.ols("tfp1000 ~ lagtariff + laginputariff", data=d).fit()
print(model2.params)
print(f"R-squared = {model2.rsquared:.3f}")
Intercept        0.115892
lagtariff       -0.065720
laginputariff   -0.075279
dtype: float64
R-squared = 0.005

Step 5 · Residuals vs fitted (session 3): structure the straight line missed?

# Step 5 · Residuals vs fitted (session 3): structure the straight line missed?
fit = model2
plt.scatter(fit.fittedvalues, fit.resid, s=12, alpha=0.6)
plt.axhline(0, color="black", lw=1)
plt.xlabel("Fitted values"); plt.ylabel("Residuals")
plt.savefig("p4_resid_vs_fitted.png", dpi=110, bbox_inches="tight")   # saved in your folder
plt.show()

Step 6 · Binary logit: log-odds (session 4, LR 6) — is the firm more productive than the median firm?

# Step 6 · Binary logit: log-odds (session 4, LR 6) — is the firm more productive than the median firm?
d["high_tfp"] = (d["tfp1000"] > d["tfp1000"].median()).astype(int)   # 1 = above the median
# Fit the logistic regression. The coefficients come out as log-odds.
m2 = smf.logit("high_tfp ~ lagtariff", data=d).fit(disp=0)
print(m2.params.round(4))
Intercept    0.5052
lagtariff   -0.7278
dtype: float64

Step 7 · Binary logit: odds (session 4, LR 7)

# Step 7 · Binary logit: odds (session 4, LR 7)
# Log-odds -> odds ratio: take the exponential of each coefficient
print(np.exp(m2.params["lagtariff"]).round(4))
0.483

Step 8 · Binary logit: predicted probability (session 4, LR 8)

# Step 8 · Binary logit: predicted probability (session 4, LR 8)
# predict() does the same calculation for you
print(m2.predict(pd.DataFrame({"lagtariff": [0.4, 0.7, 1.0]})).round(3))   # inside the data: 0.1 to 3.5
0    0.553
1    0.499
2    0.445
dtype: float64

Step 9 · Ordinal logit (session 4, OLR 3) — productivity in thirds: low < middle < high

# Step 9 · Ordinal logit (session 4, OLR 3) — productivity in thirds: low < middle < high
d["tfp_third"] = pd.qcut(d["tfp1000"], 3, labels=["low", "middle", "high"])
# Keep the order: low < middle < high
order = ["low", "middle", "high"]
d["tfp_c"] = pd.Categorical(d["tfp_third"], categories=order, ordered=True)
om = OrderedModel(d["tfp_c"], d[["lagtariff", "laginputariff", "age"]], distr="logit").fit(method="bfgs", disp=0)
print(om.summary())
                             OrderedModel Results                             
==============================================================================
Dep. Variable:                  tfp_c   Log-Likelihood:                -15982.
Model:                   OrderedModel   AIC:                         3.197e+04
Method:            Maximum Likelihood   BIC:                         3.201e+04
Date:                Thu, 05 Nov 2026                                         
Time:                        08:56:53                                         
No. Observations:               14648                                         
Df Residuals:                   14643                                         
Df Model:                           3                                         
=================================================================================
                    coef    std err          z      P>|z|      [0.025      0.975]
---------------------------------------------------------------------------------
lagtariff        -0.5092      0.081     -6.276      0.000      -0.668      -0.350
laginputariff    -0.5095      0.211     -2.413      0.016      -0.923      -0.096
age              -0.0077      0.001    -10.317      0.000      -0.009      -0.006
low/middle       -1.3692      0.056    -24.598      0.000      -1.478      -1.260
middle/high       0.3387      0.013     26.737      0.000       0.314       0.364
=================================================================================

Step 10 · Ordinal logit: log-odds and odds (session 4, OLR 4)

# Step 10 · Ordinal logit: log-odds and odds (session 4, OLR 4)
# Log-odds: the slopes as fitted (the cutpoints are left out)
print(om.params[["lagtariff", "laginputariff", "age"]].round(3))
# Proportional-odds ratios
print(np.exp(om.params[["lagtariff", "laginputariff", "age"]]).round(3))
lagtariff       -0.509
laginputariff   -0.509
age             -0.008
dtype: float64
lagtariff        0.601
laginputariff    0.601
age              0.992
dtype: float64

Step 11 · Ordinal logit: probabilities (session 4, OLR 5)

# Step 11 · Ordinal logit: probabilities (session 4, OLR 5)
# The same firm (20 years old, input tariff 0.3) under a high and a low output tariff.
# Columns: low, middle, high productivity.
new = pd.DataFrame({"lagtariff": [1.0, 0.4], "laginputariff": [0.3, 0.3], "age": [20, 20]})
print(pd.DataFrame(np.asarray(om.predict(new)), columns=order, index=["tariff 1.0", "tariff 0.4"]).round(3))
              low  middle   high
tariff 1.0  0.365   0.335  0.300
tariff 0.4  0.297   0.335  0.367

Step 12 · Multinomial logit (session 4, MLR 3) — who owns the firm? No natural order

# Step 12 · Multinomial logit (session 4, MLR 3) — who owns the firm? No natural order
d["owner"] = "stand-alone"
d.loc[d["prgroup"] == 1, "owner"] = "business group"
d.loc[d["govown"] == 1, "owner"] = "state-owned"
d.loc[d["foreign"] == 1, "owner"] = "foreign"
# Code 0 (stand-alone) is the baseline: every coefficient reads "relative to stand-alone"
levels = ["stand-alone", "business group", "state-owned", "foreign"]
d["owner_c"] = pd.Categorical(d["owner"], categories=levels).codes
# Leave large out, so large firms are the reference the other two sizes are compared with
X = d[["medium", "small"]].astype(float)
X["tfp1000"] = d["tfp1000"].astype(float)
X = sm.add_constant(X)
# Three sets of coefficients at once, each against the stand-alone baseline
mn = sm.MNLogit(d["owner_c"], X).fit(disp=0)
print(mn.summary().tables[1])          # the coefficient table

Step 12 · Multinomial logit (session 4, MLR 3) — who owns the firm? No natural order — what it prints

==============================================================================
 owner_c=1       coef    std err          z      P>|z|      [0.025      0.975]
------------------------------------------------------------------------------
const          2.6379      0.090     29.215      0.000       2.461       2.815
medium        -2.2672      0.094    -24.199      0.000      -2.451      -2.084
small         -3.6818      0.097    -38.053      0.000      -3.871      -3.492
tfp1000       -0.3608      0.066     -5.431      0.000      -0.491      -0.231
------------------------------------------------------------------------------
 owner_c=2       coef    std err          z      P>|z|      [0.025      0.975]
------------------------------------------------------------------------------
const          1.0365      0.102     10.141      0.000       0.836       1.237
medium        -2.9963      0.117    -25.712      0.000      -3.225      -2.768
small         -4.3432      0.139    -31.274      0.000      -4.615      -4.071
tfp1000       -1.5674      0.123    -12.791      0.000      -1.808      -1.327
------------------------------------------------------------------------------
 owner_c=3       coef    std err          z      P>|z|      [0.025      0.975]
------------------------------------------------------------------------------
const          0.9616      0.102      9.384      0.000       0.761       1.162
medium        -2.4377      0.111    -21.885      0.000      -2.656      -2.219
small         -3.7954      0.126    -30.059      0.000      -4.043      -3.548
tfp1000        0.2759      0.114      2.420      0.016       0.052       0.499
==============================================================================

Step 13 · Multinomial logit: log-odds and relative risk ratios (session 4, MLR 4)

# Step 13 · Multinomial logit: log-odds and relative risk ratios (session 4, MLR 4)
pd.set_option("display.width", 130)       # six columns: print them side by side
pd.set_option("display.max_columns", 10)
# Log-odds and RRR, for each owner type vs stand-alone
print(pd.concat({"log-odds": mn.params, "RRR": np.exp(mn.params)}, axis=1)
      .set_axis(["log-odds: group", "log-odds: state", "log-odds: foreign",
                 "RRR: group", "RRR: state", "RRR: foreign"], axis=1).round(3))
         log-odds: group  log-odds: state  log-odds: foreign  RRR: group  RRR: state  RRR: foreign
const              2.638            1.036              0.962      13.984       2.819         2.616
medium            -2.267           -2.996             -2.438       0.104       0.050         0.087
small             -3.682           -4.343             -3.795       0.025       0.013         0.022
tfp1000           -0.361           -1.567              0.276       0.697       0.209         1.318

Step 14 · Multinomial logit: probabilities (session 4, MLR 5)

# Step 14 · Multinomial logit: probabilities (session 4, MLR 5)
# Large firms: the median productivity, then across the range
grid = pd.DataFrame({"const": 1.0, "medium": 0.0, "small": 0.0,
                     "tfp1000": [d["tfp1000"].median(), -0.5, 0.0, 0.5]})
out = pd.DataFrame(mn.predict(grid).values, columns=levels, index=grid["tfp1000"].round(2))
print(out.round(3))
         stand-alone  business group  state-owned  foreign
tfp1000                                                   
 0.03          0.050           0.686        0.133    0.131
-0.50          0.038           0.639        0.236    0.087
 0.00          0.049           0.685        0.138    0.128
 0.50          0.059           0.688        0.076    0.177

Step 15 · The candidates for ridge, lasso and elastic net (session 5)

# Step 15 · The candidates for ridge, lasso and elastic net (session 5)
d = d.copy()
d["age2"] = d["age"] ** 2
years = pd.get_dummies(d["yr"], prefix="yr", drop_first=True).astype(float)   # 1989 is the base
d = pd.concat([d, years], axis=1)
YCOL = "tfp1000"
XCOLS = cols[1:] + ["age2"] + list(years.columns)
Xc, yc = d[XCOLS].astype(float), d[YCOL].astype(float)
print(f"{len(XCOLS)} candidates:", XCOLS)
16 candidates: ['lagtariff', 'laginputariff', 'prgroup', 'govown', 'foreign', 'medium', 'small', 'age', 'age2', 'yr_1990', 'yr_1991', 'yr_1992', 'yr_1993', 'yr_1994', 'yr_1995', 'yr_1996']

Step 16 · Ridge (session 5)

# Step 16 · Ridge (session 5)
from sklearn.linear_model import RidgeCV

ridge = make_pipeline(StandardScaler(),                        # same scale for all
                      RidgeCV(alphas=np.logspace(-3, 3, 60))   # try 60 values of λ
                      ).fit(Xc, yc)
print(f"λ = {ridge[-1].alpha_:.2f}")
print(f"ridge kept {np.sum(ridge[-1].coef_ != 0)} of {len(XCOLS)} — it never drops a predictor")
λ = 47.64
ridge kept 16 of 16 — it never drops a predictor

Step 17 · Lasso (session 5)

# Step 17 · Lasso (session 5)
from sklearn.linear_model import LassoCV

lasso = make_pipeline(StandardScaler(),
                      LassoCV(cv=5, random_state=0, max_iter=50_000)   # λ by 5-fold CV
                      ).fit(Xc, yc)
kept = [c for c, b in zip(XCOLS, lasso[-1].coef_) if b != 0]
print(f"λ = {lasso[-1].alpha_:.4f}")
print(f"lasso kept {len(kept)} of {len(XCOLS)}:", kept)
λ = 0.0003
lasso kept 16 of 16: ['lagtariff', 'laginputariff', 'prgroup', 'govown', 'foreign', 'medium', 'small', 'age', 'age2', 'yr_1990', 'yr_1991', 'yr_1992', 'yr_1993', 'yr_1994', 'yr_1995', 'yr_1996']

Step 18 · Elastic net (session 5)

# Step 18 · Elastic net (session 5)
from sklearn.linear_model import ElasticNetCV

enet = make_pipeline(StandardScaler(),
                     ElasticNetCV(l1_ratio=0.5,            # half lasso, half ridge
                                  cv=5, random_state=0, max_iter=50_000)
                     ).fit(Xc, yc)
kept = [c for c, b in zip(XCOLS, enet[-1].coef_) if b != 0]
print(f"λ = {enet[-1].alpha_:.4f}")
print(f"elastic net kept {len(kept)} of {len(XCOLS)}:", kept)
λ = 0.0006
elastic net kept 16 of 16: ['lagtariff', 'laginputariff', 'prgroup', 'govown', 'foreign', 'medium', 'small', 'age', 'age2', 'yr_1990', 'yr_1991', 'yr_1992', 'yr_1993', 'yr_1994', 'yr_1995', 'yr_1996']

Step 19 · The four, on rows they never saw (session 5, as reviewed in session 6)

# Step 19 · The four, on rows they never saw (session 5, as reviewed in session 6)
Xtr, Xte, ytr, yte = train_test_split(Xc, yc, test_size=0.3, random_state=7)

def test_rmse(model):                  # fit on 70% of rows, score on the other 30%
    pipe = make_pipeline(StandardScaler(), model).fit(Xtr, ytr)   # standardise first
    kept = int(np.sum(np.abs(pipe[-1].coef_) > 1e-8))             # non-zero coefficients
    return kept, mean_squared_error(yte, pipe.predict(Xte)) ** 0.5

results = {
    "OLS, all predictors": test_rmse(LinearRegression()),
    "ridge":       test_rmse(RidgeCV(alphas=np.logspace(-3, 3, 60))),   # λ by CV
    "lasso":       test_rmse(LassoCV(cv=5, random_state=0, max_iter=50_000)),
    "elastic net": test_rmse(ElasticNetCV(l1_ratio=0.5, cv=5, random_state=0, max_iter=50_000)),
}
res = pd.DataFrame(results, index=["predictors kept", "test RMSE"]).T.astype({"predictors kept": int})
print(res.round(3))
                     predictors kept  test RMSE
OLS, all predictors               16      0.296
ridge                             16      0.296
lasso                             16      0.296
elastic net                       16      0.296

What you should find

Model In words
OLS a tariff 10 points lower goes with productivity about 0.8% higher; R² 0.004
Binary logit odds ratio 0.48 per unit of tariff; P(above the median) 0.45 → 0.55 as the tariff falls from 1.0 to 0.4
Ordinal logit the same cut moves P(top third) from 0.30 to 0.37
Multinomial logit among large firms, the more productive are more often foreign-owned (RRR 1.32) and less often state-owned (0.21), relative to stand-alone
Ridge, lasso, elastic net λ near zero, all 16 kept, test RMSE 0.296 for all four

The penalty buys nothing here: with 14,648 rows for 16 candidates, OLS does not overfit.

What it does not license

  • No causal claim. The slopes compare different firms in different years; ownership is described, not explained.
  • No “every variable matters”. Keeping all 16 means none hurts prediction, not that each matters.
  • No claim beyond these firms: listed manufacturers that survived, 1989–1996.

Closing the afternoon

What loses marks on the midterm

  • A coefficient with no sentence, no units, or no “holding the others fixed”
  • An odds ratio read as a probability, or a lasso zero read as “no effect”
  • Presenting a number from a simple model as the paper’s finding
  • Reaching for a method the course has not taught

A simple model, read precisely, in a sentence with units, earns full marks.

Before the midterm

Midterm: Wednesday 28 October, on paper, Sessions 1 to 6. No class on 21 October.

This afternoon was the revision: every model came back once, on real data. Then read the midterm summary and sit the sample paper under exam conditions, then check it against the answer key.

PCA and factor analysis, which this session used to teach, are now session 13, “One more thing” — after the course, for self-study, and not on the midterm.