learn-people-analytics-with-phoebe / Deep-dive session 6 of 10
Learn People Analytics with Phoebe · Deep-dive session 6 of 10

Pay equity regression

Deep-dive session 5 decomposed 11,301 real filings and found that the pyramid alone reports a median gap of 9.8 percent against a reported 11.0. It could not look inside a band, because the filing does not publish pay inside a band. A salary table can. Tonight the same question is asked of individual pay: fit log pay on grade, tenure and sex by least squares, read the coefficient on sex as the within-band gap, then split the raw gap the Oaxaca-Blinder way into the part that measured differences account for and the part they do not. Apex has no salary column, so the build-along generates one with a seed and a planted coefficient, which means you know the answer the regression is supposed to find. By the end you will know why "explained" is a statement about measurement and not about fairness, and which variable can never be a control.

🟡 Core Reward analysts, people data teams, anyone asked "is our pay fair" Python, numpy, pandas 45 min
0-3 · Welcome 3-18 · Inside the band, and the split 18-40 · Build-along: least squares on a seeded table 40-45 · Q&A
Part 0

Where we start

A pay gap is a difference in averages. A pay equity question is narrower: do two people in the same job, at the same grade, with the same tenure, get paid the same? The filing cannot answer it, because the filing sees four bands and nothing inside them. A salary table with a grade and a join date on every row can, and the tool is an ordinary regression. Every salary tonight is constructed: generated by a seeded script on the page, with a small coefficient on sex planted inside it so that the method has a known answer to find. On a real table nobody planted anything, and that is the whole difficulty.

🎫 Clear all three quiz questions to stamp your passport Live = presented in the 45 minutes Self-study = the full depth, after class ★ Build-along = run the code yourself

★ What you walk out with tonight. A seeded salary generator whose planted coefficient you can change, a least-squares fit of log pay on grade, tenure and sex in six lines of numpy, an Oaxaca-Blinder split that reconciles to the raw gap by an assert, a demonstration of what a bad control does to the coefficient, and the one sentence that reports the result without claiming more than it measured.

Part 1 · covers within-band pay and the regression

Log pay on grade, tenure and sex

5 min live

Pay is multiplicative: a grade step adds a percentage, not a fixed sum, and a year of tenure adds a smaller percentage. Taking the log turns those percentages into additions, so a straight-line model fits: log pay equals a constant, plus a grade effect, plus a tenure effect, plus a coefficient on sex, plus whatever is left. Least squares finds the rungs and the slope that leave the least behind. The coefficient on sex is then the average difference in log pay between women and men at the same grade and tenure, which is the within-band question the filing could not ask.

THE REGRESSION AS A LADDER WITH RESIDUALS moss rungs: the fitted grade effect · dashed: the ladder shifted by the sex coefficient · dots: people log pay, up residual 1 2 3 4 GRADE BAND, 1 TO 4 The model, one line log(pay) = a + grade effect (the rungs) + tenure effect (the slope) + sex coefficient (the shift) + noise (the scatter) shift: planted, invented Each dot is invented. Rungs: what least squares fits. Residual: what it leaves. Shift: the coefficient on sex.
🔍 Click to zoom - the top rung is a bigger step, as the fitted ladder on real filings was (1 / 1.2 / 1.45 / 2.8); the dashed shift is the within-band question
LiveWhy log, why least squares, and what the coefficient on sex means3 min▶

Why log. A grade step at Apex Digital and a grade step at Apex Retail are different sums of money but similar percentages, and a model in percentages is one model rather than four. On the log scale a coefficient of -0.04 on sex reads as "about 4 percent less, other things in the model held equal", and the conversion back is one minus e to the coefficient.

Why least squares. It is the fit that leaves the smallest sum of squared residuals, it has a closed form that numpy solves in one call, and its coefficients have the interpretation everybody wants: the change in log pay for a one-unit change in the variable, holding the others in the model fixed. The statistics course teaches it from scratch; here it is a tool, and the tool's limits matter more than its mechanics.

What the coefficient on sex means. It is the average gap between women and men who share the same grade and tenure values. It is not the raw gap, which is bigger whenever women sit lower on the ladder, and it is not "discrimination", because a coefficient is a difference that the other variables in the model did not account for, and the model only contains what somebody chose to put in it. Change the variables and the coefficient changes. Part 3 is about that choice.

Self-studyReading a coefficient table without being fooled by it2 min▶

Three habits. Print the unit. A coefficient on tenure is per year; a coefficient on grade is per band, and only if the bands are evenly spaced does one number describe the ladder, which is why the build-along treats grade as four separate rungs in the homework. Print the raw and the adjusted gap side by side, because the distance between them is the part the measured variables absorbed, and that distance is the finding, not the adjusted number alone. Print the residual spread. A within-band coefficient of 4 percent with a residual standard deviation of 12 percent means the individual variation dwarfs the average difference, and any single pair of colleagues is a poor test of the average.

Real world

The filing that said 9.8 and the table that said something else. Deep-dive session 5 showed that on 11,301 UK filings the band composition alone would report a median gap of 9.8 percent against a reported median of 11.0, leaving a median 2.3 points that the pyramid does not account for. A regression on a salary table is what turns that residual into a number an employer can act on: it says whether the 2.3 points sit inside the bands, and at which grade. The filing cannot; it was never designed to. Real filings, constructed salaries, same question at two resolutions.

Part 2 · covers the Oaxaca-Blinder split

Explained and unexplained, and why explained is not justified

5 min live

Blinder 1973 (Journal of Human Resources 8(4) 436-455) and Oaxaca 1973 (International Economic Review 14(3) 693-709) published the same idea in the same year: fit the pay model separately for men and for women, then write the gap between the two average pays as two pieces. One piece is the difference in the averages of the variables, priced at one group's coefficients: men sit at higher grades and longer tenures, and that alone would produce a gap. The other piece is the difference in the coefficients, applied to one group's averages: the same grade and tenure, paid differently. The first is called explained, the second unexplained, and the names have done a great deal of damage.

ONE GAP, TWO PIECES mean log pay, men mean log pay, women THE RAW GAP explained unexplained measured differences in grade and tenure same grade, same tenure, different pay men women Explained is not justified: the grade mix can itself come from who gets promoted, and the split cannot tell. In the UK filings (deep-dive session 5): composition-only median 9.8, reported 11.0, residual 2.3 points. The two pieces add to the raw gap exactly; the build-along asserts it. Bars are schematic, not data.
🔍 Click to zoom - the hachured piece is what measured differences account for; it is a statement about measurement, and the moss piece is the question that remains
LiveThe arithmetic of the split, and the choice hidden in it3 min▶

Fit log pay on grade and tenure for men, giving coefficients bm, and for women, giving bw. Least squares passes through the means, so each group's mean log pay is its mean variables times its coefficients. The gap between the means is then (xm minus xw) times bm, plus xw times (bm minus bw). The first term prices the difference in average grade and tenure at men's coefficients; the second applies the difference in coefficients to women's averages. They add to the gap exactly, and an assert in the code says so.

The hidden choice is which group's coefficients price the first term. Priced at women's coefficients, the same data give a different explained share, and the difference between the two versions is a measure of how far the two pay structures diverge. Report both, or report one and say which; a decomposition that names only "explained 70 percent" without the reference group is not reproducible. On the constructed table the two versions sit close, because the generator used one structure with a shift; on a real table they may not.

Self-studyGoldin on hours, and what a filing cannot see2 min▶

Goldin 2014 (American Economic Review 104(4) 1091-1119) argued that the remaining gap is driven by occupations that reward long and particular hours: jobs where being available at specific times pays disproportionately, and where the penalty for flexibility falls more often on women. Hours is therefore a variable that belongs in a pay model when it is measured, and a variable the UK filing cannot see at all. Nor can the filing see occupation, tenure or part-time status. That is why the filing's 2.3-point residual and a regression's coefficient on sex are not the same number and should never be compared as if they were: the first is a gap the pyramid did not account for; the second is a gap that a specific list of variables did not account for. Neither is a measure of intent, and both move when the list changes.

Real world

"Seventy percent of the gap is explained." A reward team, constructed setting, ran the split and presented the explained share as reassurance. The explained share was mostly grade. A second question, who gets promoted to the higher grades and at what tenure, was not asked, because the model had already "explained" the gap. The split had done exactly what it does: priced a difference in grade mix at the going rate. Whether the grade mix is itself fair is a question the split cannot answer and did not claim to. Explained means measured. It never meant justified.

Part 3 · covers the variable that should not be in the model

The control that absorbs the gap

5 min live

Every variable added to the right-hand side takes some of the gap for itself. That is the point of a control, and it is also the trap: add a variable that was itself shaped by the thing you are measuring, and the coefficient on sex shrinks not because the gap closed but because it was relabelled. The test is one question about time and independence: was this variable set before the pay decision under examination, and by a process that did not know the answer?

A CONTROL IS A CLAIM ABOUT TIME AND INDEPENDENCE IN THE MODEL: set before pay, measured grade: the rung; borderline, promotion decides it tenure: years since join, from the join date hours worked: measured in payroll, not in the filing Goldin 2014: the remaining gap is driven by jobs that reward long and particular hours sex: the coefficient being tested, never a control NOT IN THE MODEL: set by the process under test prior salary: carries every earlier gap into the model a rating written by the manager who set the pay grade, when the same review sets pay and grade anything downstream of the sex coefficient each one absorbs the gap and reports it as explained The test: was it set before, and independently of, the pay decision examined? If not, it absorbs the gap. Grade is borderline: a control for within-band pay, a trap for the promotion question. Say which was asked.
🔍 Click to zoom - prior salary is the clearest case: it carries every earlier gap into the model and hands it back labelled explained
LivePrior salary, and the demonstration in the build-along3 min▶

The clearest bad control is what the person was paid before: last year's salary, or the salary at the previous employer. It predicts current pay almost perfectly, so the fit improves, and it already contains whatever gap existed last year, so the coefficient on sex falls toward zero. Nothing was fixed. The gap was moved from the variable you were watching to a variable you were not. Step 7 of the build-along plants exactly that: a "prior pay" column that carries the same shift, then refits, and the coefficient on sex shrinks while the pay itself has not changed by a cent.

Grade is the honest borderline. For the question "are two people in the same band paid the same", grade is the right control and the coefficient on sex answers it. For the question "is the whole pay system fair", grade cannot be a control, because who reaches each grade is part of what is being asked. The same table, the same code, two questions; the analyst's job is to say which one was asked. A pay equity report that answers the first and is read as answering the second is the most common way a true number misleads.

Self-studySmall groups, and the coefficient that is not there2 min▶

Fit the model on Apex HR, 76 people, and the coefficient on sex will have a standard error wide enough that almost any value is consistent with the data. That is not a finding of no gap; it is a finding of not enough people to say. The statistics course covers the standard error properly; the habit here is to print the count under every coefficient the way foundations session 2 printed the base under every rate, and to refuse a sentence about a group whose count would not support it. The anonymity threshold that deep-dive session 7 applies to survey groups applies here too, for a second reason: with five people in a band, the coefficient on sex identifies individuals.

Build-along

Least squares and the split on a seeded, constructed salary table

★ 22 min · everyone builds

Seven steps. Apex has no salary column, so step 1 generates one: a thousand invented rows with a planted ladder, a planted tenure slope, a planted shift on sex and noise, from numpy.random.default_rng with a stated seed. Every parameter in it is invented; the only borrowed number is the female share by band, which copies the median UK filing's pyramid from deep-dive session 5 so that the explained piece is not zero. No output is printed on the page, because the point is to run it: your numbers will differ, and with a different seed they will differ again.

SIX STATIONS 1 · generate rng, seed 2026 invented pay 2 · design matrix grade, tenure, sex plus a constant 3 · least squares np.linalg.lstsq four coefficients 4 · by sex two fits men, women 5 · the split explained unexplained 6 · the sentence explained is not justified Station 1 plants the coefficient, so station 5 has a known answer to find. On real pay, nobody planted it. Save the coefficient table with the seed in its filename. Session 10 defends a number; that is the habit.
🔍 Click to zoom - the hachured station is the invented data; every number downstream of it is a property of the seed, and the page prints none of them

Generate the constructed table. A seed, a ladder, a slope, a shift and noise. The female share by band is the only borrowed number, from the median UK filing. Everything else is invented and says so.

★ Step 1 · generate, seededimport numpy as np, pandas as pd rng = np.random.default_rng(2026) # the seed; change it and every number below moves n = 1000 # invented people, none of them real grade = rng.integers(1, 5, n) # four bands, like the filing's quartiles tenure = rng.gamma(2.0, 2.5, n).round(1) # years, invented shape # female share by band borrowed from the median UK filing, bottom to top: 56.0 / 52.7 / 47.0 / 40.0 share = np.array([0.560, 0.527, 0.470, 0.400])[grade - 1] female = (rng.random(n) < share).astype(int) # planted structure, every parameter invented: a ladder, a slope, a small shift, noise log_pay = 10.0 + 0.22 * grade + 0.015 * tenure - 0.04 * female + rng.normal(0, 0.12, n) pay = pd.DataFrame({"grade": grade, "tenure": tenure, "female": female, "log_pay": log_pay}) pay["pay"] = np.exp(pay["log_pay"]).round(0) print(pay.head()) # your numbers will differ print(pay.groupby("grade")["female"].mean().round(3).to_dict()) # near the borrowed shares

The design matrix and the fit. A column of ones for the constant, then grade, tenure and sex. One call to least squares returns the four coefficients.

★ Step 2 · least squaresX = np.column_stack([np.ones(n), pay["grade"], pay["tenure"], pay["female"]]) y = pay["log_pay"].to_numpy() beta, *_ = np.linalg.lstsq(X, y, rcond=None) for name, b in zip(["const", "grade", "tenure", "female"], beta): print(f"{name:7s} {b:+.4f}") # your numbers will differ # the female coefficient should land near the planted -0.04; the distance from it is the noise

Raw gap and adjusted gap, side by side. The raw gap is the difference in mean log pay. The adjusted gap is the coefficient on sex. The distance between them is what grade and tenure absorbed.

★ Step 3 · raw vs adjustedmen, women = pay[pay["female"] == 0], pay[pay["female"] == 1] raw_gap = men["log_pay"].mean() - women["log_pay"].mean() adj_gap = -beta[3] print(f"raw gap {raw_gap:.4f} log points = {(1 - np.exp(-raw_gap)) * 100:.1f}% of men's pay") print(f"adjusted gap {adj_gap:.4f} log points = {(1 - np.exp(-adj_gap)) * 100:.1f}% at the same grade and tenure") print(f"absorbed by grade and tenure: {raw_gap - adj_gap:.4f}") # your numbers will differ print(len(men), len(women)) # print the counts under every coefficient

Residuals, and how much the model leaves. The R2 says how much of the variation the three variables account for; the residual spread says how far any one person sits from the rung.

★ Step 4 · residualsresid = y - X @ beta r2 = 1 - resid.var() / y.var() print(f"R2 {r2:.3f} residual sd {resid.std():.3f} log points") # your numbers will differ # a within-band coefficient of a few percent next to a residual sd of 12 percent: # the average difference is real, and any single pair of colleagues is a poor test of it

Two fits, one per sex. The split needs each group's own coefficients and each group's mean variables. Least squares passes through the means, which is what makes the arithmetic exact.

★ Step 5 · fit by sexdef fit(df): Xg = np.column_stack([np.ones(len(df)), df["grade"], df["tenure"]]) b, *_ = np.linalg.lstsq(Xg, df["log_pay"].to_numpy(), rcond=None) return Xg.mean(axis=0), b # mean variables, coefficients xm, bm = fit(men) xw, bw = fit(women) print("men ", np.round(bm, 4)) # your numbers will differ print("women ", np.round(bw, 4))

The Oaxaca-Blinder split, with the assert. Explained prices the difference in mean variables at men's coefficients; unexplained applies the difference in coefficients to women's mean variables. The two must add to the raw gap.

★ Step 6 · the splitgap = xm @ bm - xw @ bw # equals the raw gap of the means explained = (xm - xw) @ bm # different grade and tenure, priced at men's coefficients unexplained = xw @ (bm - bw) # same grade and tenure, different coefficients assert abs(gap - explained - unexplained) < 1e-9, "the split does not reconcile" print(f"gap {gap:.4f} = explained {explained:.4f} + unexplained {unexplained:.4f}") print(f"explained share, men's coefficients as reference: {explained / gap * 100:.1f}%") # your numbers will differ # the other reference: price the difference at women's coefficients explained_w = (xm - xw) @ bw print(f"explained share, women's coefficients as reference: {explained_w / gap * 100:.1f}%")

The bad control, planted, and the sentence. An invented "prior pay" column that already carries the shift. Add it, refit, and watch the coefficient on sex move toward zero while nobody's pay changed.

★ Step 7 · prior salary as a control, and the sentence# invented: last year's log pay, which already carried the same shift plus its own noise pay["prior_log_pay"] = pay["log_pay"] - 0.02 + rng.normal(0, 0.05, n) X_bad = np.column_stack([X, pay["prior_log_pay"]]) beta_bad, *_ = np.linalg.lstsq(X_bad, y, rcond=None) print(f"female coefficient without prior pay {beta[3]:+.4f}, with prior pay {beta_bad[3]:+.4f}") # your numbers will differ; the second sits nearer zero and the gap has not gone anywhere sentence = (f"On {n} constructed rows, women's log pay is {raw_gap:.3f} below men's before adjustment " f"and {adj_gap:.3f} below at the same grade and tenure; {explained / gap * 100:.0f}% of the raw gap " "is accounted for by grade and tenure mix, which is a statement about measurement, not about fairness. " "Prior salary was excluded as a control because it carries earlier gaps into the model.") print(sentence) pay.to_csv("apex_pay_seed2026.csv", index=False)

Change the seed, then change the planted shift to zero. With the shift at zero the coefficient on sex should sit near zero and the explained share near a hundred percent, and the raw gap will still be positive, because women sit lower on the borrowed pyramid. That is the cleanest demonstration of the whole session: a raw gap with no within-band gap at all, produced by composition alone, exactly as the filings in deep-dive session 5 suggested for a third of employers.

Homework

Before deep-dive session 7

30 min
  1. Run the seven steps with the seed as written, then with two other seeds. Record the coefficient on sex each time and write one line on how far it moved from the planted -0.04.
  2. Replace grade as one number with four rungs: three indicator columns for grades 2, 3 and 4. Compare the fitted rungs with the planted ladder and say whether the top step is bigger.
  3. Set the planted shift to zero and re-run the split. Write the sentence the result supports, and the sentence it does not.
  4. List the controls for a pay table you own: for each, when it was set and by whom. Mark the ones that fail the test in Part 3.
Check yourself

Three questions

Clear all three and deep-dive session 6 gets stamped in your passport.

1 · The regression returns a coefficient of -0.04 on sex, with grade and tenure in the model. What does the number mean?

The raw gap is the difference in means; the coefficient is the difference holding the model's variables fixed. The distance between them is what grade and tenure absorbed. Neither is a finding about fairness on its own.

2 · The Oaxaca-Blinder split reports 70 percent of the gap as explained by grade and tenure. A colleague says the gap is therefore mostly justified. What is wrong?

The split prices a measured difference. It says nothing about how the difference arose. Report the reference group too, since the explained share changes with it.

3 · Adding last year's salary to the model makes the coefficient on sex fall from -0.04 to nearly zero. What happened?

A control that was shaped by the thing being measured relabels the gap as explained. Step 7 plants exactly this and shows the coefficient shrink while the table is unchanged.

Sources covered

What this session teaches, and where it came from

✓Log pay on grade, tenure and sex by least squares; the coefficient on sex as the within-band gapStandard practice, no single source; the statistics course teaches least squares from scratch
✓Oaxaca-Blinder decomposition into explained and unexplainedBlinder 1973, Journal of Human Resources 8(4) 436-455; Oaxaca 1973, International Economic Review 14(3) 693-709; both primary
✓The constructed salary table: 1,000 rows, seed 2026, planted ladder, slope, shift and noise; prior pay as a planted bad controlGenerated on the page's own build script; every parameter invented; the female share by band borrows the median UK filing's 56.0 / 52.7 / 47.0 / 40.0 (computed); no output printed
✓Composition-only 9.8 vs reported 11.0, residual 2.3 points, on 11,301 filingsComputed in deep-dive session 5 from the gov.uk gender pay gap CSV, Open Government Licence v3.0
◐Goldin on hours: the remaining gap is driven by occupations that reward long and particular hoursGoldin 2014, American Economic Review 104(4) 1091-1119, primary; named in self-study
◐Standard errors and small groupsNamed; the statistics course owns it

Every fact on this page, and its verification tier, is recorded in the course's source map.

Deep-dive session 6 cheat sheet · pin this

The modellog(pay) = a + grade + tenure + sex + noise. Log because pay is multiplicative.
The coefficient on sexThe within-band gap the listed variables did not absorb. Changes with the list.
Raw and adjusted, side by sideThe distance between them is what the controls absorbed. Print the counts.
Oaxaca-Blindergap = (xm - xw) bm + xw (bm - bw). Assert it reconciles. Name the reference group.
Explained is not justifiedIt prices a measured difference. Whether the grade mix is fair is a separate question.
The control testSet before, and independently of, the pay decision? Prior salary fails. Grade depends on the question.