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

Map the data before you read it

Five analyst sessions, one running artifact: a five-year diagnosis of the Printwell platform that ends in a chairman deck. Before a single trend chart, today builds the map: what tables exist, at what grain, for which months, joined how, and where they lie. You will produce the data-availability heatmap, the key-integrity report and the defect scan that every later chart stands on - and discover that two of the nine questions the chairman asked cannot be answered by any table the company has.

🟢 Analyst track Data analysts · pandas Notebook: a1_data_map.ipynb Start here
0-3 · Welcome 3-16 · Grain + coverage 16-40 · Build the map 40-45 · Q&A
Part 0

Where this track goes

Today (a1) maps the data. a2 follows the margin through five years of transactions and names the mechanism behind the 2024 break. a3 walks the product hierarchy and the baskets. a4 measures merchants and stores - acquisition, survival, dormancy. a5 does customer retention with frozen history, the promotion double edge, the two-month login funnel, and assembles the canon numbers the chairman pages quote. Everything runs on the repo's seeded synthetic dataset (data/generate_data.py, planted truths documented at the top of the file) through one shared reader, analysis/lib.py, so a schema change lands in exactly one place. Work along in notebooks/a1_data_map.ipynb.

Live - presented in session Self-study - read after class Sources covered
★ What you walk out with today A coverage heatmap that says which table can answer which question and until when. A key-integrity table by year that shows two identity gaps growing. A defect scan that names a bad load by server and half-year. And the question-availability table that turns "we cannot answer that" from a shrug into a data-collection recommendation with a date on it.
Part 1 · covers grain, hierarchy and the join map

Say the grain, draw the joins 7 min live

One row of fact_transaction is an order LINE: one SKU inside one order. Revenue, cost and profit are additive at the line. Orders are distinct txn_id. Customers are distinct customer_id with blanks excluded, because guest checkout leaves the field empty. Every chart states its grain in the title, because most dashboard disagreements are two people counting different things and calling them the same name.

The join map - one fact, six dimensions, two loners fact_transaction grain: order line · 2020-2026 dim_server dim_merchant dim_store dim_product dim_customer dim_promotion server_id merchant_id store_id sku_id customer_id(blank = guest) promo_id(orphans pre-2025) fact_login 2026-07 onward, on customer_id known_events 9 remembered rows, no key - dates only revenue, cost, profit, qty, unit_price, unit_cost, channel, ts
🔍 Click to zoom - the join map. Two tables stand apart: one starts too late, one has no key at all.
Live One reader, one truth 3 min▶

Every notebook in the track imports the same module. Nothing in notebooks/ parses a CSV on its own. This is not tidiness; it is how five notebooks stay consistent when a column is renamed or a dedupe rule changes - two scripts parsing the same file with their own logic are two sources of truth wearing a costume.

★ Load everything through the shared readerimport sys sys.path.insert(0, "../analysis") import lib lib.style() # the course palette for every chart tx = lib.load_transactions() # raw today: we want to SEE the defect first lg = lib.load_logins() dims = lib.load_dims() # server, merchant, store, customer, product, promotion events = lib.load_known_events() print(f"{len(tx):,} lines | {tx.txn_id.nunique():,} orders | {tx.ts.min().date()} .. {tx.ts.max().date()}")

Later sessions load with dedupe=True. Today you load raw on purpose, so the duplicate defect shows up in your own scan instead of being silently removed by a flag you did not know existed.

Live The grain table 4 min▶

Write the grain table before the first chart. It is the single page a new analyst reads to avoid every classic mistake on this dataset.

TableGrainDate columnKeys out
fact_transactionorder linetsserver, merchant, store, customer, sku, promo
fact_loginlogin sessionlogin_tscustomer, server, merchant
dim_promotioncampaignstart_datemerchant (optional)
dim_customercustomersignup_monthstore, merchant, server
dim_storestoreopen_monthmerchant, server
dim_merchantmerchantonboard_monthserver
dim_productSKU-merchant (when owner = merchant)
known_eventsremembered eventevent_datescope text, no key
Real world A retailer's finance and marketing teams reported repeat-purchase rates of 31 percent and 44 percent for the same quarter. Both were right: finance counted orders per customer id, marketing counted sessions per device. Neither had written the grain down. The reconciliation took three weeks; the grain table takes twenty minutes.
Self-study The owner axis on products 3 min▶

dim_product carries an owner column: platform for the 98 standard SKUs and merchant for the 1,075 customised SKUs each merchant created from the platform's lines. Same category, same product line, different SKU, different price and cost. This axis is what makes the customisation question in a3 and a5 answerable at all - if the platform had stored customised orders under the base SKU, the finding that customised goods retain customers better could never have been computed. When you design a catalogue, keep the base line and the owner separate, and never overwrite one with the other.

Part 2 · covers the availability heatmap

Coverage before analysis 6 min live

The single most important chart before any analysis: rows per table per month, as a presence heatmap. A five-year question can only be answered by a table with five years. Two of Printwell's tables cannot carry the chairman's questions, and it is far better to know that in the first hour than in the last slide.

Live Build the coverage heatmap 6 min▶
★ Rows per table per month - the shared helper does the reindexingframes = { "fact_transaction": tx.assign(d=tx.ts), "fact_login": lg.assign(d=lg.login_ts), "dim_promotion": dims["promotion"].assign(d=pd.to_datetime(dims["promotion"].start_date)), "dim_customer (signups)": dims["customer"].assign(d=pd.to_datetime(dims["customer"].signup_month)), "dim_merchant (onboardings)": dims["merchant"].assign(d=pd.to_datetime(dims["merchant"].onboard_month)), "dim_store (openings)": dims["store"].assign(d=pd.to_datetime(dims["store"].open_month)), "known_events": events.assign(d=events.event_date), } cov = lib.coverage(frames, {k: "d" for k in frames}) # months x tables, zero-filled to 2020-01 .. today present = (cov > 0).T.astype(int)
Heatmap of data availability by month for seven tables from 2020 to 2026. fact_transaction and dim_customer cover 79 of 81 months; fact_login covers 3 of 81; dim_promotion covers 21 of 81 starting January 2025; known_events has 9 scattered months.
🔍 Click to zoom - navy means the table has rows that month. Two rows are mostly grey.

fact_login has about two months: login analytics was switched on 2026-07-01. dim_promotion starts 2025-01 even though order lines have carried a promo_id since 2020 - the promotion module launched late, and every earlier id points at nothing. Neither gap is dropped; both are filed as data-collection recommendations with a date, in c3.

Read the sparse rows honestly Merchant onboardings and store openings are sparse in 2024-2026 not because the tables are broken but because acquisition slowed. A coverage heatmap shows presence, not health; distinguishing the two is the first judgement call of the analysis, and a4 makes it with the acquisition chart.
Self-study Presence is not completeness 3 min▶

A month can have rows and still be incomplete: a table that was backfilled for the last 18 months only, a column added in 2023 and null before, a server whose feed dropped for a week. Three cheap follow-ups after the heatmap:

  • Rows per month per server (or whatever your unit is): a unit that vanishes for a month is a feed outage, and it will look like a demand collapse in every later chart.
  • Null share per column per year: a column that is 100 percent null before a date has a start date, and every analysis using it inherits that date.
  • Row count against a trusted external total (finance's booked revenue per quarter): the reconciliation catches both duplicates and missing loads in one pass.
Part 3 · covers key integrity + the defect scan

Do the joins hold, and where does the data lie? 9 min live

Integrity by year, not overall. A join that resolves 95 percent of rows overall can resolve 100 percent in 2020 and 80 percent in 2026, and the trend is the finding. Then an exact-duplicate scan by unit and period: a duplicated order line is a load defect, not two sales, and it names itself when you cut the right way.

Live Key integrity by year 4 min▶
★ Share of lines whose key resolves, per yeardef resolvable(col, dim, key): ok = tx[col].isin(set(dim[key])) return tx.assign(ok=ok).groupby("year").ok.mean() integrity = pd.DataFrame({ "merchant_id -> dim_merchant": resolvable("merchant_id", dims["merchant"], "merchant_id"), "store_id -> dim_store": resolvable("store_id", dims["store"], "store_id"), "sku_id -> dim_product": resolvable("sku_id", dims["product"], "sku_id"), "customer_id present (not guest)": tx.assign(ok=tx.customer_id != "").groupby("year").ok.mean(), "promo_id resolvable (of lines with a promo)": tx[tx.promo_id != ""].assign( ok=lambda d: d.promo_id.isin(set(dims["promotion"].promo_id))).groupby("year").ok.mean(), }).round(3)
Bar chart per year 2020 to 2026. Guest checkout share of order lines rises from about 6 percent to about 12 percent. Share of promo-tagged lines whose promo id has no promotion row is 100 percent through 2024 and near zero from 2025.
🔍 Click to zoom - two identity gaps: one growing every year, one that ends abruptly when a module launched.

Guest checkout rises from 6 percent of lines in 2020 to 12 percent in 2026. Those orders have revenue but no customer, so every retention number in a5 silently excludes a growing slice - and the chart says so in its title. Promotion ids before 2025 are orphans: the effect of a 2022 campaign on retention is unknowable from this data, however good the analyst.

Live The duplicate scan names the defect 5 min▶
★ Exact duplicates by server and half-yearkey = ["txn_id", "sku_id", "qty", "unit_price", "unit_cost"] tx["dup"] = tx.duplicated(subset=key, keep="first") tx["half"] = tx.ts.dt.year.astype(str) + "-H" + ((tx.ts.dt.month > 6) + 1).astype(str) dup = tx.groupby(["server_id", "half"]).dup.mean().unstack(fill_value=0) * 100 hot = [(s, h, round(v, 2)) for (s, h), v in dup.stack().items() if v > 0.5]
Heatmap of exact duplicate order line percentage by server and half-year. Every cell is near zero except S02 in 2021 H1 at about 3.4 percent.
🔍 Click to zoom - one hot cell. It matches the order-database migration in known_events to the half-year.

S02 in 2021-H1 carries about 3.4 percent duplicated lines - exactly the window of the order-database migration the team remembers. Raw revenue for that server and half-year is overstated by the same amount. From a2 onward every notebook loads with dedupe=True; the rule lives once, in the shared reader.

Real world Duplicate writes during a migration are the most common load defect in commerce data and the least often caught, because totals still look plausible. A 3 percent inflation on one region for six months is invisible in a company total and glaring in a server-by-half-year grid. The cut is the detector.
Self-study Which questions this data can answer, and until when 4 min▶

The last output of a1 is a table the chairman can read: question, tables needed, answerable span, verdict. It converts "we cannot answer that" from a shrug into a recommendation with a shape.

QuestionNeedsAnswerable spanVerdict
Revenue, cost, margin trend by server, merchant, productfact_transaction2020-01 to 2026-09yes, full history
Customer cohort retentionfact_transaction, customer_idfull, minus guestsyes, with a growing blind spot
Login-to-purchase funnelfact_login2026-07 to 2026-09two months - no trend, no season
Did a promotion type lift baskets or retention?fact_transaction x dim_promotion2025-01 onward20 months; earlier ids orphaned
Merchant and store acquisition and survivaldims + fact_transactionfullyes
What changed on product or infra when numbers moved?known_events, 9 rowssparseno - there is no event log
Fulfilment cost per order--no - cost is a unit constant per SKU

The last two rows are the two most valuable recommendations in c3, and they were found in the first hour of the analysis, not the last.

Build-along

Build the data map in the notebook ★ 22 min · everyone builds

Open notebooks/a1_data_map.ipynb and run it top to bottom once, then rebuild each section yourself against the shared reader. The repo data is seeded, so your numbers match the page's.

Load through lib, raw. Print shapes and spans for every table. Write the grain table as a DataFrame before touching a chart.

Coverage. Assemble the frames dict with one date column per table, call lib.coverage, draw the presence heatmap. Annotate the sparse rows with "N of 81 months".

Integrity by year. Resolve every foreign key against its dimension per year. Chart the two that move: guest share and orphan promo share.

Duplicate scan. Exact duplicates on the natural line key, cut by server and half-year. Print the hot cells above 0.5 percent and match them to known_events.

The question table. For each of the chairman's nine questions, name the tables, the answerable span and the verdict. Save it to reports/: c3 reads it.

Hand-off note. Four lines at the end of the notebook: dedupe on, guests excluded and stated, short windows caveated, events overlaid. a2 starts from this note.

The check that keeps you honest Rerun python3 analysis/nbgen.py notebooks/src/a1_data_map.py. A failed run leaves no notebook behind, so a stale passing notebook can never masquerade as a fresh one. Verifiers must be able to fail visibly or they are decoration.
Homework

Try it yourself - this week ◐ 45 min

Sources covered

What this session draws on

Data mapping is standard warehouse craft rather than a certified course. This page teaches the working core of:

✓Kimball Group - dimensional modelling: declare the grain, conformed dimensionsPart 1 · grain table and join map
✓pandas user guide - groupby, duplicated, isin, reindex on PeriodIndexParts 2-3 · every snippet on the page
✓Great Expectations / dbt tests - the vocabulary of not-null, unique and relationship checksPart 3 · integrity by year as a trend, not a gate
◐ydata-profiling - automated column profilesNamed in self-study; the hand-built heatmap is the teaching
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · The chairman asks how the 2022 Q4 promotion affected repeat purchases. The promo table starts 2025-01. What do you say?

A question a table cannot answer is filed, not fudged. The orphaned ids are a finding about the company's data ownership, and c3 turns it into a recommendation. Extrapolating or proxying would produce a number that looks like an answer and is not.

2 · Overall, 92 percent of order lines carry a customer id. Why cut it by year before moving on?

An overall integrity number hides a trend. A blind spot that grows every year means every later retention chart is a little more wrong than the one before, and the chart title has to say so.

3 · Your duplicate scan shows 3.4 percent exact-duplicate lines on S02 in 2021-H1 and near zero everywhere else. What is the right next step?

The defect gets a cause (the migration in known_events), a single rule in one reader, and a note in the hand-off. Editing the raw file destroys the evidence; ignoring it overstates one server's revenue for a half-year in every later chart.

Analyst session 1 cheat sheet · pin this

Say the grainOrder line: revenue sums. Orders: distinct txn_id. Customers: distinct customer_id, blanks excluded. Title every chart with it.
One readerEvery notebook imports analysis/lib.py. No notebook parses a CSV. A schema change lands in one file.
Coverage firstRows per table per month as a presence heatmap. A five-year question needs a five-year table.
Presence is not completenessFollow up with rows per unit per month, null share per column per year, and a reconciliation to finance.
Integrity by yearResolve every key per year. The trend is the finding: guest share 6 to 12 percent; promo ids orphaned before 2025.
The duplicate scanExact duplicates on the natural key, cut by unit and half-year. Hot cells get a cause from known events and one dedupe rule in lib.
File, never fudgeA question no table can answer becomes a collection recommendation with a date, not a proxy that looks like an answer.
Verifiers must fail visiblyA failed notebook run leaves nothing behind. A stale passing file that survives a failure is worse than no check.