The course’s models on the papers’ data — three hours, in autonomy
Thierry Warin, PhD
HEC Montréal
Outline · The hour ahead
How the afternoon works
Paper 1 · news sentiment and stock returns — OLS and the three logits
Paper 2 · social groups — binary, ordinal and multinomial logits
Paper 3 · foreign direct investment — OLS, ridge, lasso, elastic net
Paper 4 · tariffs and productivity — OLS, the three logits, ridge, lasso, elastic net
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.
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:
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
Load the data
Return on sentiment
How much did it explain?
yesterday’s VIX
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 npimport pandas as pdimport matplotlib.pyplot as pltimport statsmodels.formula.api as smfimport statsmodels.api as smfrom statsmodels.miscmodels.ordinal_model import OrderedModel# The data: one CSV per paper on the course site, read over the internetDATA ="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-dayd = 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 dayprint(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)
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)
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))
# Step 7 · Binary logit: odds (session 04, LR 7)# Log-odds -> odds ratio: take the exponential of each coefficientprint(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 dayprint(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 < upd["day"] = pd.qcut(d["F0_index"], 3, labels=["down", "flat", "up"])# Keep the order: down < flat < uporder = ["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())
# 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 ratiosprint(np.exp(om.params[["L1_sent1_best", "L1_vix"]]).round(3))
# 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).codesX = 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 baselinemn = sm.MNLogit(d["day_m"], X).fit(disp=0)print(mn.summary().tables[1]) # the coefficient table
# 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 flatprint(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
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 warningswarnings.filterwarnings("ignore") # convergence notes are not part of the answerimport numpy as npimport pandas as pdimport statsmodels.api as smimport statsmodels.formula.api as smffrom statsmodels.miscmodels.ordinal_model import OrderedModelpd.set_option("display.width", 130) # as in the session-04 slides, a little widerpd.set_option("display.max_columns", 10)# The data: one CSV per paper on the course site, read over the internetDATA ="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 weekFILE = DATA +"paper2_saganowski_2019.csv"# the authors' file, unchangedraw = pd.read_csv(FILE)print(raw.shape)print(raw["event_type"].value_counts(normalize=True).round(3)) # the base rates
# Step 2 · Three features a person can readgroups = 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 aroundprint(groups[["log2_size", "avg_degree", "aging"]].median().round(2))
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 coefficientprint(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 membersprint(m2.predict(pd.DataFrame({"log2_size": [2, 3, 4]})).round(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).codesX = ml[["log2_size", "avg_degree", "aging"]].astype(float)X = sm.add_constant(X)# Three sets of coefficients at once, each against the dissolving baselinemn = 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
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
Load: logs, complete rows
Three lines of Python: FDI on host GDP
How much did the line explain?
Two predictors: + distance
Penalties — the code of session 05
Ridge, λ by cross-validation
Lasso: how many kept?
Elastic net, mix 0.5
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 warningswarnings.filterwarnings("ignore") # convergence notes are not part of the answerimport numpy as npimport pandas as pdimport statsmodels.formula.api as smffrom sklearn.pipeline import make_pipelinefrom sklearn.preprocessing import StandardScalerfrom sklearn.linear_model import LinearRegressionfrom sklearn.model_selection import train_test_splitfrom sklearn.metrics import mean_squared_error# The data: one CSV per paper on the course site, read over the internetDATA ="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 candidatesfdi = pd.read_csv(DATA +"paper3_blonigen_piger_2014.csv")YCOL ="log_fdi"XCOLS = [c for c in fdi.columns if c notin ("parent", "host", "year", "fdi_stock_musd")]d = fdi[fdi.fdi_stock_musd >0].dropna(subset=XCOLS).copy() # positive FDI, complete rowsd["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)
# 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}")
# Step 5 · Ridge (session 5)from sklearn.linear_model import RidgeCVXc, 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 · Lassofrom sklearn.linear_model import LassoCVlasso = 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 inzip(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 netfrom sklearn.linear_model import ElasticNetCVenet = 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 inzip(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 coefficientsreturn kept, mean_squared_error(yte, pipe.predict(Xte)) **0.5results = {"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.
The lines that open every script: import what the steps use, and the address of the data on the course site.
import numpy as npimport pandas as pdimport matplotlib.pyplot as pltimport statsmodels.formula.api as smfimport statsmodels.api as smfrom statsmodels.miscmodels.ordinal_model import OrderedModelfrom sklearn.linear_model import LinearRegressionfrom sklearn.preprocessing import StandardScalerfrom sklearn.pipeline import make_pipelinefrom sklearn.model_selection import train_test_splitfrom sklearn.metrics import mean_squared_error# The data: one CSV per paper on the course site, read over the internetDATA ="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-1996firms = 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 useprint(f"{len(d):,} firm-years, {d['companyname'].nunique():,} firms")print(d[["tfp1000", "lagtariff", "laginputariff"]].describe().round(3))
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)
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}")
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 = model2plt.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 folderplt.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 coefficientprint(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 youprint(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 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 ratiosprint(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 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 orderd["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 withX = 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 baselinemn = 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
# 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 sidepd.set_option("display.max_columns", 10)# Log-odds and RRR, for each owner type vs stand-aloneprint(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 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 coefficientsreturn kept, mean_squared_error(yte, pipe.predict(Xte)) **0.5results = {"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.