learn-ship-platform-diagnosis-with-phoebe / Analyst session 2 of 5
Learn Ship Platform Diagnosis with Phoebe · Analyst track · Session 2 of 5

Follow the margin

Revenue on the key servers grew every year through 2024 and the platform's blended margin barely flinched, so nobody looked. Today you take five years of order lines, draw the whole platform and then the same series one panel per server, and watch the break appear on S07 and S08 in March 2024 and nowhere else. Then you name the mechanism with a per-SKU margin bridge, let a change-point scan date it without being told, and put a running price on the decision: 251k of profit over 30 months. Every chart is full history first, because a window cannot tell a step from a wobble.

🟡 Analyst track Data analysts · pandas + matplotlib Notebook: a2_follow_the_margin.ipynb 45 min
0-3 · Welcome 3-16 · Whole, then per server 16-40 · Bridge, change-point, price 40-45 · Q&A
Part 0

Where a2 sits

a1 left a map and a four-line hand-off note: dedupe on, guests excluded and stated, short windows caveated, events overlaid. a2 starts from that note and answers the first of the nine angles - the transaction trend and the 2024 break - then prices the break so the chairman pages have a number to quote. a3 walks the product hierarchy and the baskets, a4 measures merchants and stores, a5 does customers, promotions and the data gaps. Everything loads through analysis/lib.py with dedupe=True; work along in notebooks/a2_follow_the_margin.ipynb.

Live - presented in session Self-study - read after class Sources covered
★ What you walk out with today A monthly series helper you will reuse in every later session. A 2x5 server grid that shows the break living on two servers only. A margin bridge that says cost, not volume, and says it per SKU so the mix term is real. A change-point scan that dates the break to March 2024 on its own and stays quiet on eight servers that had no decision. And a price: 251k over 30 months, 8.4k a month, stated with its assumption.
Part 1 · covers the monthly helper, small multiples, the event overlay

Whole platform first, then one panel per server 13 min live

Blends hide. Ten servers averaged into one line lose a few margin points and the line still looks like a healthy business drifting. The same series drawn as ten small panels shows two of them stepping down twelve points in one month. The order matters: the whole first, so the reader knows the company is not on fire, then the un-blend, so they see exactly where it is smouldering.

Blends hide - ten servers, one average margin, Sep23-Feb24 margin, Apr24-Sep24 key servers after the decision S01 S02 S03 S04 S05 S06 S07 S08 S09 S10 S07 + S08 fall 12 points weighted by revenue platform, blended drifts a few points Ten servers, two hit. Averaged together the platform line loses a few points and nobody looks twice.
🔍 Click to zoom - the blended line is not wrong, it is just the wrong grain for a diagnosis. Un-blend by the unit that took the decision.
Live The monthly helper 5 min▶

One helper, lib.monthly, returns revenue, cost, profit, margin, orders, lines, units and identified customers per month, for the whole frame or for any list of keys. Every long series in the track comes out of it, so the definition of "a month of margin" lives in one place. Two rules ride along. Load with dedupe=True, because a1 found S02 duplicating lines in 2021-H1. And never chart the partial current month as a value: the dataset ends 2026-09-07, so September 2026 has a week of orders, and a week drawn next to full months looks like a collapse.

★ Load deduplicated, drop the partial month, one monthly frameimport lib lib.style() tx = lib.load_transactions(dedupe=True) events = lib.load_known_events() complete = tx.month < pd.Period(lib.AS_OF, "M") # the current month is partial - never chart it as a value txc = tx[complete] print(f"{len(tx):,} lines after dedupe; charting {txc.month.nunique()} complete months") plat = lib.monthly(txc) # revenue, cost, profit, margin, orders, units, customers fig, axes = plt.subplots(2, 1, figsize=(13, 7), sharex=True, gridspec_kw={"height_ratios": [2, 1]}) axes[0].plot(plat.month_ts, plat.revenue / 1e3, color=lib.NAVY, lw=2, label="revenue (k)") axes[0].plot(plat.month_ts, plat.profit / 1e3, color=lib.AMBER, lw=2, label="profit (k)") lib.annotate_events(axes[0], events) axes[1].plot(plat.month_ts, plat.margin * 100, color=lib.NAVY_MID, lw=2) axes[1].set_ylim(25, 50) lib.save(fig, "a2_platform_monthly")
Two stacked panels of monthly platform revenue and profit from 2020 to 2026 with known events overlaid, and blended margin percent below. Revenue climbs with repeating Q4 peaks; the blended margin line drifts down only a few points from 2024.
🔍 Click to zoom - 250,093 lines after dedupe, 77 complete months. Blended, the platform looks healthy. That is the point of drawing it first.

Read the whole before the parts. Revenue climbs, the Q4 peaks repeat, and the blended margin slides a few points from 2024. Nothing here says crisis, and a chairman who sees only this chart is right to feel calm. The next chart takes the calm away with the same numbers.

Live Small multiples by server 5 min▶

Same helper, one extra key. Ten panels, shared axes so the eye compares heights honestly, key servers in amber, a faint dashed line at March 2024 on every panel so the reader can see that eight of them do nothing there. Shared y-limits are the whole trick: let matplotlib autoscale each panel and every server looks like it has a dramatic story.

★ The 2x5 grid - shared axes, key servers ambersrv = lib.monthly(txc, ["server_id"]) servers = sorted(srv.server_id.unique()) fig, axes = plt.subplots(2, 5, figsize=(15, 6), sharex=True, sharey=True) for ax, s in zip(axes.ravel(), servers): d = srv[srv.server_id == s] ax.plot(d.month_ts, d.margin * 100, color=lib.AMBER if s in lib.KEY_SERVERS else lib.NAVY, lw=1.8) ax.axvline(pd.Timestamp("2024-03-01"), color=lib.FAINT, ls="--", lw=1) ax.set_title(s + (" (key)" if s in lib.KEY_SERVERS else ""), fontsize=11) ax.set_ylim(20, 50) fig.suptitle("Margin % per month, one panel per server - the break lives on S07 and S08 only", x=0.01, ha="left") lib.save(fig, "a2_server_margin_grid")
A two by five grid of monthly margin percent, one panel per server, shared axes. Eight navy panels hold near 42 percent throughout. The two amber panels, S07 and S08, step down to about 30 percent at the dashed March 2024 line and stay there.
🔍 Click to zoom - S07 went 42.3 percent in 2023 to 30.9 in 2024 and 28.5 in 2025; S08 42.2 to 32.1 to 30.3. Every other server sits within a point of where it was.
Real world A regional chain reported group margin every month for two years while one region's margin had halved after a warehouse change. The group line moved less than the seasonal noise. The first small-multiples chart, drawn by a new analyst in an afternoon, ended the debate about whether there was a problem and started the one about who had decided it. Small multiples are not a style choice; they are the only chart that can show where a blended number lives.
Self-study Overlay what you know 3 min▶

lib.annotate_events draws every row of known_events as a thin dashed line with a short label - amber for business events, faint for technical ones. It goes on every long series, not just the one where you expect a bend, for a plain reason: the first thing a reader does when a line bends is ask "what happened there?", and an analyst who has to look it up loses the room. The team's memory is data. Printwell keeps nine rows of it, which a1 filed as a gap; until there is a real event log, those nine lines are the best context the company has, and they belong on the chart rather than in a footnote.

Two habits make the overlay honest. Filter by scope when a chart is about one server, using scope_filter, so platform-wide events and that server's events show and other servers' events do not. And never let an overlay do the arguing: the line marks a date, the bridge in Part 3 names the mechanism, and only the two together make a claim.

Part 2 · covers the indexed view, the lookback windows, seasonality

Volume held, margin fell - and the lookback trap 7 min live

The chairman's first question is always "did we sell less?" On S07 and S08 the answer is no. Orders and revenue after March 2024 sit within a few percent of the six months before; profit fell by nearly a third. Say that in one indexed picture, then show what the same number looks like through a three-month window, because a three-month window is exactly what the weekly dashboard was showing while this happened.

Live Index to the six months before 3 min▶

Index every series to the average of September 2023 through February 2024 = 100. Indexing puts orders, revenue and profit on one axis so the reader sees them diverge; the six-month base smooths a single odd month out of the denominator. Shade March 2024 so the eye lands where the story starts.

★ Orders, revenue and profit on the key servers, indexedkey = lib.monthly(txc[txc.is_key]) base = key[(key.month >= pd.Period("2023-09", "M")) & (key.month <= pd.Period("2024-02", "M"))] idx = key[(key.month >= pd.Period("2023-03", "M")) & (key.month <= pd.Period("2025-02", "M"))].copy() for c in ["orders", "revenue", "profit"]: idx[c + "_i"] = idx[c] / base[c].mean() * 100 fig, ax = plt.subplots(figsize=(12, 5)) ax.plot(idx.month_ts, idx.orders_i, color=lib.NAVY, lw=2, label="orders") ax.plot(idx.month_ts, idx.revenue_i, color=lib.NAVY_MID, lw=2, label="revenue") ax.plot(idx.month_ts, idx.profit_i, color=lib.AMBER, lw=2.5, label="profit") ax.axhline(100, color=lib.FAINT, lw=1) ax.axvspan(pd.Timestamp("2024-03-01"), pd.Timestamp("2024-03-31"), color=lib.AMBER_50) lib.save(fig, "a2_volume_held_margin_fell")
Three indexed lines for S07 plus S08 from March 2023 to February 2025 with Sep 2023 to Feb 2024 average as 100. Orders and revenue stay near 100 through the shaded March 2024 band; profit drops by roughly a third and stays down.
🔍 Click to zoom - Dec23-Feb24 against Apr24-Jun24: orders -4.2 percent, revenue -4.4 percent, profit -31.8 percent, margin 42.3 to 30.2.

Print the three-month comparison as a table under the chart. A chart persuades; a table of four rows is what gets pasted into the email that follows. Both come from the same key frame, so they cannot disagree.

Live Three windows on one number 4 min▶

Take the S07 margin series as of August 2024, five months after the break, and draw it three times: the last three months, the last twelve, the full history. Same numbers, same axis limits. The three-month view is a wobble around 29. The twelve-month view is a step. The full view is a cliff with four flat years in front of it. Anyone running the business off a trailing-quarter dashboard in 2024 saw the first panel and was right, from inside that window, to see nothing.

★ One series, three windows, same y-axiss07 = srv[srv.server_id == "S07"].set_index("month") asof = pd.Period("2024-08", "M") fig, axes = plt.subplots(1, 3, figsize=(15, 4.2)) for ax, n, label in zip(axes, [3, 12, 60], ["last 3 months", "last 12 months", "full history"]): d = s07.loc[asof - n + 1: asof] ax.plot(d.month_ts, d.margin * 100, color=lib.AMBER, lw=2.2, marker="o" if n == 3 else None) ax.set_title(f"S07 margin % - {label}") ax.set_ylim(20, 50) # shared limits or the comparison is theatre if n > 3: ax.axvline(pd.Timestamp("2024-03-01"), color=lib.FAINT, ls="--") lib.save(fig, "a2_lookback_windows")
Three panels of S07 margin percent as of August 2024 on a shared 20 to 50 axis. The three-month panel shows a flat wobble near 29. The twelve-month panel shows a step down at the dashed March 2024 line. The full-history panel shows four years near 42 then a cliff.
🔍 Click to zoom - only the long window shows a step rather than a wobble. Full history first, then window: it is the first methodology law for a reason.

Now do it yourself, on the real monthly numbers the notebook exports. The widget below draws the course dataset, not a model. Change data-series to S08, S03 or platform in the page source to repeat the exercise on a second hit, a null server and the blend.

How to read the widget Start where it opens: 3 mo, margin, as of 2024-08. That is the dashboard view - a flat line near 29 with nothing to explain. Click 12 mo and the step appears. Click all and the four flat years before it appear too, which is what turns a step into a decision. Then switch the metric to orders and revenue and notice that neither steps. Finally drag as-of back to 2024-03 and forward month by month: the earliest a three-month window could have shown the break is the month it happened, and by 2024-06 it has already forgotten.
Self-study Seasonality so November is not mistaken for strategy 3 min▶

Printwell's revenue by month of year, averaged over the five complete years 2021-2025 and indexed to 100: Q4 carries the year and February is the trough. This chart exists so that two mistakes cannot be made later. Nobody credits a Q4 initiative for a Q4 peak that happens every year, and nobody blames a February dip on a January decision. It also explains the volume term of the bridge in Part 3: comparing Sep-Feb with Apr-Sep bakes in a seasonal difference, and the same difference shows up on the servers that had no decision, which is how you know it is season and not the decision.

Bar chart of platform revenue by month of year, 2021 to 2025 average indexed to 100. October, November and December stand well above 100 in amber; February is the lowest bar.
🔍 Click to zoom - Q4 above 120 in amber, February the trough. Compare like-for-like months or difference out the season before reading any change.
Part 3 · covers the per-SKU margin bridge, the change-point scan, raw rows

Name the mechanism: the bridge and the change-point 11 min live

Decompose before you narrate. Profit change between two periods is volume plus price plus cost plus mix, and naming the term comes before naming a cause. On S07 and S08 between Sep23-Feb24 and Apr24-Sep24 the terms are volume -18k, price +6k, cost -54k, mix +5k. On every other server the cost term is exactly zero. That is not a market moving; that is someone changing what an order costs to fulfil.

Bridge anatomy - S07 + S08 profit, Sep23-Feb24 to Apr24-Sep24 193k -18k +6k -54k +5k 132k Sep23-Feb24 volume price cost mix Apr24-Sep24 volume = (units_b - units_a) x average unit profit in a price = sum over SKUs sold in both periods of (unit price_b - unit price_a) x units_b cost = - sum over the same SKUs of (unit cost_b - unit cost_a) x units_b mix = what is left: SKU composition shifts, plus SKUs sold in only one period. Amber = negative terms.
🔍 Click to zoom - four terms, one of them the story. The other servers' bridge over the same months has volume +21k, price +13k, cost 0, mix +3k.
Live Per-SKU rates or the mix term is a lie 5 min▶

The tempting shortcut is a blended bridge: average price per unit and average cost per unit for each period, times units. It is quick and it is wrong in a specific way. If price and cost effects are computed on blended rates, then volume + price + cost equals the whole profit change by construction, and mix comes out as exactly zero every time. A term that is zero by algebra is not a finding; it is a column you forgot to compute. lib.margin_bridge computes price and cost per SKU, over the SKUs sold in both periods, at period-b units, and lets mix be the remainder: composition shifts and SKUs that appear in only one period. On S07 and S08 mix is +5k, small but real, which is what a mix term is supposed to look like when nothing happened to mix.

★ Two bridges, key servers against the rest, same monthsdef slice_(d, a, b): return d[(d.month >= pd.Period(a, "M")) & (d.month <= pd.Period(b, "M"))] bridges = {} for name, d in [("S07 + S08", txc[txc.is_key]), ("other servers", txc[~txc.is_key])]: bridges[name] = lib.margin_bridge(slice_(d, "2023-09", "2024-02"), slice_(d, "2024-04", "2024-09")) bridge = pd.DataFrame(bridges).round(0) bridge.to_csv(lib.REPORTS / "a2_margin_bridge.csv") # c2 quotes this file, never a retyped number bridge
Two waterfall charts side by side. S07 plus S08: start 193k, volume minus 18k, price plus 6k, cost minus 54k in a tall amber bar, mix plus 5k, end 132k. Other servers: start 395k, volume plus 21k, price plus 13k, cost exactly zero, mix plus 3k, end 432k.
🔍 Click to zoom - on the key servers the term is cost; on the rest the term is volume and the cost bar has no height at all.
TermS07 + S08Other serversReading
start193k395kprofit, Sep23-Feb24
volume-18k+21kseasonal, similar shape on both groups
price+6k+13kprices did nothing unusual
cost-54k0the whole story, and only here
mix+5k+3ksmall and real, as a mix term should be
end132k432kprofit, Apr24-Sep24

Price did nothing, mix did little, volume was seasonal and moved the same way on both groups. Cost is the term, and it is confined to two servers. known_events has a candidate dated 2024-03-01. The bridge does not prove the event caused the cost; it proves that whatever happened was a cost change and not a demand change, which rules out most of the explanations the room will offer.

Live Let the data date the break 4 min▶

The bridge needs you to choose the periods, and choosing them with the event date in hand is a small circularity. So run a detector that has never heard of March 2024: the month-over-month change in margin per server, standardised against the trailing twelve months of its own changes, shifted one month so the month under test does not sit in its own baseline. A step shows as one large negative z followed by nothing, because after the step the changes are small again.

★ Rolling z-score of the monthly margin change, every serverpiv = srv.pivot(index="month", columns="server_id", values="margin") dz = piv.diff() z = (dz - dz.rolling(12, min_periods=6).mean().shift(1)) / dz.rolling(12, min_periods=6).std().shift(1) hits = z.stack().reset_index(); hits.columns = ["month", "server_id", "z"] top = hits.sort_values("z").head(6) top.to_csv(lib.REPORTS / "a2_changepoints.csv", index=False) fig, ax = plt.subplots(figsize=(13, 4)) for s in servers: ax.plot(z.index.to_timestamp(), z[s], color=lib.AMBER if s in lib.KEY_SERVERS else lib.FAINT, lw=1.6 if s in lib.KEY_SERVERS else 1) ax.axhline(-3, color=lib.RED, ls=":", lw=1) lib.save(fig, "a2_changepoint")
Line chart of rolling z-scores of monthly margin change for ten servers from 2020 to 2026. Eight faint lines wobble between plus and minus two. Two amber lines, S07 and S08, spike to about minus 7 and minus 6 in March 2024 and return to the noise band.
🔍 Click to zoom - S07 at -7.3 and S08 at -5.9 in 2024-03. The next hit is S02 in 2021-03 at -3.1, the duplicate-load half-year a1 found; then nothing below -2.5.

The detector is validated on its nulls as much as its hits. Eight servers took no decision, and eight servers produce no spike beyond noise - that is what makes the two amber spikes evidence rather than decoration. A detector that fires everywhere is a random number generator with a title. The one extra hit it does raise, S02 in 2021-03, is the duplicate-load defect from a1 leaking into margin, which is a second correct answer, not a false alarm.

Shift the baseline The .shift(1) is not cosmetic. Without it the month being tested is part of its own mean and standard deviation, a big step inflates its own baseline, and the z-score shrinks toward respectability. Detectors that include the observation in the null are the most common reason a real break scores only -2.
Self-study Read fifty raw rows 3 min▶

Before trusting a cost story, read the raw lines. Pull fifty rows from S07 after March 2024 and fifty from before, same SKUs where you can, and look at unit_cost with your own eyes. Ask what it actually is. In many warehouses "unit cost" is a live supplier cost that changes when the supplier does; in many others it is a standard cost re-allocated once a year by finance, and a step in it means an accounting decision, not a fulfilment one. The bridge cannot tell those apart. Fifty rows and one question to whoever owns the column usually can.

a1 already filed the answer for Printwell: cost is a unit constant per SKU in dim_product, so the 2024 step is a change to what the platform books per unit, and there is no fulfilment cost per order anywhere in the data. That is why c3 recommends collecting one. Reading rows is how you find out what your aggregate is made of; it takes ten minutes and it has saved more analyses than any model.

Part 4 · covers the counterfactual, the running gap, the export

Price the decision, then export 6 min live

A margin drop is an abstraction. A number of profit foregone is a decision-sized fact, and it needs exactly one stated assumption to compute: that the pre-shift margin would have held. Take the twelve months to February 2024 on S07 and S08, get their margin, apply it to every month of revenue since March 2024, and the gap between that counterfactual and actual profit is the running price of the decision.

Live The running price 4 min▶
★ Pre-shift margin applied to post-shift revenue; the gap accumulatespre12 = key[(key.month >= pd.Period("2023-03", "M")) & (key.month <= pd.Period("2024-02", "M"))] old_margin = pre12.profit.sum() / pre12.revenue.sum() post = key[key.month >= pd.Period("2024-03", "M")].copy() post["counterfactual"] = post.revenue * old_margin post["gap"] = post.counterfactual - post.profit post["cum_gap"] = post.gap.cumsum() fig, ax = plt.subplots(figsize=(12, 4.6)) ax.fill_between(post.month_ts, post.profit / 1e3, post.counterfactual / 1e3, color=lib.AMBER_50) ax.plot(post.month_ts, post.counterfactual / 1e3, color=lib.NAVY_SOFT, lw=2, ls="--", label=f"profit at the pre-shift margin ({old_margin*100:.1f}%)") ax.plot(post.month_ts, post.profit / 1e3, color=lib.AMBER, lw=2.2, label="actual profit") ax2 = ax.twinx(); ax2.plot(post.month_ts, post.cum_gap / 1e3, color=lib.INK, lw=1.4, ls=":", label="cumulative gap (right axis)") lib.save(fig, "a2_price_of_decision") price = {"old_margin_pct": round(old_margin * 100, 1), "cum_gap_k": round(post.gap.sum() / 1e3), "months": int(len(post)), "avg_gap_per_month_k": round(post.gap.mean() / 1e3, 1)} json.dump(price, open(lib.REPORTS / "a2_price_of_decision.json", "w"), indent=1)
Monthly profit on S07 plus S08 from March 2024 to August 2026. A dashed navy line shows profit at the pre-shift margin of 42.4 percent, an amber line shows actual profit below it, the gap is shaded amber, and a dotted line on the right axis climbs to about 251k.
🔍 Click to zoom - 251k of profit over 30 months at the pre-shift margin of 42.4 percent, 8.4k a month, still running.

State the assumption in the caption and the chart title, every time: this is what profit would have been had the margin held, and holding the margin is exactly the thing in question. Whether the cost change also cost customers is a different question with a different method - a5 tests it with cohorts and finds the repeat rate on these servers fell two quarters later - so the 251k is a floor, not an estimate of total damage, and the page says so. Revenue on the key servers went 686k in 2023 to 884k in 2024 to 765k in 2025; profit 290k to 224k between 2023 and 2025. Growth and loss at the same time is why nobody noticed.

Real world Finance teams price decisions this way every quarter under the name "rate/volume variance", and the counterfactual margin is always the argument. Pick a base period that is too short and one good month sets the bar; pick one that spans an earlier change and you price two decisions as one. Twelve months ending the month before the break is the conventional compromise, and the convention is worth following because it is the one the CFO will recognise.
Live Export for the widget with json.dumps, never string-built 2 min▶

The lookback widget on this page and on c1 draws real monthly numbers, and those numbers come from this notebook. Build a plain dict of lists per series, round the values so the file is small, and serialise with json.dumps. Never assemble JSON by string concatenation: one unescaped quote or a stray NaN produces a file that is invalid JavaScript, the widget silently renders nothing, and the page looks like the export never ran. A serialiser fails loudly at write time, which is where you want it to fail.

★ One dict, one json.dumps, one file the pages loadexport = {} for s in ["S07", "S08", "S03", "S01"]: d = srv[srv.server_id == s] export[s] = {"months": [str(m) for m in d.month], "revenue": [round(v) for v in d.revenue], "profit": [round(v) for v in d.profit], "orders": [int(v) for v in d.orders], "margin": [round(v * 100, 1) for v in d.margin]} export["platform"] = {"months": [str(m) for m in plat.month], "revenue": [round(v) for v in plat.revenue], "profit": [round(v) for v in plat.profit], "orders": [int(v) for v in plat.orders], "margin": [round(v * 100, 1) for v in plat.margin]} (lib.ROOT / "assets" / "diagnosis-data.js").write_text("window.DIAG_SERIES = " + json.dumps(export) + ";\n")

The same cell writes reports/a2_server_year_summary.csv, the server-by-year table c2 reads. Every number a chairman page quotes traces back to a file a notebook wrote, so a rerun of the notebook updates the page and a retyped number is impossible.

Build-along

Rebuild the margin notebook ★ 20 min · everyone builds

Open notebooks/a2_follow_the_margin.ipynb, run it top to bottom once, then rebuild each section against the shared reader. The data is seeded, so your bridge lands on the same -54k.

Load deduplicated, drop the partial month. lib.load_transactions(dedupe=True), filter month < lib.AS_OF. Print how many complete months you are charting.

The whole platform. lib.monthly(txc), revenue and profit above, margin below, lib.annotate_events on the top panel. Look at it and say out loud what a chairman would conclude.

Un-blend. lib.monthly(txc, ["server_id"]), a 2x5 grid with sharey=True and a dashed line at 2024-03 on every panel. Key servers amber.

Index and window. Orders, revenue, profit indexed to Sep23-Feb24 = 100. Then the S07 margin as of 2024-08 through 3, 12 and 60 months with shared y-limits.

Bridge both groups. lib.margin_bridge on Sep23-Feb24 versus Apr24-Sep24 for key servers and the rest. Save the frame to reports/. Check that mix is small and non-zero.

Change-point. Pivot margin by server, diff, rolling z with the baseline shifted one month. Print the six most negative and confirm the nulls stay in the noise band.

Price and export. Pre-shift margin on the twelve months to 2024-02, counterfactual profit, cumulative gap, JSON to reports/. Then json.dumps the series to assets/diagnosis-data.js and reload this page: the widget should draw.

The check that keeps you honest Rerun python3 analysis/nbgen.py notebooks/src/a2_follow_the_margin.py. A failed run leaves no notebook and no figures behind, so a stale bridge can never sit under a fresh title. Then open the widget and pick S03: if it shows a step in 2024, your export or your dedupe is wrong, because S03 took no decision.
Homework

Try it yourself - this week ◐ 45 min

Sources covered

What this session draws on

Margin analysis is standard FP&A and time-series craft rather than a certified course. The working core drawn on here:

✓pandas user guide - groupby on PeriodIndex, resample, pivot, diff, rolling with min_periods and shiftParts 1-3 · lib.monthly and the change-point cell
✓Price-volume-mix analysis as taught in FP&A - rate/volume variance, the counterfactual base periodParts 3-4 · lib.margin_bridge, the price of the decision
✓Change-point detection via rolling z-scores - the simplest detector that can be validated on its nullsPart 3 · one large negative z followed by nothing
✓Cole Nussbaumer Knaflic, Storytelling with Data - small multiples, shared axes, one colour for the thing that mattersPart 1 · the 2x5 grid and the amber key servers
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · A colleague's bridge uses average price per unit and average cost per unit for each period. Their mix term is exactly 0 on every server. What does that tell you?

A blended-rate bridge cannot produce a mix term; the remainder is identically zero. Per-SKU rates over the shared SKUs, with mix as the remainder, is what makes mix a measurement. On S07 and S08 it comes out +5k: small, non-zero, believable.

2 · Your change-point detector fires at -7.3 on S07 in 2024-03. Before you present it, what do you need to have looked at?

A detector validated only on its hits is worth nothing: every method finds the event you already know about. The null servers staying inside the noise band is the evidence. Printwell's do, and the one extra hit, S02 in 2021-03, has a known cause.

3 · The data runs to 2026-09-07. Why does every monthly chart stop at August 2026?

A partial month is a fraction of a month dressed as a whole one. Filtering on month < AS_OF in the shared reader means every notebook drops it the same way and no chart ends in a fake cliff.

Analyst session 2 cheat sheet · pin this

Whole, then un-blendDraw the platform first so the reader knows it is not on fire. Then small multiples by the unit that took the decision, shared axes.
Never chart the partial monthFilter month < AS_OF. A week of orders next to full months is a fake cliff.
Full history first, then windowThree months is a wobble, twelve is a step, sixty is a cliff. Same numbers, same axis. Only the long one can tell step from noise.
Overlay what you knowlib.annotate_events on every long series. The team's memory is data; the line marks a date, the bridge names the term.
Decompose before you narrateProfit change = volume + price + cost + mix. Per-SKU rates over shared SKUs; mix is the remainder. Blended rates make mix zero by algebra.
The Printwell bridgeS07 + S08, Sep23-Feb24 to Apr24-Sep24: volume -18k, price +6k, cost -54k, mix +5k. Other servers: cost exactly 0.
Validate the detector on its nullsRolling z of monthly change, baseline shifted one month. S07 -7.3, S08 -5.9 in 2024-03; eight null servers stay in the band.
Price with a stated assumptionPre-shift margin 42.4 percent on post-shift revenue: 251k over 30 months, 8.4k a month. A floor, not the total; a5 tests the retention half.