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

Products and baskets

Printwell sells five categories, fifteen product lines and 1,121 SKUs, and every SKU has an owner: a platform standard or a merchant's customised version of the same line. Today you walk that hierarchy top to bottom - which categories shifted share, which lines faded, how few SKUs make most of the profit, and whether customisation pays - then turn to the basket. Support, confidence and lift are three divisions in pandas, no library needed, and the gap between an attach rate and a base rate is a bundle nobody is selling on purpose. You finish with the table a merchant success team hands to every merchant: its top line and how broad its sales really are.

🟡 Analyst track Data analysts · pandas Notebook: a3_products_and_baskets.ipynb 45 min
0-3 · Welcome 3-18 · Hierarchy and mix 18-40 · Baskets and preference 40-45 · Q&A
Part 0

Where a3 sits

a1 mapped the tables and a2 followed the margin to a cost decision on two servers. a3 changes axis: from when and where to what. The product hierarchy answers the third of the nine angles - which categories, lines and SKUs grew or faded, and whether merchant-customised goods out-earn the platform's standards - and the basket work answers most of the ninth: pair lift, bundle candidates, cross-sell headroom and per-merchant preference. a4 measures merchants and stores; a5 does customers, promotions and the gaps. Same reader, same dedupe, same rule about the partial month. Work along in notebooks/a3_products_and_baskets.ipynb.

Live - presented in session Self-study - read after class Sources covered
★ What you walk out with today A share-of-revenue chart that separates "we grew" from "we shifted". A slope chart that names the lines that lost a fifth of their share. A Pareto curve and an owner table showing merchant SKUs at 44.9 percent margin against the platform's 36.6. A lift matrix and a bundle-candidate file made from three pandas divisions. An attach-rate chart whose gaps are the cross-sell offers. And a preference profile per merchant: top line, its share, and an effective number of lines sold.
Part 1 · covers share of revenue, slope charts, Pareto, the owner axis, the price ladder

Category, line, SKU - and the owner axis 15 min live

Three levels down and then a fork. Category holds five values, product line fifteen, SKU 1,121 sold. At the leaf, owner says whether a SKU is one of the 98 platform standards or one of the 1,023 merchant-customised versions sold - same category, same line, different SKU, different price and cost. Analyse the levels in order, because a category total hides a line that is dying and a line total hides which owner is earning.

The hierarchy - three levels, then an owner split at the leaf category (5) product line (15) SKU (1,121 sold) Drinkware Mug Bottle platform SKU merchant SKU merchant SKU platform SKU merchant SKU owner axis: 98 platform SKUs and 1,023 merchant-customised SKUs sold, 2020-2026 Same line, different SKU, price and cost. Store base line and owner as two columns; never overwrite one.
🔍 Click to zoom - the owner axis is the fork that makes the customisation question answerable at all. Lose it in the catalogue and no analyst can recover it.
Live Share, not absolute 3 min▶

Revenue grew almost every year, so an absolute stacked chart of categories is five bands all getting taller and a reader who learns nothing. Divide each year by its total. Now the chart answers a different question - not "did we grow" but "did we shift" - and the shifts are the finding: drinkware and home spiked in 2020, bags rose steadily, stationery has faded since 2023. Use complete years only; 2026 is eight months and its share would be a blend of the wrong seasons.

★ Revenue share by category, complete yearstx = lib.load_transactions(dedupe=True) dims = lib.load_dims() txc = tx[tx.month < pd.Period(lib.AS_OF, "M")] full_years = txc[txc.year.between(2020, 2025)] share = full_years.groupby(["year", "category"]).revenue.sum().unstack() share = share.div(share.sum(axis=1), axis=0) * 100 # each year sums to 100 order = share.loc[2025].sort_values(ascending=False).index fig, ax = plt.subplots(figsize=(11, 5)) ax.stackplot(share.index, [share[c] for c in order], labels=order, colors=lib.SEQ[:5], alpha=.95) ax.set_xticks(share.index); ax.set_ylim(0, 100) lib.save(fig, "a3_category_share") share.round(1)
Stacked area of revenue share by category, 2020 to 2025, each year summing to 100. Drinkware falls from 25.9 to 16.8 percent and home from 32.1 to 25.0; bags rise from 12.0 to 26.9; apparel holds near 26; stationery slips from 6.3 in 2023 to 4.9.
🔍 Click to zoom - bags 12.0 to 26.9 percent of revenue between 2020 and 2025; drinkware 25.9 to 16.8; stationery 6.3 in 2023 to 4.9 in 2025.
YearApparelBagsDrinkwareHomeStationery
202024.512.025.932.15.5
202129.214.720.030.25.8
202228.319.118.128.16.4
202327.321.217.927.36.3
202427.124.317.525.95.2
202526.426.916.825.04.9
Real world A consumer brand celebrated a home-goods category "growing every year" for three years while its share of the business fell by a third. The absolute chart was true and the share chart was the story. Both belong in the deck, in that order: absolute so nobody thinks the category shrank, share so nobody thinks it is still the future.
Live Lines that fell 3 min▶

One level down, fifteen lines are too many for a stacked area and exactly right for a slope chart: each line's share of revenue in 2021 on the left, in 2025 on the right, one segment between. Colour only the lines that lost more than a fifth of their share. The eye reads the amber slopes in a second and ignores the rest, which is what a slope chart is for. Two full years four years apart avoids both the 2020 spike and the partial 2026.

★ Slope chart per product line, amber where share fell by a fifthln = full_years[full_years.year.isin([2021, 2025])].groupby(["year", "product_line"]).revenue.sum().unstack(0) ln = ln.div(ln.sum()) * 100 fig, ax = plt.subplots(figsize=(8, 7)) for line, r in ln.iterrows(): c = lib.AMBER if r[2025] < r[2021] * 0.8 else lib.NAVY ax.plot([0, 1], [r[2021], r[2025]], color=c, lw=2, marker="o") ax.text(-0.03, r[2021], f"{line} {r[2021]:.1f}%", ha="right", va="center", fontsize=9) ax.text(1.03, r[2025], f"{r[2025]:.1f}%", ha="left", va="center", fontsize=9) ax.set_xticks([0, 1]); ax.set_xticklabels(["2021", "2025"]); ax.set_xlim(-0.6, 1.3) ax.grid(False) lib.save(fig, "a3_line_slope")
Slope chart of fifteen product lines, share of revenue in 2021 on the left and 2025 on the right. Most segments are navy and near flat. The drinkware and stationery lines slope down in amber, having lost more than a fifth of their share; bag lines slope up.
🔍 Click to zoom - amber lines lost more than a fifth of their share between 2021 and 2025. Every label is on the chart, so there is no legend to decode.
Live Where profit concentrates 3 min▶

Rank every SKU sold by total profit, accumulate, and plot cumulative profit share against cumulative SKU share. The curve bends hard: a small share of SKUs carries 80 percent of profit, and the long tail is hundreds of SKUs that each earn almost nothing. A Pareto curve is a catalogue decision waiting to be made - which SKUs to feature, which to leave to merchants, which to retire - and it is also the reason category totals mislead: a category can look healthy on the strength of three SKUs.

★ Cumulative profit share by SKU rank, plus the owner splitsku = txc.groupby(["sku_id", "owner"]).agg(profit=("profit", "sum"), revenue=("revenue", "sum"), units=("qty", "sum")).reset_index() sku = sku.sort_values("profit", ascending=False).reset_index(drop=True) sku["cum_profit"] = sku.profit.cumsum() / sku.profit.sum() * 100 sku["cum_skus"] = (sku.index + 1) / len(sku) * 100 p80 = sku[sku.cum_profit >= 80].cum_skus.iloc[0] fig, ax = plt.subplots(figsize=(10, 5)) ax.plot(sku.cum_skus, sku.cum_profit, color=lib.NAVY, lw=2.2) ax.axhline(80, color=lib.FAINT, ls="--"); ax.axvline(p80, color=lib.AMBER, ls="--") lib.save(fig, "a3_pareto") sku.groupby("owner").agg(skus=("sku_id", "count"), profit=("profit", "sum"), revenue=("revenue", "sum")) \ .assign(margin=lambda d: (d.profit / d.revenue).round(3), profit_share=lambda d: (d.profit / d.profit.sum()).round(3))
Pareto curve of cumulative profit share against cumulative share of SKUs ranked by profit, 2020 to 2026. The curve rises steeply then flattens; a dashed amber vertical marks the SKU share at which cumulative profit crosses 80 percent.
🔍 Click to zoom - the amber line is where 80 percent of profit is reached. Everything to its right is the long tail.
OwnerSKUs soldMarginShare of profit
platform9836.6%52.5%
merchant1,02344.9%47.5%

Read the owner table twice. Ninety-eight platform SKUs make just over half the profit: they are the volume. The 1,023 merchant SKUs make just under half at a margin 8.3 points higher: they are the rate. Neither is the "better" half; the platform runs on both.

Live Platform vs merchant SKUs 3 min▶

Customisation is Printwell's thesis: merchants make their own versions of the platform's lines and the platform earns more on them. The data is allowed to say whether the thesis pays. Margin by owner per complete year on the left; share of revenue on merchant SKUs per server on the right, key servers amber. The margin gap holds in every year, so it is a property of the goods, not of one good year.

★ Margin by owner per year, customised share per serverown = full_years.groupby(["year", "owner"]).agg(revenue=("revenue", "sum"), profit=("profit", "sum")).reset_index() own["margin"] = own.profit / own.revenue * 100 srv_share = txc.groupby(["server_id", "owner"]).revenue.sum().unstack() srv_share = srv_share["merchant"] / srv_share.sum(axis=1) * 100 fig, axes = plt.subplots(1, 2, figsize=(14, 4.6)) for o, c in [("platform", lib.NAVY), ("merchant", lib.AMBER)]: d = own[own.owner == o] axes[0].plot(d.year, d.margin, color=c, lw=2.2, marker="o", label=f"{o} SKUs") axes[0].set_ylim(25, 55); axes[0].legend() axes[1].bar(srv_share.index, srv_share.values, color=[lib.server_color(s) for s in srv_share.index]) lib.save(fig, "a3_owner_margin")
Two panels. Left: margin percent by year 2020 to 2025 for platform SKUs in navy and merchant SKUs in amber; the amber line sits 8 to 10 points above the navy line in every year. Right: bars of the share of revenue on merchant-customised SKUs per server, S07 and S08 in amber.
🔍 Click to zoom - merchant SKUs earn 8-10 margin points more than platform SKUs, every year. a5 asks whether their customers also come back more; they do.
Keep owner out of the line The whole comparison exists because dim_product stores product_line and owner as two columns. A catalogue that files a merchant's mug as its own line, or overwrites the base SKU with the customised one, destroys both charts on this card and every retention cut in a5. When you design a product table, the base line and who owns the variant are different facts; store both.
Self-study The price ladder is the upsell 3 min▶

For the three biggest lines, plot every SKU's list price, platform on the left and merchant on the right. Merchant SKUs sit one rung above platform SKUs on every line: the customised version of a mug costs more than the standard mug, and that rung is the upsell path a store can walk a customer up. Where a line has no merchant SKUs, there is no rung and nothing to upsell to, which is a merchant-success conversation rather than an analytics one. Read the ladder alongside the owner-margin chart: the higher price and the higher margin are the same fact seen from two sides.

Three panels, one per top product line, each scattering SKU list prices in two columns: platform SKUs in navy on the left and merchant SKUs in amber on the right. In every panel the amber cloud sits above the navy one.
🔍 Click to zoom - merchant SKUs one price rung above platform SKUs on every line. The rung is the upsell.
Part 2 · covers support, confidence, lift, attach rates, basket size

Baskets: support, confidence, lift in pandas 14 min live

What sits together in an order? Three measures answer it and all three are ratios of counts. Support: how often the pair appears at all. Confidence: given the first item, how often the second. Lift: confidence against the second item's base rate - how much more often than chance. At the product-line level on 145,640 orders, the strongest pairs top out around 1.5: notebook + sticker pack, sticker pack + pen set, poster + cushion, then mug + t-shirt and t-shirt + tote near 1.1. Modest lifts, real supports. That is what a bundle candidate looks like in commerce data.

Three measures, one pair - Notebook + Sticker pack, all orders 2020-2026 support orders with A and B / all orders 1,692 / 145,640 = 1.2% how often the pair happens at all - the volume behind it confidence support / p(A) = orders with both / with A of Notebook orders, 14.2% hold a Sticker pack given the first item, how often the second - the offer lift confidence / p(B) = support / (p(A) x p(B)) 14.2 / 9.2 (base rate of B) = 1.54x chance how much more often than independence predicts Lift on a handful of orders is noise: lib.pair_lift drops pairs under min_support = 0.002 before ranking anything.
🔍 Click to zoom - three ratios of counts. Support is the volume, confidence is the offer, lift is the surprise. Rank by lift, but only among pairs with support.
Live pair_lift without a library 6 min▶

Self-join the order-line table on txn_id, keep each unordered pair once with a string comparison, count distinct orders per pair, and divide. That is the whole algorithm at pair level, and doing it by hand once means you will never again read a lift table without knowing exactly what the denominators are. min_support comes first, before ranking: a pair that appears in twelve orders can have a lift of 9 by luck, and it will sit at the top of any table sorted by lift. The 0.002 floor means a pair must appear in at least a fifth of a percent of orders before its lift is allowed to speak.

★ lib.pair_lift - support, confidence, lift in a dozen lines of pandasdef pair_lift(tx, level="product_line", min_support=0.002): b = tx[["txn_id", level]].drop_duplicates() # one row per order x line n = b.txn_id.nunique() item_p = b[level].value_counts() / n # p(A) for every line m = b.merge(b, on="txn_id") m = m[m[f"{level}_x"] < m[f"{level}_y"]] # each unordered pair once pairs = m.groupby([f"{level}_x", f"{level}_y"]).txn_id.nunique().rename("n").reset_index() pairs["support"] = pairs.n / n pairs = pairs[pairs.support >= min_support] # the floor comes BEFORE the ranking pairs["conf_x_to_y"] = pairs.support / pairs[f"{level}_x"].map(item_p) pairs["lift"] = pairs.support / (pairs[f"{level}_x"].map(item_p) * pairs[f"{level}_y"].map(item_p)) return pairs.sort_values("lift", ascending=False).reset_index(drop=True) pairs = lib.pair_lift(txc, "product_line", min_support=0.002) pairs.to_csv(lib.REPORTS / "a3_bundle_candidates.csv", index=False) pairs.head(8).round(3)
A fifteen by fifteen heatmap of co-purchase lift between product lines on all orders, blue scale from 0.5 to 3. Most cells sit near 1. The cells labelled with numbers at or above 1.5 are Notebook with Sticker pack.
🔍 Click to zoom - numbers are printed only where lift reaches 1.5. At line level on five years of orders that is one cell each way; the rest of the signal is in the table.
PairOrders with bothSupportConfidence x to yLift
Notebook + Sticker pack1,6920.0120.1421.54
Pen set + Sticker pack1,8410.0130.1381.49
Cushion + Poster2,1800.0150.1441.39
T-shirt + Tote2,6870.0180.1211.12
Mug + T-shirt2,8020.0190.1701.12
Notebook + Pen set9250.0060.0780.85

Read the table with the pair the data planted in mind: the stationery trio co-occurs above chance as two pairs, notebook + sticker pack and sticker pack + pen set, while notebook + pen set sits below 1. Lift is pairwise; a three-item bundle is not the sum of its pairs, and the honest bundle offer here is "sticker pack with either", not a trio.

Live Attach rate vs base rate 5 min▶

Lift is the analyst's number. The attach rate is the merchandiser's: given a mug is in the basket, how often is a t-shirt? Draw it next to the base rate of t-shirts in any basket and the gap is the bundle nobody is selling on purpose - customers are already assembling it more often than chance, without a prompt, a discount or a "goes well with" tile. Six pairs, two bars each.

★ Attach rate given the first item, against the second item's base rateb = txc[["txn_id", "product_line"]].drop_duplicates() n = b.txn_id.nunique() base_rate = b.product_line.value_counts() / n * 100 def attach(a, c): with_a = set(b[b.product_line == a].txn_id) return b[b.txn_id.isin(with_a) & (b.product_line == c)].txn_id.nunique() / len(with_a) * 100 combos = [("Mug", "T-shirt"), ("Poster", "Cushion"), ("Notebook", "Sticker pack"), ("Sticker pack", "Pen set"), ("Tote", "T-shirt"), ("Hoodie", "Cap")] att = pd.DataFrame([(f"{a} -> {c}", attach(a, c), base_rate[c]) for a, c in combos], columns=["pair", "attach %", "base rate %"]) fig, ax = plt.subplots(figsize=(11, 4.5)) x = np.arange(len(att)) ax.bar(x - 0.2, att["attach %"], 0.4, color=lib.AMBER, label="attach rate given the first item") ax.bar(x + 0.2, att["base rate %"], 0.4, color=lib.NAVY_SOFT, label="base rate of the second item") lib.save(fig, "a3_attach_rates") att.round(1)
Grouped bars for six product-line pairs. Amber bars are the attach rate given the first item, navy bars the base rate of the second item. Poster to Cushion 14.4 against 10.4, Notebook to Sticker pack 14.2 against 9.2, Sticker pack to Pen set 13.7 against 9.2, Mug to T-shirt 17.0 against 15.2, Tote to T-shirt 17.1 against 15.2. Hoodie to Cap is the one pair where the amber bar is below the navy: 7.6 against 10.6.
🔍 Click to zoom - five gaps in the right direction and one control that goes the other way. Hoodie + cap is the pair nobody assembles, and the chart says so.
Given the first itemAttach rateBase rate of the secondGap
Mug -> T-shirt17.0%15.2%+1.8
Poster -> Cushion14.4%10.4%+4.0
Notebook -> Sticker pack14.2%9.2%+5.0
Sticker pack -> Pen set13.7%9.2%+4.5
Tote -> T-shirt17.1%15.2%+1.9
Hoodie -> Cap7.6%10.6%-3.0
Real world Every "frequently bought together" tile on a large storefront is an attach rate with a base-rate check behind it. The check is what stops the tile recommending the store's best-seller next to everything; the best-seller has a high attach rate to every item because it has a high rate, full stop. Always draw the base-rate bar. Without it the chart flatters whichever product is already popular.
Self-study Basket size over time 3 min▶

Count distinct product lines per order, then the distribution per year. Most Printwell baskets are a single line, every year. That is the plain statement of the cross-sell headroom: the pair lifts above are computed on the minority of orders with two or more lines, and the majority never got the chance to attach anything. A bundle programme's ceiling is the one-line share, and its progress metric is that share going down. Draw the chart before promising anything about baskets.

Stacked bars per year 2020 to 2026 of the share of orders with one, two, three and four product lines. The one-line segment is the largest in every year; the two-line segment is a minority; three and four lines are thin.
🔍 Click to zoom - most baskets are one line in every year. The headroom for cross-sell is the height of the bottom segment.
Part 3 · covers top line, breadth, sizing a bundle offer

Customer preference per merchant 8 min live

The platform view says what sells; the merchant view says what sells for whom. Every merchant has a top line, the share of its revenue that line takes, and a breadth score: how many lines it effectively sells, not how many it has listed. Two merchants with fifteen lines each can have breadth 2 and breadth 11. This is the table a merchant success team hands to each merchant, and it is the first place a "your customers like X, try Y" conversation gets its X.

Live Top line and breadth 5 min▶

Breadth is the exponential of the Shannon entropy of a merchant's revenue shares across lines: the effective number of lines. A merchant selling fifteen lines equally scores 15; one selling almost only mugs scores just above 1. Counting lines with any sale would give both a 15 and tell the success team nothing. The where(shares > 0, 1) guard keeps log(0) out of the sum without dropping rows.

★ Top line, its share, and the effective number of lines soldmp = txc.groupby(["merchant_id", "product_line"]).revenue.sum().unstack(fill_value=0) shares = mp.div(mp.sum(axis=1), axis=0) pref = pd.DataFrame({ "top_line": shares.idxmax(axis=1), "top_line_share": shares.max(axis=1).round(3), "breadth (effective lines)": np.exp(-(shares * np.log(shares.where(shares > 0, 1))).sum(axis=1)).round(2), "revenue": mp.sum(axis=1).round(0), }) pref = pref.join(dims["merchant"].set_index("merchant_id")[["server_id", "segment", "vertical", "custom_share"]]) pref.sort_values("revenue", ascending=False).to_csv(lib.REPORTS / "a3_merchant_preference.csv") fig, ax = plt.subplots(figsize=(10, 5)) ax.scatter(pref.custom_share, pref["breadth (effective lines)"], s=np.sqrt(pref.revenue) / 6 + 8, c=[lib.server_color(s) for s in pref.server_id], alpha=.75, edgecolor="white") lib.save(fig, "a3_merchant_preference") pref.sort_values("revenue", ascending=False).head(10)
Scatter of 140 merchants: customisation share on the x axis, effective number of lines sold on the y axis, bubble size revenue, key-server merchants in amber and the rest in soft navy. Large enterprise merchants cluster at breadth 9 to 12; smaller bubbles spread lower.
🔍 Click to zoom - bubble = revenue, amber = merchants on S07 and S08. The biggest merchants sell broadly; the narrow ones are the cross-sell conversations.
MerchantTop lineTop line shareBreadthServerVertical
M020Blanket29.4%9.24S01nonprofit
M017Backpack22.5%11.51S04sports club
M014Blanket25.6%10.46S06creator
M030Backpack23.3%10.39S03corporate gifting
M041Backpack24.5%10.03S07sports club

The top five by revenue are all enterprise merchants with breadth between 9 and 12: they already sell across the catalogue and the conversation with them is about depth, not breadth. The file reports/a3_merchant_preference.csv carries all 140, and the ones that matter to a success team are further down: high revenue on one line, breadth under 3, and a pair-lift table that says what their customers buy elsewhere.

Self-study From lift to a bundle offer 3 min▶

A lift of 1.5 is not a business case. Sizing one honestly takes three numbers you already have and one you must decide. The attach gap - attach rate minus base rate - is the share of first-item orders that already carry the second above chance, and the most a prompt can plausibly move is some fraction of the orders that do not. Multiply the orders with the first item by the gap you expect to close, then by the margin of the second item, and you have an annual profit figure for the bundle before any discount. Then subtract the discount on every order that would have bought both anyway; that is the cost of bundling, and it is usually most of the benefit if the discount is generous.

  • Size it on the notebook + sticker pack gap of 5.0 points and the 1,692 orders that already hold both, not on the lift, which has no units.
  • Decide the target before launch: attach rate on the first item, measured monthly, against a control group of stores that did not get the bundle tile.
  • Watch two things after launch: does the attach rate rise, and does the one-line share of baskets in Part 2 fall? If the first moves and the second does not, the bundle is cannibalising two-line baskets rather than creating them.
  • Re-run lib.pair_lift on post-launch orders only. A bundle that works shows up as a higher lift on the pair; one that only discounts shows the same lift at a lower margin.
Build-along

Rebuild the products notebook ★ 18 min · everyone builds

Open notebooks/a3_products_and_baskets.ipynb, run it once, then rebuild each section against the shared reader. Same seed, same 1,121 SKUs, same lift table.

Load and restrict. dedupe=True, drop the partial month, and make a full_years frame for 2020-2025. Print the counts of categories, lines and SKUs sold.

Share by category. Groupby year and category, divide each year by its total, stackplot ordered by the 2025 share. Check that every row of the table sums to 100.

Slope chart. Share per line in 2021 and 2025; amber where 2025 is under 80 percent of 2021. Label every line on the chart itself.

Pareto and owner. Rank SKUs by profit, cumulate, find where 80 percent is crossed. Then the owner table: SKUs, margin, profit share. It should read 36.6 against 44.9 percent.

Owner over time. Margin by owner per year and merchant-SKU share per server. Confirm the margin gap holds in every year, not just on average.

Pairs. lib.pair_lift with min_support=0.002, write the bundle-candidate file, draw the lift matrix. Try min_support=0 once and watch what climbs to the top.

Attach rates and basket size. The six-pair attach chart with base rates, then lines per order per year. Say the one-line share out loud; that is the headroom.

Preference per merchant. Top line, top-line share, breadth as effective lines, joined to the merchant dimension. Write the CSV a success team can open.

The check that keeps you honest Rerun python3 analysis/nbgen.py notebooks/src/a3_products_and_baskets.py. A failed run leaves no notebook and no figures. Then open reports/a3_bundle_candidates.csv and read the bottom rows too: pairs below lift 1 are as real as the ones above it, and a bundle table that only shows winners has been filtered by someone.
Homework

Try it yourself - this week ◐ 45 min

Sources covered

What this session draws on

Product-mix and basket analysis are standard retail-analytics craft rather than a certified course. The working core drawn on here:

✓Agrawal, Imielinski and Swami - association rules: support, confidence, lift and the minimum-support floorPart 2 · lib.pair_lift by hand
◐mlxtend - apriori and association_rules, the library route to the same measuresNamed as the alternative; not used, so the denominators stay visible
✓The Pareto principle applied to SKUs - cumulative profit share by rank, the long tail as a catalogue decisionPart 1 · the Pareto curve and the owner table
✓Cole Nussbaumer Knaflic, Storytelling with Data - slope charts, direct labelling, one colour for the lines that matterPart 1 · lines that fell
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · With no support floor, the top of your lift table is a pair with lift 9 that appears in eleven orders. What do you do with it?

Lift divides by two small probabilities, so a pair that co-occurs a handful of times by luck gets an enormous number. The floor comes before the ranking. At line level on 145,640 orders the honest maximum is around 1.5, on pairs with over a thousand orders each.

2 · Drinkware revenue rose every year from 2020 to 2025. Its share of revenue fell from 25.9 to 16.8 percent. Which chart goes in front of the chairman, and why?

Absolute and share never disagree; they measure different things. Absolute stops anyone thinking the category shrank. Share stops anyone thinking it is still the future. A page that shows only one is choosing the reader's conclusion for them.

3 · A merchant creates a customised mug. The catalogue team proposes filing it as a new product line called "M041 Mug". Why is that a mistake?

The margin-by-owner chart, the price ladder, the customised share per server and the retention-by-customisation cut in a5 all join on product_line and owner as separate columns. Fold the owner into the line name and the base line disappears from the merchant's SKUs; the comparison can never be recomputed.

Analyst session 3 cheat sheet · pin this

Walk the levels in orderCategory (5), line (15), SKU (1,121 sold), then the owner fork. A category total hides a dying line; a line total hides which owner earns.
Share, not absoluteDivide each year by its total. Absolute says "did we grow", share says "did we shift". Show both, in that order.
The Printwell mix2020 to 2025: bags 12.0 to 26.9 percent, drinkware 25.9 to 16.8, home 32.1 to 25.0, stationery fading since 2023.
Owner is a column98 platform SKUs at 36.6 percent margin make 52.5 percent of profit; 1,023 merchant SKUs at 44.9 percent make 47.5. Keep base line and owner separate.
Three ratios of countsSupport = both / all orders. Confidence = both / with A. Lift = confidence / p(B). No library needed; the denominators stay visible.
Floor before rankmin_support first, lift second. A lift of 9 on eleven orders is chance. At line level the honest ceiling is about 1.5 on pairs with over a thousand orders.
Attach rate vs base rateAlways draw the base-rate bar. The gap is the bundle nobody sells on purpose; a control pair that goes the other way proves the chart can say no.
Breadth is effective linesexp of the entropy of revenue shares, not a count of lines with any sale. Top line + share + breadth is the table a merchant success team hands over.