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

Workforce planning

Seven sessions measured what happened. A workforce plan says what will, and the honest version is smaller and stranger than the spreadsheet it usually lives in: it is the headcount identity from deep-dive session 1, start plus joiners minus leavers equals end, run forwards with a rate in place of the leavers. Tonight the inputs are Apex's computed counts, 1,022 on 1 January 2026, 49 joiners, 210 leavers and 861 on 1 July, with 22.3 percent attrition on the average base for the half. The plan writes hiring need as growth plus replacement and finds that replacement is most of it. A transition-rate plan across Apex's four entities fits on one page. And three scenarios are reported as ranges, because the rates that drive them are guesses, and every guess below is labelled invented.

🟡 Core People analysts, HR business partners, finance partners who own the headcount budget Python, pandas, numpy 45 min
0-3 · Welcome 3-18 · The tank, the need, the bands 18-40 · Build-along: a plan from 861 40-45 · Q&A
Part 0

Where we start

Apex is constructed: a seeded table of 1,071 invented people in four invented entities. What is not invented is the arithmetic. The four bridge numbers and the attrition rate come out of that table by the same code as deep-dive session 1, so they are inputs a planner would actually have. Everything a planner would have to decide, how much to grow, how many will move between entities, whether the second half of the year looks like the first, is invented here and says so. That split, measured inputs on one side and chosen inputs on the other, is the whole discipline of workforce planning.

🎫 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 hiring-need function that returns growth and replacement separately and asserts the identity every half, a projection loop that runs it forwards from 861, three scenarios whose output is a low and a high and never a middle, a four-entity transition matrix whose rows sum to one, and a plan sentence that names every measured input and flags every invented one.

Part 1 · covers the stock-flow model

The tank, and the line a plan draws on it

5 min live

A workforce is a stock with two pipes: joiners in, leavers out. The history is a bridge; a plan is the same bridge with the end number chosen first. Pick the target stock, estimate the leavers from a rate, and the joiners are what is left over. Nothing more elaborate is going on in any planning tool, and every elaboration is a way of splitting the stock into smaller tanks or the rate into smaller pieces.

A PLAN IS THE BRIDGE WITH THE END NUMBER CHOSEN FIRST joiners, H1: + 49 the pipe the plan controls stock on 1 Jan 2026 1,022 stock on 1 Jul 2026 861 target stock T, invented the stock, H leavers, H1: - 210 22.3% on the average base 44.6% a year only by doubling HIRING NEED = growth T - H + replacement a x (H + T) / 2 a: per half, stated Apex's four counts are computed from the constructed table, and the identity holds: 1,022 + 49 - 210 = 861. The target line T and the growth that sets it are invented, and your numbers will differ.
🔍 Click to zoom - the dashed line is the only thing a plan chooses; the leavers come from a measured rate and the joiners are what closes the gap
LiveThe model is the identity, run forwards3 min▶

Deep-dive session 1 asserted start + joiners - leavers == end on history. A plan rearranges it: joiners = end - start + leavers. The end is the target, chosen. The start is measured. The leavers are not known yet, so they are a rate times a base, and that is the first place a plan can go quietly wrong. For Apex the measured half is H1 2026: 210 leavers over an average base of 941.5, which is 22.3 percent per half. Written that way, with the base named, it is a statement about one half-year of one constructed table and nothing more.

The shape of the code follows the shape of the identity. One function takes the stock at the start of a half, the planned growth and the rate, and returns the target, the growth, the expected leavers and the hires, separately. Then it asserts that start plus hires minus leavers equals the target. The assert cannot fail on a correct function, which is the point: the day someone edits the function and it does fail, the plan has stopped reconciling and the number on the slide is not a headcount any more.

Self-studyThe rate and its base travel together2 min▶

The 22.3 percent was measured on the average of the start and end stocks. Apply it to the start stock alone and it predicts more leavers than the 210 that actually left; apply it to the end stock and it predicts fewer. Neither is a mistake in arithmetic. Both are a rate used on a base it was not measured on. The hiring-need function therefore applies the rate to the average of the start and the target, the same base it came from, and the comment above it says so.

The annual figure has the same problem one level up. Doubling 22.3 gives 44.6 percent a year, which assumes the second half repeats the first and that the base stays where it was. Compounding the half-year survival gives a different annual figure under a different assumption. A plan that steps in halves needs neither: it uses the rate at the grain it was measured and never converts it. When a leader asks for the annual number, print 44.6 with the words "annualised by doubling H1, which assumes H2 repeats" beside it, every time.

Real world

The annual rate that became a hiring budget. Constructed setting, common mechanism. A planning deck carried "attrition: 44.6 percent" in a box, and a finance partner multiplied it by the year-end headcount to size the recruiting budget. The 44.6 was a doubled half-year, measured in a half that included a restructuring; the year-end headcount was a target, not a stock. Two assumptions, each reasonable in isolation, multiplied into one number nobody had chosen. The fix was not a better rate. It was a half-year model with the rate, its base and its measurement window printed in the same row.

Part 2 · covers hiring need as growth plus replacement

Most hiring is replacement

5 min live

Split the hires a plan needs into two parts and the conversation changes. Growth is the change in the stock, and it is what leaders argue about. Replacement is the leavers the plan expects at the measured rate, and at Apex's rate it is most of the number, in every scenario, including the one where the company shrinks. A flat plan is not a plan that hires nobody. It is a plan that hires everybody who leaves.

HIRING NEED = GROWTH + REPLACEMENT, AND REPLACEMENT IS THE BIG PART replacement: a x (H + T) / 2 growth: T - H HIRES IN ONE HALF, SCHEMATIC shrink, g < 0 flat, g = 0 grow, g > 0 REPLACEMENT PER 100 HEADS, ONE HALF Sales Customer Service Operations Marketing Engineering HR Finance R&D 50.0 37.6 25.2 19.7 11.3 10.7 8.3 7.4 if H2 repeats H1, Sales replaces half its start stock Left: schematic heights; the target T and the growth g are invented, and your numbers will differ. Right: Apex H1 leavers over the 1 January headcount by department, computed from the constructed table.
🔍 Click to zoom - the moss block is what leaders argue about; the hachure is what recruiting actually spends the half on
LiveGrowth plus replacement, and a flat plan still hires3 min▶

Write the target as T = H x (1 + g), with g the planned growth for the half. Then growth = T - H and replacement = a x (H + T) / 2, the expected leavers at the measured rate on the average base. Hires are the sum. At g of zero the growth term vanishes and the hires equal the replacement, which at Apex's measured rate is more than a fifth of the stock every half. At a negative g the growth term is negative, and the plan still hires unless the planned shrinkage exceeds the expected leavers.

Two consequences follow. First, the attrition rate is the biggest input to the hiring number, bigger than the growth target in any plausible range, so it deserves more scrutiny than it gets. Second, a hiring freeze is a growth decision, not a replacement decision: it sets hires to zero and lets the stock fall at the rate, and the code can print exactly how far, half by half, before anybody signs it.

Self-studyOne company rate, eight department rates, and the small tank2 min▶

A company plan built on one rate assumes every department leaves at it. Apex's departments, on the start base, ran from Sales at 50.0 percent (54 of 108) to R&D at 7.4 (7 of 94) in the same half. A plan that grows Sales and holds R&D flat needs far more hires than one that does the reverse, at the same company-level growth, and the company rate cannot see the difference. Split the tank by department and run the same function per department with its own rate.

Then watch the small tanks. HR's 10.7 percent is 8 leavers of 75; one more leaver moves it by more than a point. A department rate on a base that small is a very noisy estimate, and a plan built on it inherits the noise. The usual compromise is to use the department rate where the base is large and a pooled rate where it is not, and to say which departments got which. Deep-dive session 2's survival curves are the more careful version of the same question, asked per person rather than per tank.

Real world

The same flat plan in two industries. The BLS JOLTS survey put the 2025 mean monthly quits rate at 3.92 percent in US leisure and hospitality and 1.39 percent in manufacturing, computed from the public series in deep-dive session 3. Hold both headcounts flat and the hospitality plan needs nearly three times the voluntary replacement per head, before a single growth hire. Monthly is not annual and neither figure is a target for any one employer; the point is only that "flat" means very different recruiting loads, and the difference is entirely in the replacement term.

Part 3 · covers scenarios as ranges and transition-rate planning

Three bands, and a plan on one page

5 min live

A plan with one growth rate and one attrition rate produces one number, and the number is wrong in a way nobody can see. Give each scenario a band for each rate instead, run the model across every combination in the band, and report the lowest and the highest hires. Three scenarios become three ranges; where they overlap, the scenario label matters less than the attrition assumption underneath it. Then split the tank by entity and let people move between the tanks: that is a transition-rate plan, and it fits in a four-by-five matrix.

THREE SCENARIOS, THREE BANDS, ONE MEASURED STARTING POINT each band is every mix of its invented inputs: report the band, never its middle 861, computed grow flat shrink 1 Jul 2026 +1 half +2 halves +3 halves HEADCOUNT BY HALF, SCHEMATIC US quits rate, JOLTS monthly, percent of employment 2.32 in 2019 1.5, Apr 2020 3.0, Nov 2021 2.0, Dec 2025 861 is Apex's computed headcount; every band comes from invented rates, and your numbers will differ. Inset: BLS JOLTS total nonfarm quits rate, US, seasonally adjusted, computed from the public series.
🔍 Click to zoom - one measured dot and three invented fans; the inset is why the fans have width, since a real rate doubled inside nineteen months
LiveScenarios as ranges, not points3 min▶

A scenario is a named set of bands: the attrition rate somewhere between a low and a high, the growth somewhere between a low and a high. Run the model on a grid across both bands and keep the lowest and the highest total hires, and the lowest and highest end stock. Three scenarios give three ranges. That is the output. The middle of a range is not a forecast; it is the average of two guesses, and quoting it discards the only honest part of the work.

Two things become visible that a point plan hides. Overlap: if the flat range and the grow range overlap, the recruiting team cannot tell from the plan which world it is in, and the plan should say so. Which band is doing the work: narrow the growth band to a single value and see how much the range shrinks, then do the same for attrition. At Apex's rates the attrition band moves the hires more than the growth band does, which is Part 2's finding said again as a sensitivity.

Self-studyThe transition-rate (Markov) plan in one page3 min▶

Split the stock into states, here Apex's four entities, and add one more state, left. A transition matrix says, for a person in each state at the start of a half, the probability of being in each state at the end: stay, move to another entity, or leave. Each row sums to one. Multiply the start stock vector by the matrix and you get where everyone is at the end of the half, plus a count of leavers; add the hires vector and step again. That is the whole of a Markov workforce plan, and deep-dive session 1's identity holds inside it, row by row.

What is measured and what is invented must be kept apart. The exit column comes from Apex: every H1 leaver over the 1 January stock, by entity, Digital 25.0, Logistics 23.5, Manufacturing 19.3 and Retail 17.7 percent. Those rates count every leaver, including anyone who joined during the half and left before it ended, so as transition probabilities they are an approximation, and the code can split the two. The between-entity moves are invented: 291 Apex rows carry a transfer flag, but the snapshot does not say when most of them moved, so there is no measured half-year transfer rate to use. The hires vector is invented. Run the first half from 1 January and it is a back-test: the leavers must come out at 210, because the exit column was measured on that half.

Real world

The rate that doubled while the plan was in its binder. The US total nonfarm quits rate in the BLS JOLTS series averaged 2.32 percent a month in 2019, fell to 1.5 in April 2020 and reached 3.0 in November 2021, computed from the public series in deep-dive session 3. A plan built in 2019 on the 2019 rate, as a point, was overtaken in both directions inside two years: the rate fell a third below it, then rose nearly a third above it. A plan built on a band would not have contained 3.0 either, unless someone had been willing to write down a band that wide, and that willingness is most of the skill.

Build-along

A plan from 861, in pandas and numpy

★ 22 min · everyone builds

Seven steps on materials/apex-staff.csv, the constructed Apex table. Steps 1 and 2 print Apex's computed counts and rate, and those numbers are printed here because the table produces them. From step 3 on, every result depends on an invented input, so no output is printed: your numbers will differ, and the comment on each invented input says so.

SEVEN STATIONS 1 · inputs 1,022 · 861 49 · 210 2 · rate 22.3% a half not 44.6 3 · need() growth + replacement 4 · project half by half assert each 5 · bands 3 scenarios invented 6 · Markov 4 entities + exit state 7 · sentence rates named flags shown Stations 1 and 2 are measured on the constructed table; from station 3 on, a chosen input enters. Save the plan with its inputs in the same file. Session 9 asks what a model may and may not be fed.
🔍 Click to zoom - the wine station is the function every later station calls; get its base right and the rest is bookkeeping

The measured inputs. The same headcount function as deep-dive session 1, the same window, the same assert. These four numbers are the only ones in the plan that a planner did not choose.

★ Step 1 · inputs from Apeximport numpy as np, pandas as pd from itertools import product d = pd.read_csv("materials/apex-staff.csv", parse_dates=["Date Join", "Last Day"]) START, END = pd.Timestamp("2026-01-01"), pd.Timestamp("2026-07-01") def headcount(df, date): joined = df["Date Join"] <= date still = df["Last Day"].isna() | (df["Last Day"] >= date) return int((joined & still).sum()) H0, H1 = headcount(d, START), headcount(d, END) joiners = int((d["Date Join"] >= START).sum()) leavers = int(((d["Last Day"] >= START) & (d["Last Day"] < END)).sum()) assert H0 + joiners - leavers == H1, "bridge does not reconcile" print(H0, joiners, leavers, H1) # 1022 49 210 861

The rate, at the grain it was measured. One half, average base. The annual figure is printed with its assumption and then never used again.

★ Step 2 · the rate with its baseavg_base = (H0 + H1) / 2 # 941.5 a_half = leavers / avg_base # attrition per half, average base, H1 2026 only print(f"H1 attrition, average base {avg_base}: {a_half * 100:.1f}%") # H1 attrition, average base 941.5: 22.3% print(f"annualised by doubling: {a_half * 200:.1f}% (assumes H2 repeats H1)") # annualised by doubling: 44.6% (assumes H2 repeats H1) print(round(a_half * H0)) # the average-base rate on the start stock alone: compare it with the 210 that left

Hiring need as a function. Growth and replacement returned separately, the rate applied to the base it was measured on.

★ Step 3 · need()def hiring_need(H, g, a): """One half. H: stock at the start. g: planned growth over the half. a: attrition per half, average base.""" T = H * (1 + g) # the target stock at the end of the half growth = T - H replacement = a * (H + T) / 2 # expected leavers on the average base, the base a came from return {"target": T, "growth": growth, "replacement": replacement, "hires": growth + replacement} G_FLAT = 0.00 # invented: a flat plan; your numbers will differ print(hiring_need(H1, G_FLAT, a_half)) # at g = 0 the hires are all replacement

Run it forwards, and assert the identity every half. The target of one half is the start of the next.

★ Step 4 · projectdef project(H, halves, a, g): rows = [] for t in range(1, halves + 1): n = hiring_need(H, g, a) assert abs(H + n["hires"] - n["replacement"] - n["target"]) < 1e-9 # start + in - out == end rows.append({"half": t, "start": H, **n}) H = n["target"] return pd.DataFrame(rows) plan = project(H1, 3, a_half, G_FLAT) # three halves from 1 July 2026; g invented print(plan.round(1).to_string(index=False)) # your numbers will differ # a freeze: hires = 0, so T = H - a(H + T)/2, which solves to T = H(1 - a/2)/(1 + a/2) freeze = [H1 * ((1 - a_half / 2) / (1 + a_half / 2)) ** t for t in (1, 2, 3)] print([round(x) for x in freeze]) # what a freeze does, half by half, before anybody signs it

Three scenarios, each a pair of bands. Every combination on a five-by-five grid, and only the lowest and highest results are kept.

★ Step 5 · scenarios as ranges# every band below is invented; your numbers will differ SCENARIOS = { "shrink": {"a": (0.18, 0.26), "g": (-0.04, -0.01)}, "flat": {"a": (0.18, 0.26), "g": (-0.01, 0.01)}, "grow": {"a": (0.18, 0.26), "g": ( 0.01, 0.04)}, } def band(lo, hi, n=5): return np.linspace(lo, hi, n) out = [] for name, s in SCENARIOS.items(): runs = [project(H1, 3, a, g) for a, g in product(band(*s["a"]), band(*s["g"]))] hires = [r["hires"].sum() for r in runs] end = [r["target"].iloc[-1] for r in runs] out.append((name, round(min(hires)), round(max(hires)), round(min(end)), round(max(end)))) table = pd.DataFrame(out, columns=["scenario", "hires_low", "hires_high", "end_low", "end_high"]) print(table.to_string(index=False)) # three ranges over three halves; your numbers will differ # then narrow one band to a single value and re-run: whichever band shrinks the range most is doing the work

The transition-rate plan, four entities and an exit. The exit column is measured; the moves and the hires are invented. The first half, run from 1 January, is a back-test.

★ Step 6 · Markov, one pageents = ["Apex Digital", "Apex Logistics", "Apex Manufacturing", "Apex Retail"] in_start = (d["Date Join"] <= START) & (d["Last Day"].isna() | (d["Last Day"] >= START)) in_half = (d["Last Day"] >= START) & (d["Last Day"] < END) n0 = d.loc[in_start, "Company"].value_counts().reindex(ents).to_numpy() left = d.loc[in_half, "Company"].value_counts().reindex(ents).to_numpy() exit_rate = left / n0 # every H1 leaver over the 1 January stock, by entity print(n0, (exit_rate * 100).round(1)) # [124 221 440 237] [25. 23.5 19.3 17.7] # invented: the share of each entity's stock moving to another entity in one half; your numbers will differ move = np.array([[0.00, 0.02, 0.01, 0.01], [0.01, 0.00, 0.02, 0.01], [0.01, 0.01, 0.00, 0.01], [0.01, 0.01, 0.02, 0.00]]) stay = 1 - exit_rate - move.sum(axis=1) P = np.column_stack([move + np.diag(stay), exit_rate]) # 4 x 5; the last column is "left" assert np.allclose(P.sum(axis=1), 1), "a row does not sum to one" hires = np.array([10, 15, 25, 15]) # invented hires per half by entity; your numbers will differ n = n0.astype(float) for half in (1, 2): flows = n @ P # where this half's stock is at the end of it n = flows[:4] + hires print(half, n.round(0), "leavers", round(flows[4])) # half 1 is H1 2026 again: its leavers must be 210, because the exit column was measured there; # its stock will not be 861, because the hires are invented. Half 2 is the plan; your numbers will differ

The sentence. Measured inputs with their base and window, the range and not its middle, and the word invented on everything chosen.

★ Step 7 · the plan sentencelo, hi = table.set_index("scenario").loc["flat", ["hires_low", "hires_high"]] sentence = (f"Plan from Apex's {H1} people on 1 July 2026 (computed from a constructed table). " f"Attrition is {a_half * 100:.1f}% per half on the average base, measured on H1 2026 only. " f"The flat scenario needs between {lo} and {hi} hires over three halves. " f"Every growth, transfer and hiring rate is invented; the range is the answer, not its middle.") print(sentence) # your numbers will differ from the third sentence on table.to_csv("apex_plan_from_2026-07-01.csv", index=False)

Narrow the attrition band to 0.223 exactly and run step 5 again. The three ranges shrink to the width the growth band alone produces, and you can see how much of the plan's uncertainty was the rate. Then widen it to the full swing the JOLTS inset showed, a rate that fell to 1.5 and then rose to 3.0, and write down honestly whether your recruiting budget could absorb the high end.

Homework

Before deep-dive session 9

30 min
  1. Run the seven steps as written. Check that steps 1 and 2 print Apex's numbers exactly and that the back-test half in step 6 prints 210 leavers.
  2. Split the tank by department. Run project per department with its own start-base rate, and write which departments carry most of the replacement need and whether any base is too small to trust.
  3. Find the band doing the work. In step 5, fix growth at a single value and record the width of each range; then fix attrition instead. Write one sentence on which input deserves more scrutiny.
  4. For a team you plan for, write the measured inputs with their base and window on one line and the invented ones on the next, and bring both lines to session 9.
Check yourself

Three questions

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

1 · A leader says the headcount plan for next half is flat, so recruiting can pause. What does the model say?

Growth is the part leaders argue about and replacement is the part recruiting spends the half on. A pause is a freeze, a growth decision, and the stock then falls at the rate.

2 · The model steps in half-years. Why does it use 22.3 percent per half rather than 44.6 percent a year divided by two?

Every conversion adds an assumption. A model that steps at the grain of the measurement needs none of them, and the rate keeps its base and window attached.

3 · Why does each scenario report a low and a high for total hires rather than one number?

A point plan is wrong in a way nobody can see. The US quits rate went from 1.5 to 3.0 inside two years; a band is how a plan admits that before it happens.

Sources covered

What this session teaches, and where it came from

✓Apex inputs: 1,022 start, 49 joiners, 210 leavers, 861 end; 22.3 percent H1 attrition on the average base of 941.5; entity rates 25.0 / 23.5 / 19.3 / 17.7 and department rates Sales 50.0 to R&D 7.4 on the start baseComputed from the constructed Apex table that ships with these pages; Apex and everyone in it are invented
✓44.6 percent annualisedModelled: H1 doubled, which assumes H2 repeats H1; stated every time it appears
✓The stock-flow model; hiring need as growth plus replacement; scenarios as rangesStandard practice, no single source
✓Transition-rate (Markov) planning across four entities with an exit stateStandard practice, no single source; the exit column is computed from Apex, the moves and hires are invented
✓JOLTS quits: total nonfarm 2.32 (2019 mean), 1.5 (April 2020), 3.0 (November 2021), 2.0 (December 2025); 2025 means 3.92 leisure and hospitality, 1.39 manufacturingComputed from the BLS JOLTS public series, seasonally adjusted; monthly, US, not a target for any employer
◐Every growth rate, attrition band, transfer rate and hires vectorConstructed; labelled invented in the code; no output printed
◐A cost of attrition, or of a hireNot claimed; the folk "1.5 to 2x salary" figure has no measured source

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

Deep-dive session 8 cheat sheet · pin this

A plan is the identity forwardsHires = end - start + leavers. The end is chosen; the leavers are a rate times a base.
Growth + replacementGrowth = T - H. Replacement = a x (H + T) / 2. Report them separately.
A flat plan still hiresAt zero growth, hires are all replacement. A freeze is a growth decision.
Rate at its grain22.3 per half, average base, H1 2026. 44.6 only with "assumes H2 repeats H1".
Scenarios are bandsGrid the bands, keep the low and the high. Never quote the middle.
Markov in one pageStates plus "left", rows sum to one, n @ P + hires. Back-test the first half.