learn-ship-ecommerce-enabler-with-phoebe / Analyst session 3 of 8
Ship It: Social Commerce Enablers · Analyst session 3 of 8

The identity gate

Every use case on an enabler's roadmap quietly assumes a join: this order to that customer, this listing to that SKU, this store to that brand. Whether the join exists is not a matter of opinion, and it is not a matter of how clever the matching is. It is a count. Tonight you learn to score a join key as a claim about pairs, watch the marketplaces' masking break the buyer into fragments that either collide or go silent, and see why Serai's own store and SKU ids pass a gate the buyer never will. Then you write the scorer yourself in Python and run it on the same real Olist orders the bench uses.

🟠 Advanced Analysts and analytics engineers Python, pandas 45 min
0-3 · Welcome 3-19 · Pairs, masking, grain 19-41 · Build-along: score the keys 41-45 · Q&A
Part 0

Where tonight sits on the track

Session 1 marked the buyer domain as the least obtainable row on the estate map. Session 2 conformed three marketplace exports into one schema, and each export kept a different slice of the buyer. Tonight measures what those slices are worth as join keys, at three grains: the customer, the store and the SKU. The rows are real Olist orders and the people who placed them are real (anonymised by Olist); the three-export split, the synthetic names and phones, and every export's masking are constructed, modelled on what integrators publicly report. Session 8's bench runs the same ladder in your browser; tonight you rebuild it in about sixty lines.

🎫 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 pair scorer in plain Python that counts predicted, true and correct pairs without ever building a pair, the true match rate that punishes both missed and invented links, a measured answer to "can we build a single customer view" (no: the best of 127 key combinations reaches 0.2329), and the habit of scoring a join before anyone builds on it.

Part 1 · covers scoring a join key as pairs

A join key is a claim about pairs

6 min live

When you join on a key you are claiming that every two rows sharing its value belong to the same thing. So the unit of scoring is not the row, it is the pair. Count the pairs the key claims, the pairs that are really true, and the pairs on both lists, and three numbers tell you everything a key is worth.

SIX ORDERS, THREE CUSTOMERS, ONE KEY (A TOY) above: true pairs, same customer · T = 4 TP order 1 customer X order 2 customer X order 3 customer X order 4 customer Y order 5 customer Y order 6 customer Z below: pairs the key claims, same key value · P = 4 TP = 1 only orders 1 and 2 sit on both sides recall 1 / 4 · precision 1 / 4 rate 1 / (4 + 4 - 1) = 0.14 Score the lines, not the dots: a key is judged on which pairs it draws, and on which it leaves out.
🔍 Click to zoom - the denominator counts every pair on either side once, so both a miss and a false link cost you
LiveThree counts: P, T and TP3 min▶

Hide the truth in a column the key never sees: for customer grain, which real person placed each order. Then three counts:

CountWhat it isIn the toy
P, predictedPairs of rows that share every chosen key value4
T, truePairs of rows that share the hidden truth4
TP, correctPairs on both lists1

From them, recall = TP / T (how many real links the key finds), precision = TP / P (how many of its links are real), and true match rate = TP / (P + T - TP). A row whose export left a chosen field empty links to nothing: it adds no predicted pairs, and its true pairs count as missed. That rule is what makes a masked field visible in the score.

LiveWhy the union in the denominator2 min▶

P + T - TP is the number of distinct pairs on either list: the union of the two pair sets. TP over that union is the Jaccard index of the claimed pairs and the true pairs. It is 1 only when the key draws exactly the true lines, no more and no fewer.

That is why one number is enough. A key that links everything finds every true pair, recall 100 percent, and drowns them: on the bench, the masked name alone claims 2,371,086 pairs to find 339 true ones, a rate of 0.0001. A key that links nothing has TP = 0 and scores 0. Recall rewards the first, precision rewards the second, and the union punishes both. If someone quotes you a match rate as a recall, ask for P.

Self-studyHow to count pairs without making them4 min▶

8,614 orders make 37,096,191 possible pairs. You never need to list them. Group the rows by key value: a group of n rows holds n choose 2 = n(n-1)/2 pairs, so P is the sum of n choose 2 over key groups. The same over truth groups gives T.

For TP, group by the key value and the truth together, the cells of a contingency table of key against truth. Two rows are a correct pair exactly when they share both, so TP is the sum of n choose 2 over those cells. Three Counters and three sums, linear in the rows, exact to the pair. The build-along writes it; the entity resolution course's first session shows why comparing every pair stops being an option long before Serai's scale.

Real world

Most pairs are between strangers. In the public Olist dataset, 93,358 customers placed 96,478 delivered orders, and only 2,801 of them bought twice or more: a 3.0 percent repeat rate. At that base rate almost every pair of orders belongs to two different people, so a key that groups loosely manufactures false pairs far faster than it finds true ones. The bench's sample keeps that real ratio on purpose (250 repeat customers against 8,083 one-time customers), because the base rate is what decides how bad a weak key looks.

Part 2 · covers what masking leaves as a join key

Masking turns identity into fragments

6 min live

The marketplaces do not hand an enabler the buyer. They hand it fragments: a first and last letter, a couple of digits, sometimes an address, sometimes not, and a different mix on each platform. Each fragment either collides, because thousands of buyers share it, or goes silent, because the export dropped it. Stacking fragments trades one failure for the other, and the ladder stops well below any gate.

THE CUSTOMER-GRAIN LADDER, 339 TRUE PAIRS axis stops at 0.25; the 0.90 gate is far off the right edge name + phone + store id best of 127 · precision 53.5% 0.2329 name + phone + city + zip recall 19.2% · precision 100% 0.1917 masked name + masked phone 100% recall · 23,928 pairs 0.0142 masked phone 100% recall · 371,023 pairs 0.0009 masked name 100% recall · 2,371,086 pairs 0.0001 hashed phone 265 raw phones, 0 pairs 0 predicted pairs · silent 0 0.05 0.10 0.15 0.20 0.25 Single fragments collide, stacked fragments go quiet, and the best of 127 combinations stops at 0.2329.
🔍 Click to zoom - read the bottom four rungs by their pair counts: recall is perfect and the key is useless
LiveCollide or go silent3 min▶

The bench's masking is constructed, modelled on the reported patterns: every export shows the name as its first letter, three stars and its last letter, and the last two digits of the phone. Platform A keeps zip and city; Platform B keeps neither; Platform C keeps the phone and the address only on seller-shipped orders. Scored at customer grain on 8,614 orders with 339 true pairs:

KeyRateRecallPredicted pairsWhat happened
masked name0.0001100%2,371,08663 masks for 8,333 people: collides
masked phone0.0009100%371,023100 possible values: collides
name + phone0.0142100%23,928still colliding, about 70 claimed pairs per true one
name + phone + city + zip0.191719.2%65never wrong, silent wherever the address was dropped
name + phone + store id0.232929.2%185best of 127; precision 53.5%

Every rung trades one failure for the other. Fragments that appear on every export collide; the fragment that disambiguates, the address, exists on too few rows to find most repeat buyers. There is no combination in between that does both, and the search over all 127 subsets of the seven keys proves it rather than asserting it.

LiveA hash needs the raw value2 min▶

The standard suggestion is to hash the phone and join on the hash. On the bench, the hashed phone links nothing: 0 predicted pairs. Only the 265 seller-shipped Platform C orders carried a raw phone to hash, and no two of those 265 orders were the same person.

A hash is a way of keeping a raw value private while still comparing it, and it can only exist where the raw value arrived. Hashing the masked fragment gives you the fragment again, with its collisions. So the question for any hashed-identity proposal is not the algorithm, it is: on how many rows, from which exports, do we receive the raw value today? That is an obtainability question, and session 1's scale already answered it: closed on platform-shipped orders.

Self-studyThe sample flatters fragments: treat 23.3 percent as a ceiling4 min▶

The bench scores 8,614 orders. Serai's real estate would hold many times that. True pairs grow roughly with the number of rows (each new repeat buyer adds a few), but false pairs from a colliding key grow with the square of the rows: double the orders and every masked-name group doubles, so its pairs roughly quadruple. P grows faster than TP, and every fragment rung sinks as the data grows.

So read 0.2329 as a ceiling measured on a small, friendly sample, not an estimate of what a production join would reach. The direction is certain; the size at Serai's scale is not measured here, and nothing on this page claims it.

Real world

Three marketplaces, three different slices of the buyer. Integrators' public notes report that all three marketplaces mask the buyer's name and phone on orders they fulfil, and that each keeps a different amount of the address; seller-delivered orders are the exception. Shopee is reported to remove buyer names and phone numbers while leaving the address for order matching (Easy Shopee & Lazada help centre, 2022). Lazada is reported to mask name, address and phone in its seller centre and exports from 12 January 2026 and in its Open Platform API from 1 July 2026, with full details kept on delivered-by-seller orders (BigSeller; Zetpy, June 2026). TikTok Shop is reported to desensitise recipient name and phone when its own logistics ship the order (openlinker-project, GitHub issue 2882). All three are reported, not read at source, and the rules have changed twice in 2026 alone: re-check before you build on any of them.

Part 3 · covers which grains an enabler can join

Grain decides what can be joined

4 min live

The buyer is not Serai's to key, but the store and the SKU are. Run the same scorer with the truth set to the store or the SKU behind each order line, and the picture flips: the keys are Serai's own ids, and they clear a 90 percent gate with room to spare. The gate is not about matching skill. It is about who owns the key.

THREE GRAINS AGAINST THE GATE gate 0.90 customer grain best: name + phone + store below the gate 0.2329 shuffled ids: 0.0000 store grain key: store id, Serai's own 186,387 of 186,387 pairs 1.0000 shuffled ids: 0.0037 SKU grain key: SKU · 17,033 of 17,474 gap: 219 unmapped lines 0.9748 shuffled ids: 0.0004 0 0.5 1.0 Serai's own keys clear the gate and the buyer's fragments do not. Shuffle the truth and all three fall to zero.
🔍 Click to zoom - the ghost bars are the proof that the score measures matching, not the size of the groups
LiveStore 1.0000, SKU 0.9748, and where the 2.5 percent went2 min▶

At store grain (every order line, truth = the store behind it) the store id scores 1.0000: 186,387 predicted pairs, all of them true, none missed. At SKU grain the SKU scores 0.9748: it finds 17,033 of 17,474 true pairs with no false ones. The gap is 219 lines on listings missing from Serai's SKU mapping table, so their SKU is empty and they link to nothing.

That gap is the lesson in miniature. Owning the key means maintaining it: a listing created on a marketplace last week and not yet mapped is invisible to every SKU-grain use case. The fix is a job and an owner (a daily check for unmapped listings, a count on the ops dashboard), not a better algorithm.

LiveThe break button: shuffle the truth1 min▶

A scorer that returns high numbers for the wrong reason is worse than none. So break it on purpose: shuffle the hidden ids (seed 7 on the bench) and score again. The best customer key drops to 0.0000, the store id to 0.0037, the SKU to 0.0004. If the store id had kept a high score on shuffled truth, the rate would be measuring how big the groups are, not whether they are right. It falls to near zero, so it measures matching.

The quieter sanity check sits in the key list: the order id is unique per order, so at customer grain it claims 0 pairs and scores 0. A key that is unique per row can never join anything above its own grain, which is exactly what it should print.

Self-studyWhat this does not teach: probabilistic linkage2 min▶

Everything above asks one question: does a deterministic key exist at this grain? It does not try to beat the fragments with weighted agreement scores, blocking or a clerical review band. That is record linkage proper, and it has its own session: Fellegi-Sunter in the entity resolution course. Read it with tonight's numbers in hand: a probabilistic model can weigh a matching address above a matching initial, but it still cannot recover a value the export never contained.

Real world

Olist keys its sellers and products itself. Olist describes itself as connecting small businesses from all over Brazil to marketplace channels with a single contract, an enabler model. In its public dataset every order line carries Olist's own seller_id and product_id, which is why store and SKU grain hold on its data: the ids belong to the business doing the joining, not to the channel. Serai's version of that is its store and SKU mapping tables from session 2. The 219 unmapped lines on the bench are constructed (the full Olist file has no gaps), placed there to show what an unmaintained mapping table costs.

Build-along

Score keys against the hidden truth

★ 22 min · everyone builds

Plain Python, standard library only, on the bench's own sample: 8,614 real Olist orders with constructed exports and masking. Every output below is what this code printed when it was run for this page. The unshuffled numbers must match the bench to the digit; if yours do not, the bug is in your row rules, and step 2 is where to look.

THE SCORER, END TO END enabler-sample.js JSON behind a JS prefix rows: one per order 7 fields, None = export hid it skip a row if any chosen key is None hashed phone exists on 265 orders only Counter(truth) sum of n choose 2 = T Counter(key values) sum of n choose 2 = P Counter((key, truth)) sum of n choose 2 = TP true match rate = TP / (P + T - TP) masked name: 339 / 2,371,086 = 0.0001 Truth counts every row; P and TP only count rows that carry every chosen key. No pair is ever built.
🔍 Click to zoom - T is fed from all rows, so a key that drops rows pays for it as missed pairs

Load the sample and expand the lines. The bench's sample is a JavaScript file: a comment header, then window.ENABLER_SAMPLE = , then plain JSON, then a semicolon. Split off everything up to the prefix, strip the trailing semicolon, and json.loads the rest. Lines ship as a count per order; expand them into a line-to-order index.

★ Step 1 · load and expandimport json, os, urllib.request from collections import Counter from itertools import combinations import random URL = "https://phoebefu6.github.io/learn-ship-ecommerce-enabler-with-phoebe/assets/enabler-sample.js" if not os.path.exists("enabler-sample.js"): urllib.request.urlretrieve(URL, "enabler-sample.js") txt = open("enabler-sample.js", encoding="utf-8").read() body = txt.split("window.ENABLER_SAMPLE = ", 1)[1].strip().rstrip(";") s = json.loads(body) line_order = [o for o, k in enumerate(s["linesPerOrder"]) for _ in range(k)] print(s["nOrders"], len(line_order), s["nStores"], s["nSkus"]) print("seller-shipped", sum(s["shipped"]), "unmapped lines", sum(s["lineUnmapped"])) # 8614 9843 1527 5978 # seller-shipped 265 unmapped lines 219

Build the customer-grain rows, one per order. Each field follows the constructed masking exactly. The masked name and the two phone digits reach every export. The hashed phone exists only where the raw phone arrived, on seller-shipped orders; since a hash is one value per person, the stand-in stores the customer index there. City and zip exist on Platform A (export 0) and on seller-shipped orders. Store and SKU come from the order's first line, and the SKU is empty when that listing is missing from the mapping table. The truth, the real customer, lives in its own list and is never a key.

★ Step 2 · rows and hidden truthfirst_line = {} for i, o in enumerate(line_order): first_line.setdefault(o, i) def fields(o, li): c, e, sh = s["cust"][o], s["exp"][o], s["shipped"][o] return {"mname": s["mname"][c], # F***l on every export "mphone": s["phone2"][c], # last two digits "hphone": c if sh else None, # raw phone only if seller-shipped "loc": s["loc"][o] if (e == 0 or sh) else None, # Platform A, or shipped C "oid": o, "store": s["lineStore"][li], "sku": None if s["lineUnmapped"][li] else s["lineSku"][li]} rows = [fields(o, first_line[o]) for o in range(s["nOrders"])] truth = [s["cust"][o] for o in range(s["nOrders"])] print(len(rows), rows[0]) # 8614 {'mname': 39, 'mphone': 70, 'hphone': None, 'loc': None, 'oid': 0, 'store': 458, 'sku': 1163}

Write the scorer with three Counters. Skip any row missing a chosen key for P and TP, count every row for T, and sum n choose 2 over each. Then check it against the bench's first rung before you trust it: masked name must print P 2371086, T 339, TP 339.

★ Step 3 · score(rows, truth, keys)def c2(n): return n * (n - 1) // 2 def score(rows, truth, keys): pred, both = Counter(), Counter() for r, t in zip(rows, truth): vals = tuple(r[k] for k in keys) if None in vals: continue pred[vals] += 1 both[(vals, t)] += 1 P = sum(c2(n) for n in pred.values()) T = sum(c2(n) for n in Counter(truth).values()) TP = sum(c2(n) for n in both.values()) den = P + T - TP return {"P": P, "T": T, "TP": TP, "rate": TP / den if den else 0.0, "recall": TP / T if T else 0.0, "precision": TP / P if P else None} print(score(rows, truth, ["mname"])) # {'P': 2371086, 'T': 339, 'TP': 339, 'rate': 0.0001429724607205306, # 'recall': 1.0, 'precision': 0.0001429724607205306}

Run the rungs. Single keys first, then the stacks. Watch P fall and recall hold, until the address arrives and recall drops to the 19.2 percent of true pairs where both orders carry it.

★ Step 4 · the ladderRUNGS = [["mname"], ["mphone"], ["hphone"], ["loc"], ["oid"], ["store"], ["sku"], ["mname", "mphone"], ["mname", "mphone", "loc"], ["mname", "mphone", "store"]] for k in RUNGS: r = score(rows, truth, k) print(f"{'+'.join(k):<20} rate {r['rate']:.4f} recall {r['recall']:.1%} P {r['P']:>9,} TP {r['TP']}") # mname rate 0.0001 recall 100.0% P 2,371,086 TP 339 # mphone rate 0.0009 recall 100.0% P 371,023 TP 339 # hphone rate 0.0000 recall 0.0% P 0 TP 0 # loc rate 0.0366 recall 19.2% P 1,502 TP 65 # oid rate 0.0000 recall 0.0% P 0 TP 0 # store rate 0.0007 recall 29.2% P 139,297 TP 99 # sku rate 0.0033 recall 11.2% P 11,237 TP 38 # mname+mphone rate 0.0142 recall 100.0% P 23,928 TP 339 # mname+mphone+loc rate 0.1917 recall 19.2% P 65 TP 65 # mname+mphone+store rate 0.2329 recall 29.2% P 185 TP 99

Search every combination, not the ones you like. Seven keys give 127 non-empty subsets. A hand-picked ladder can always be accused of missing the clever one; the exhaustive search cannot.

★ Step 5 · best of 127KEYS = ["mname", "mphone", "hphone", "loc", "oid", "store", "sku"] combos = [list(c) for n in range(1, 8) for c in combinations(KEYS, n)] best = max(combos, key=lambda k: score(rows, truth, k)["rate"]) b = score(rows, truth, best) print(len(combos), "combos, best", "+".join(best), f"{b['rate']:.4f} precision {b['precision']:.1%}") # 127 combos, best mname+mphone+store 0.2329 precision 53.5%

Change the grain, then break the truth. At store and SKU grain there is one row per order line and the truth is the line's store or SKU. Then shuffle each truth list and search again. This uses Python's random.Random(7).shuffle, which is not the bench's mulberry32 shuffle, so your shuffled numbers differ slightly from the bench's (0.0036 here against its 0.0037 at store grain); what matters is that every grain collapses to near zero.

★ Step 6 · three grains and the break buttondef line_rows(grain): rw = [fields(o, li) for li, o in enumerate(line_order)] tr = [s["lineStore"][li] if grain == "store" else s["lineSku"][li] for li in range(len(line_order))] return rw, tr grains = {"customer": (rows, truth), "store": line_rows("store"), "sku": line_rows("sku")} def best_rate(rw, tr): return max(score(rw, tr, k)["rate"] for k in combos) for g, (rw, tr) in grains.items(): sh = list(tr) random.Random(7).shuffle(sh) print(f"{g:<9} best {best_rate(rw, tr):.4f} shuffled {best_rate(rw, sh):.4f}") sr, st = grains["store"]; print("store id", score(sr, st, ["store"])) kr, kt = grains["sku"]; print("sku", score(kr, kt, ["sku"])) # customer best 0.2329 shuffled 0.0000 # store best 1.0000 shuffled 0.0036 # sku best 0.9748 shuffled 0.0004 # store id {'P': 186387, 'T': 186387, 'TP': 186387, 'rate': 1.0, 'recall': 1.0, 'precision': 1.0} # sku {'P': 17033, 'T': 17474, 'TP': 17033, 'rate': 0.9747625042920911, # 'recall': 0.9747625042920911, 'precision': 1.0}

Put the scorer next to the registry. Every use case on the roadmap names the grain it needs; the gate is a one-line check of that grain's best rate against 0.90, run every time an export changes. Session 8's roadmap sequencer does exactly that and blocks the single customer view. Serai, its exports and its masking are constructed; the orders, and who placed them, are real Olist rows, and no figure here is a claim about any real enabler.

Homework

Before session 4

30 min
  1. Change the masking rule in step 2 so Platform B also keeps city and zip, rerun steps 4 and 5, and write down how far the best customer-grain rate moves. Is it anywhere near 0.90?
  2. Double the data by stacking the rows with a second copy whose customer ids are offset (so no new true pairs cross copies), and rescore the masked phone. Check that P roughly quadruples while T only doubles.
  3. Pick one join your team relies on today and score it: write down P, T, TP and the rate, or write down why you cannot, because nobody holds the truth to score it against.
  4. List every use case on your roadmap with the grain it needs. Mark each grain with its best measured rate, and circle the ones that sit below the gate.
Check yourself

Three questions

Clear all three and Analyst session 3 gets stamped in your passport.

1 · The masked phone finds all 339 true pairs. Why does it score only 0.0009?

Recall is 100 percent and precision is almost zero. The true match rate is TP over the union of claimed and true pairs, 339 over roughly 371,023, so a key that links everything scores almost like one that links nothing.

2 · Someone proposes hashing the buyer's phone to build a single customer view. What does the bench say?

Hashing protects a value you already hold; it cannot create one. Where the export masked the phone there is nothing to hash, so the question is how many rows arrive with the raw value, and on platform-shipped orders the answer is none.

3 · The store id scores 1.0000 at store grain. After shuffling the hidden store ids it scores 0.0037. What does the second number tell you?

The break button is a test of the scorer, not of the key. A metric that stayed high on shuffled truth would be rewarding group size; one that falls to near zero at every grain is measuring matching.

Sources covered

What this session teaches, and where it came from

✓Pair scoring: P, T, TP and the true match rateComputed: every rate on this page was printed by the scorer on the bench sample and agrees with the bench to four decimals (the Python shuffle differs slightly, as stated)
◐Marketplace buyer maskingReported, from integrators' public notes (Easy Shopee & Lazada help centre, 2022; BigSeller; Zetpy, June 2026; openlinker-project, GitHub issue 2882); the bench's masking is constructed and modelled on them
✓Store and SKU grain on Serai's own keysComputed on real Olist lines; the three-export split and the 219 unmapped lines are constructed
✓Olist orders and customersOlist, "Brazilian E-Commerce Public Dataset by Olist", Kaggle, CC BY-NC-SA 4.0; repeat-rate counts recomputed in this build
◐Record linkage methodsNot taught here: Fellegi-Sunter, blocking and review bands belong to the entity resolution course, linked from Part 3

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

Analyst session 3 cheat sheet · pin this

Score pairsP claimed, T true, TP both. Rate = TP / (P + T - TP), the Jaccard index of the pair sets.
Never build a pairSum n choose 2 over key groups, truth groups, and key x truth cells. Three Counters.
Collide or go silentMasked fragments either link strangers or vanish on the exports that drop them. Best of 127: 0.2329.
A hash needs the raw value265 raw phones, 0 pairs. Ask how many rows arrive unmasked before discussing algorithms.
Own the key, maintain itStore 1.0000, SKU 0.9748; the gap is 219 unmapped listings. A job and an owner, not a model.
Break it on purposeShuffle the truth; every grain must fall to near zero. Small samples flatter fragments: read rates as ceilings.