Session 6: Sessions 2–5 in Review, and Three Advanced Techniques
Published course presentation · repository edition
Thierry Warin, PhD
HEC Montréal
Outline · The hour ahead
Where we are — four sessions, one toolkit
Session 2 · describe one variable: goal, code, results, interpretation
Session 3 · fit a line, then check it
Session 4 · binary, ordinal and multinomial logistic regression
Session 5 · too many predictors: let the computer choose
Advanced · panel data, fixed or random effects
Advanced · interactions — a slope per group
Advanced · squared terms — effects that accelerate or level off
Key takeaways
What session 6 is for
The mathematics
Summarise sessions 2 to 5 as goal, implementation, results and interpretation
State what a panel is, and distinguish fixed from random effects
Use the Hausman test to choose between fixed and random effects
Read an interaction as a slope that differs between groups
Read a squared term as an effect that accelerates or levels off
The Python
Fit fixed- and random-effects models with PanelOLS and RandomEffects
Write an interaction (a * b) and a quadratic (I(x**2)) in a formula
Interpret both in units, from the fitted coefficients
The international business
Separate within-country change from between-country difference
Say which specification your identifying variation can actually support
Report the estimate and the specification, because neither means much alone
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 6, 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.
Where we are
Four sessions, one toolkit. Each one answered a different kind of question:
Session
The question
The method
The Python
2
What does one variable look like?
centre, spread, shape
.mean(), .median(), .std(), stats.skew
3
How does \(y\) move with \(x\) — and can we trust the line?
OLS, \(R^2\), residuals, Cook’s distance
smf.ols(...).fit()
4
What if \(y\) is a category?
logistic, ordinal, multinomial
smf.logit, np.exp, .predict
5
What if there are too many predictors?
stepwise, ridge, lasso, elastic net
LassoCV, ElasticNetCV
The first part of today revisits the four, two slides each: the goal and the code, then the results and their interpretation, with the sentence you would write in a paper. The second part adds three techniques that make the regression of session 3 fit real data: panels, interactions, squared terms.
Part 1 · Sessions 2–5 in review
Session 2 · Describe one variable: the goal and the code
Before any model, know your variable. How large is a typical value? How far apart are the values? Is the distribution symmetric, or does it have a long tail?
Example: GDP per capita across 30 European countries, 2009–2023 — one row per country-year.
A regression in session 3 will use this variable. If it is strongly skewed, a few very rich countries can drive the result.
The description decides later choices: report the mean or the median? Model GDP or its logarithm? Look for outliers?
It also catches errors early: a negative GDP or a value ten times too large shows up here, not in a regression table.
Measure
In plain words
Sensitive to extremes?
Centre
mean \(\bar x = \frac{1}{n}\sum x_i\)
the balance point
yes
median
the middle value: half below, half above
no
trimmed mean
the mean after cutting 5% off each tail
little
Spread
standard deviation \(s\)
typical distance from the mean
yes
IQR \(= Q_3 - Q_1\)
width of the middle 50%
no
Shape
skewness \(g_1\)
0 = symmetric; \(>0\) = long right tail
—
excess kurtosis \(g_2\)
0 = normal tails; \(>0\) = more extreme values
—
Rule of thumb: the distribution is substantially skewed if \(|g_1| > 2\sqrt{6/n}\).
from scipy import statscore = qmib.load("core") # 30 countries x 15 yearsgdp = core["gdp_pc_eur"].dropna() # drop the missing yearssummary = pd.Series({"mean": gdp.mean(),"median": gdp.median(),"5% trimmed mean": stats.trim_mean(gdp, 0.05), # cut 5% from each tail"standard deviation": gdp.std(ddof=1), # ddof=1: divide by n-1"IQR": gdp.quantile(0.75) - gdp.quantile(0.25),"skewness g1": stats.skew(gdp),"excess kurtosis g2": stats.kurtosis(gdp),})summary.round(2)
mean 30372.66
median 25733.29
5% trimmed mean 29419.43
standard deviation 15419.54
IQR 22215.47
skewness g1 0.88
excess kurtosis g2 0.00
dtype: float64
Session 2 · Describe one variable: results and interpretation
Most country-years sit between €10,000 and €45,000; a long tail runs to the right, out to €80,039.
Centre: the mean (€30,373) is above the median (€25,733). A few very rich countries pull the mean up; the median does not move.
Shape: skewness \(g_1 =\) 0.88 is far above the threshold \(2\sqrt{6/n} =\) 0.23: the distribution is substantially right-skewed.
Spread: for a skewed variable, the IQR (€22,215) describes the typical range better than the standard deviation, which the tail inflates.
Consequence: describe a “typical” country with the median and IQR, and consider modelling \(\log(\text{GDP})\) later.
“GDP per capita has a median of €25,733 (IQR €18,289–€40,505, n = 437 country-years). The distribution is right-skewed (\(g_1 =\) 0.88), so we report medians.”
Every number in session 2 describes one variable at a time. Session 3 puts two in the same sentence.
Session 3 · Fit a line, then check it: the goal and the code
Squaring makes every miss count, above or below the line, and punishes big misses most. Then four checks, because fitting is the easy part:
Question
Tool
A problem looks like
Adequacy — how much does it explain?
\(R^2\), adjusted \(R^2\)
a small share explained
Validity — do the assumptions hold?
residual plot
a curve or a funnel
Robustness — does one row drive it?
Cook’s distance
one spike far above the rest
Parsimony — which model?
AIC, BIC
— lower is better
d = core.dropna(subset=["gdp_pc_eur", "productivity_idx"]) # rows with both values# "gdp_pc_eur explained by productivity_idx"; the intercept is added for youfit = smf.ols("gdp_pc_eur ~ productivity_idx", data=d).fit()print(fit.params.round(1)) # intercept and slopeprint(f"R-squared = {fit.rsquared:.3f} AIC = {fit.aic:,.0f}")cooks_d = fit.get_influence().cooks_distance[0] # one value per row
Left: residuals should scatter at random around zero. Right: the dashed line (4/n) is a prompt to look at a row, not a rule for dropping it.
The slope: one more point of productivity goes with €1,111 more GDP per capita, on average. Say the units: euros per index point.
The intercept (€-86,118) is the prediction at productivity 0, far outside the data (72 to 129). Do not interpret it.
Adequacy:\(R^2 =\) 0.63 — productivity accounts for 63% of the variation in GDP per capita. It says nothing about whether a straight line is the right shape.
Validity: any curve or funnel in the residual plot is structure the line missed — a reason to try a squared term or a log (Part 2).
Robustness: if one spike towers over the others, one country-year is carrying the result. Find out which, and report the fit with and without it.
“Across 428 country-years, a one-point higher productivity index is associated with €1,111 higher GDP per capita (SE 42; \(R^2 =\) 0.63).”
“Associated with”, not “causes”: the slope says the two move together. Higher income can also buy the capital that raises productivity.
Session 4 · Two categories: binary logistic regression, the goal and the code
Which loans default?default is 1 if the borrower did not repay, 0 otherwise.
A straight line through 0s and 1s (the linear probability model) can predict probabilities below 0 or above 1: for a very good credit score it predicts a negative chance of default.
We need a model whose predictions always stay between 0 and 1. The logistic function does that: it bends the line into an S-shape.
Outcome
Model
Python
two categories (default or not)
logistic
smf.logit
ordered categories (unlikely < likely < very likely)
ordinal logit
OrderedModel
unordered categories (academic, general, vocational)
\(\frac{p}{1-p}\) is the odds: a probability of 0.2 is odds of 0.25, “one default for four repayments”.
It is not fitted by least squares but by maximum likelihood: Python picks the \(\beta\)s that make the defaults and repayments we actually observed as probable as possible.
The price: the coefficients come out as log-odds, which nobody reads directly. So we convert them, in two steps.
loans = qmib.load("loans")m = smf.logit("default ~ fico", data=loans).fit(disp=0) # maximum likelihoodprint(m.params.round(4)) # step 0: log-oddsprint(np.exp(m.params["fico"]).round(4)) # step 1: odds ratio# step 2: probabilities, inside the range of the data (FICO 612 to 827)m.predict(pd.DataFrame({"fico": [650, 700, 800]})).round(3)
The predicted probability of default falls as the credit score rises — and never leaves the 0–1 range.
Python prints \(b_1 =\) -0.0119. Read it in two passes.
First the sign. It is negative: a higher FICO score goes with a lower chance of default. The sign is the one thing you can read straight from a log-odds coefficient — positive raises the chance, negative lowers it.
Then the size. “One more FICO point lowers the log-odds of default by 0.0119.” That sentence is correct, but nobody thinks in log-odds: a reader cannot tell whether 0.0119 is a lot or a little.
Why does the model use log-odds at all? Because on that scale the relationship is a straight line: every extra point changes the log-odds by the same amount, wherever you start.
So the log-odds are the model’s working scale. To talk to a reader, we convert them — first to odds, then to probabilities.
What are odds? The chance that something happens divided by the chance that it does not: \(\text{odds} = p / (1 - p)\).
probability of default \(p\)
odds \(p/(1-p)\)
in words
0.50
1.00
one default for every one repayment
0.20
0.25
one default for every four repayments
0.169 (FICO 700)
0.203
about one default for every 5 repayments
The odds ratio\(e^{b_1} =\) 0.988 is a multiplier: each extra FICO point multiplies the odds by 0.988, i.e. lowers them by 1.2%, holding everything else constant (ceteris paribus).
Check it: odds at 700 = 0.2028; at 701 = 0.2028 × 0.988 = 0.2004.
For a bigger step, multiply the coefficient first: 100 points → \(e^{100 \times b_1} =\) 0.305, odds about 70% lower. From 700 to 800 the odds fall from 0.203 to 0.062.
The rule: if \(e^b > 1\) the odds rise by \((e^b - 1) \times 100\%\); if \(e^b < 1\) they fall by \((1 - e^b) \times 100\%\).
Odds are still not what most readers want. For a probability, put the whole linear predictor through the logistic function: \(p = e^{b_0 + b_1 x} / (1 + e^{b_0 + b_1 x})\). That is what predict() does.
FICO score
650
700
750
800
probability of default
0.269
0.169
0.101
0.058
change over the previous 50 points
—
-0.100
-0.068
-0.042
Every 50-point step multiplies the odds by the same factor, yet the probability falls by less and less. The S-curve is steepest where defaults are common and flattens where they are already rare.
So a logistic model has no single “effect on the probability”: it depends on where the borrower starts. Report probabilities at a few meaningful values — and only inside the scores you observed (612 to 827).
“Each additional FICO point is associated with 1.2% lower odds of default (odds ratio 0.988). The predicted probability of default falls from 0.27 at a score of 650 to 0.06 at 800.”
Never report a log-odds as if it were a probability, and never predict outside the scores you observed.
Two cutpoints\(c_1 < c_2\) act as intercepts, one per cut. There is one\(\beta\) per predictor, the same at both cuts: the proportional-odds assumption.
Because of the minus sign, a positive\(\beta\) pushes students up the scale.
# Two otherwise identical students (private school, GPA 3.0): parents with or without graduate schoolnew = pd.DataFrame({"pared": [1, 0], "public": [0, 0], "gpa": [3.0, 3.0]})probs_o = pd.DataFrame(np.asarray(om.predict(new)), columns=order, index=["pared = 1", "pared = 0"])probs_o.round(3)
unlikely
somewhat likely
very likely
pared = 1
0.334
0.469
0.197
pared = 0
0.588
0.333
0.079
Each row sums to 1. The model computes \(P(Y \le j) = F(c_j - x\beta)\) and takes differences to get each category.
What does “a higher category” mean? There are two cuts: somewhat or very likely versus unlikely, and very likely versus the rest. An odds ratio says how the odds of being above a cut change — and, by proportional odds, it is the same at both cuts.
pared (a parent went to graduate school): \(b =\) 1.048, positive, so it pushes students up the scale. \(e^{b} =\) 2.85: their odds of being above any cut are 2.85 times those of other students — 185% higher.
Check it with the predicted probabilities from the Results tab, at the first cut:
\(P(\text{somewhat or very likely})\)
odds
pared = 0
0.412
0.700
pared = 1
0.666
1.997
and 1.997 / 0.700 = 2.85 — the odds ratio.
gpa:\(e^{b} =\) 1.85 per full GPA point. A full point is a big move (GPA’s standard deviation is about 0.4); for a 0.4-point rise the odds ratio is \(e^{0.4\,b} =\) 1.28.
public:\(p =\) 0.84. The data cannot tell its effect from zero: report “no detectable effect”, not “6% lower odds”.
The Results table, row by row:
Each row is a full distribution over the three answers, so it sums to one. Raising one category means lowering another.
Moving pared from 0 to 1 takes \(P(\text{unlikely})\) from 0.59 down to 0.33, and \(P(\text{very likely})\) from 0.08 up to 0.20. One coefficient shifts the whole distribution up the scale.
The middle category can go either way: it gains students from below and loses some to above. Here it moves from 0.33 to 0.47.
This is why you show probabilities: an odds ratio of 2.85 does not tell a reader that the share of “very likely” students more than doubles.
The sign convention. statsmodels writes \(c_j - x\beta\), so a positive \(\beta\) means higher categories. Some software writes \(c_j + x\beta\); check before you read a sign.
Proportional odds is an assumption, not a result. Fit a separate binary logit at each cut: if the slopes differ a lot, one number is the wrong summary (session 4, Appendix C).
The cutpoints are not effects: they only place the thresholds between categories. Do not interpret them as you would a coefficient.
Which high-school programme does a student follow — academic, general or vocational? Three categories with no order: vocational is not “more” than general.
Pick a baseline category (academic, the most common). The model writes one log-odds equation per other category, each against the baseline:
Unlike the ordinal model, every predictor gets its own coefficient in each equation. The equations are estimated together by maximum likelihood, so the three probabilities sum to one.
import statsmodels.api as smml = qmib.load("hsbdemo")levels = ["academic", "general", "vocation"] # code 0 = academic = the baseliney_m = pd.Categorical(ml["prog"], categories=levels).codesX_m = pd.get_dummies(ml["ses"])[["middle", "high"]].astype(float) # low SES is the referenceX_m["write"] = ml["write"].astype(float)mn = sm.MNLogit(y_m, sm.add_constant(X_m)).fit(disp=0)b_m = mn.params.set_axis(["general vs academic", "vocation vs academic"], axis=1)pd.concat({"log-odds": b_m, "RRR": np.exp(b_m)}, axis=1).round(3)
log-odds
RRR
general vs academic
vocation vs academic
general vs academic
vocation vs academic
const
2.852
5.218
17.326
184.602
middle
-0.533
0.291
0.587
1.338
high
-1.163
-0.983
0.313
0.374
write
-0.058
-0.114
0.944
0.893
# Middle-SES students across the range of writing scoresgrid_m = pd.DataFrame({"const": 1.0, "middle": 1.0, "high": 0.0, "write": [35, 45, 55, 65]})probs_m = pd.DataFrame(mn.predict(grid_m).values, columns=levels, index=grid_m["write"])probs_m.round(3)
academic
general
vocation
write
35
0.143
0.192
0.665
45
0.309
0.232
0.460
55
0.527
0.221
0.252
65
0.720
0.169
0.110
The model computes \(P(Y = j) = e^{\eta_j} / \sum_k e^{\eta_k}\) (the softmax), with \(\eta = 0\) for the baseline.
Everything is relative to the baseline (academic). A relative risk ratio (RRR) multiplies the ratio \(P(\text{category}) / P(\text{academic})\) — how many students are in that programme for every academic one.
write, general vs academic: RRR = 0.944. Each extra writing point multiplies the general-to-academic ratio by 0.944: 5.6% lower. Over 10 points, multiply the coefficient by 10: \(e^{10b} =\) 0.56 — the ratio is almost halved.
write, vocation vs academic: RRR = 0.893 per point, \(e^{10b} =\) 0.32 over 10 points. Writing pulls students away from vocation faster than away from general.
high SES, general vs academic: RRR = 0.313 (\(p =\) 0.024). Compared with a low-SES student, a high-SES student’s general-to-academic ratio is about a third.
middle SES:\(p =\) 0.23 and 0.54 — no detectable difference from low SES in either equation.
From the Results table, middle-SES students:
writing score
35
45
55
65
academic
0.14
0.31
0.53
0.72
general
0.19
0.23
0.22
0.17
vocation
0.66
0.46
0.25
0.11
Academic rises steadily and vocation falls steadily: easy to read.
General first rises, then falls — even though its coefficient on write is negative. Why? Vocation loses students faster than general does, and some of the weakest writers who leave vocation land in general.
The lesson: a coefficient tells you how a category moves against the baseline, not how its own probability moves. Only the probabilities show that.
Forgetting the baseline. Every coefficient is “relative to academic”. Choose another baseline and every number changes — so always name it.
Reading a negative coefficient as “less likely”. As the General row shows, a category’s probability can rise while its coefficient is negative.
Comparing two non-baseline categories directly. General vs vocation is not in the output; it is the difference of their coefficients, \(b_{1k} - b_{2k}\).
Small categories give unstable estimates: check how many students each programme has before trusting a coefficient.
Session 5 · Too many predictors: the goal and the code
Which of 29 candidate predictors explain R&D intensity, with only 37 countries?
With almost as many predictors as rows, OLS fits the noise: large coefficients, wrong signs, useless variables kept. It looks perfect on the data it was fitted to and fails on new data.
Choosing the predictors by hand is slow and subjective. So we automate model selection.
Method
What it does
In one phrase
Stepwise (old style)
adds one predictor at a time while AIC improves
a greedy loop
Ridge
keeps all predictors, shrinks every coefficient
a tax on big coefficients
Lasso
shrinks, and sets the weak ones to exactly zero
an entry fee: the weak leave
Elastic net
a mix: zeros like the lasso, keeps correlated predictors together
our default
The three penalised methods all minimise
\[\sum_i (y_i - \hat y_i)^2 + \lambda \times \text{size of the coefficients}\]
\(\lambda\) (alpha in Python) sets the price: 0 gives OLS, a huge value pushes everything to 0. Cross-validation chooses it, and the predictors must be standardised first.
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)),}
Session 5 · Too many predictors: results and interpretation
OLS with every predictor has the largest error on rows it never saw (0.483): it learned the noise of the training rows.
The penalised models cut that error by more than half. Nothing was added — the coefficients were only restrained.
Ridge keeps all 29 predictors; the lasso keeps 6 and the elastic net 8. When predictors come in correlated groups, the elastic net keeps the group, which is why it is our default.
“We selected predictors with an elastic net (mix 0.5, λ chosen by 5-fold cross-validation on standardised predictors). It keeps 8 of 29 candidates and reduces the test error from 0.483 (OLS) to 0.198.”
Do not write “these are the variables that matter”: the selection is a set that predicts well on this sample. And no \(p\)-values after selection.
A panel follows the same entities (countries, firms) over several periods. Here: government debt and economic-freedom indices for countries over the years.
Countries differ in many ways we do not measure — institutions, history, culture. If those also affect debt, a plain regression mixes them into the slopes.
Fixed effects (FE): give each country its own intercept. Only changes within a country over time identify the slopes, so anything constant about a country is controlled for.
Random effects (RE): treat the country differences as random noise. More efficient, but only valid if those differences are unrelated to the predictors.
If FE and RE give similar coefficients, RE is fine (and more precise). If they differ by more than sampling noise, RE is inconsistent: use FE.
from scipy import statsdiff = fixed.params - random_.paramsvdiff = fixed.cov - random_.covH =float(diff.values @ np.linalg.pinv(vdiff.values) @ diff.values)p_value = stats.chi2.sf(H, len(diff))print(f"Hausman H = {H:.2f} on {len(diff)} df, p = {p_value:.4f}")
Hausman H = 27.59 on 3 df, p = 0.0000
Small \(p\) (below 0.05): the two disagree → use fixed effects.
Large \(p\): no evidence they disagree → random effects is acceptable, and more precise.
A fixed-effects slope answers a within question: when a country’s business freedom rises, what happens to its debt? It cannot say why some countries have more debt than others.
Rejecting says random effects is wrong here — not that fixed effects is right. And report which one you used: the same data can give different answers under each.
Does bonus pay fall with age at the same rate for men and women? A plain regression bonus ~ age + female assumes yes. An interaction lets the slope differ:
For women (female = 1): slope in age \(= \beta_1 + \beta_3\)
\(\beta_3\) is the difference between the two slopes. If \(\beta_3 = 0\), the effect of age is the same for both.
glassdoor = qmib.load("glassdoor")glassdoor["female"] = (glassdoor["gender"].str.lower() =="female").astype(int)# age * female expands to age + female + age:femalefit6 = smf.ols("bonus ~ age * female", data=glassdoor).fit()b = fit6.paramsprint(f"slope in age, men : {b['age']:8.2f} per year")print(f"slope in age, women : {b['age'] + b['age:female']:8.2f} per year")print(f"difference : {b['age:female']:8.2f} p = {fit6.pvalues['age:female']:.4f}")
slope in age, men : -51.45 per year
slope in age, women : -66.83 per year
difference : -15.38 p = 0.0592
Interactions: reading the result
Bonus falls with age for both groups: by 51.4 dollars a year for men, 66.8 for women.
The difference between the slopes (-15.4) has \(p =\) 0.059: not significant at 5%, so we cannot say the age effect differs by gender.
The female coefficient alone is the gap at age 0 — a value no employee has. With an interaction, read the two slopes, not each coefficient separately.
Squared terms: when the effect is not a straight line
\(\beta_2 > 0\): the effect accelerates (a U-shape, upward curve). \(\beta_2 < 0\): it levels off (an inverted U).
The effect of one more square foot is no longer one number: it is \(\beta_1 + 2\beta_2 \times \text{area}\), so it depends on the size of the house.
It is still a linear regression: linear in the coefficients. Squares, logs, interactions are all allowed.
re_data = qmib.load("realestate")re1 = smf.ols("price ~ living_area", data=re_data).fit()re2 = smf.ols("price ~ living_area + I(living_area**2)", data=re_data).fit() # I() = "compute this"print(re2.params.round(4))print(f"p-value on the squared term = {re2.pvalues['I(living_area ** 2)']:.5f}")print(f"adjusted R2: straight line {re1.rsquared_adj:.3f} -> with square {re2.rsquared_adj:.3f}")
Intercept 471374.9775
living_area -110.7200
I(living_area ** 2) 0.0408
dtype: float64
p-value on the squared term = 0.00001
adjusted R2: straight line 0.134 -> with square 0.152
Squared terms: reading the result
\(\beta_2 =\) 0.0408 is positive and significant: the price accelerates with size.
One more square foot adds about $12 to a 1,500 sq. ft house, but $134 to a 3,000 sq. ft one.
Check the curve stays sensible over the range of your data — never extrapolate a quadratic beyond it.
Key takeaways
Session 2 — describe before you model: centre, spread, shape; median and IQR when the data are skewed
Session 3 — a slope is a sentence with units; then check adequacy, validity and robustness
Session 4 — for a category: log-odds → \(e^{b}\) for odds → \(e^{xb}/(1+e^{xb})\) for probabilities
Session 5 — with many predictors, let a penalty choose; the elastic net is the default
Panels — fixed effects use only within-entity change; the Hausman test chooses FE or RE
Interactions — a * b gives a slope per group; read the slopes, not the coefficients alone
Squared terms — I(x**2) lets an effect accelerate or level off
Before the next session
Required: the next session’s article, its Dataverse package, and the self-check. That is the pre-session deck.
Optional: the QMIB Lab for this session — a knowledge check on the hour you have just sat through: warin.ca/qmib-labs
Never a prerequisite, never marked for correctness. A completed report counts toward participation because attempting it is the engagement being measured.