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

Three marketplace exports, one schema

Every marketplace hands a seller the same order in its own dialect: its own column names, its own id formats, its own idea of how much of the buyer you are allowed to see. An enabler running stores on three of them cannot compare anything until those three dialects become one language. Tonight you split 96,478 real delivered Olist orders into three marketplace-style exports, conform them back into one schema with a platform column, build the two mapping tables Serai owns, and discover that GMV is R$ 15.42M or R$ 13.22M depending on one word nobody wrote down.

🟡 Core Analysts and analytics engineers Python, pandas 45 min
0-3 · Welcome 3-19 · Dialects, mapping tables, GMV 19-41 · Build-along: split, then conform 41-45 · Q&A
Part 0

Where tonight sits on the track

Session 1 drew the estate map and wrote the registry. Tonight is the spine of domain 4, orders, fees and settlement, and the middle link of the first chain: the mapping from each platform's ids to Serai's own. The stand-in is the public Olist dataset, real Brazilian marketplace orders, anonymised. The three-way split and the export column names are constructed for teaching; the orders, the prices, the shipping fees and the sellers are real. Session 3 then asks which of these columns can still join a buyer across platforms, and the answer depends on everything you build tonight.

🎫 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 per-platform column map that turns any of three exports into one conformed schema, a shop mapping table and a SKU mapping table with a weekly unmapped-rows check, and two GMV numbers from the same 110,197 lines, each with a name that says exactly what it counts.

Part 1 · covers three exports and why you conform them

Three exports, three dialects

6 min live

Put one order line from each marketplace side by side and it is the same fact three times: an order, a listing, a shop, a price, a shipping fee. Nothing lines up. The order id is called three different things, the price column means a slightly different thing each time, and one export has no address at all. The job is not to stack the three files. It is to translate each one into a language Serai owns.

THE SAME ORDER LINE, THREE DIALECTS column names constructed; the rows under them are real Olist lines Platform A export ordersn · item_id · shop_id item_price · shipping_fee buyer_zip · buyer_city keeps zip and city Platform B export order_number · sku_id seller_code · paid_price shipping_amount no address at all Platform C export order_no · listing_id shop_code · sale_price shipping_fee no address in this export per-platform column map platform · platform_order_id · platform_listing_id · platform_shop_id item_price · shipping · buyer_zip · buyer_city Same meaning, different names: shipping_fee on A and C, shipping_amount on B. A union would keep all three.
🔍 Click to zoom - the teal box is the only thing downstream code is allowed to read
LiveSame order, three column names, three masking levels3 min▶
What it isPlatform APlatform BPlatform CConformed
The orderordersnorder_numberorder_noplatform_order_id
The listingitem_idsku_idlisting_idplatform_listing_id
The shopshop_idseller_codeshop_codeplatform_shop_id
Item priceitem_pricepaid_pricesale_priceitem_price
Shippingshipping_feeshipping_amountshipping_feeshipping
Buyer addresszip and citynonenone in this exportbuyer_zip, buyer_city

The names are constructed, chosen to look the way marketplace exports tend to look, and the masking follows the reported pattern from session 1: every marketplace hides the buyer's name and phone on orders it fulfils, and each keeps a different amount of the address. On the session 8 bench, Platform C keeps phone and address only on seller-shipped orders; tonight's export leaves those out, so only A carries an address.

In a real estate the ids differ in format as well as name: each platform issues its own order numbers, its own listing ids and its own shop ids, and none of them means anything on the other two. In the Olist file every id is the same 32-character hex string whichever platform it lands on, which is exactly why you have to imagine the difference, and why Serai owns a mapping table instead of trusting the ids to agree.

LiveWhy conform, instead of stacking the three files2 min▶

A plain union of three exports gives you a frame with every column from every file: ordersn, order_number and order_no side by side, each two-thirds empty. Every query then has to know which column to read for which platform, and every analyst re-learns the dialects on their own. The dialect knowledge leaks into a hundred notebooks and drifts in each one.

Conforming puts that knowledge in exactly one place: a column map per platform, read by one function. Downstream code reads eight columns and never learns that Platform B calls the price paid_price. When a marketplace renames a field next quarter, one line changes and nothing else notices.

Self-studyThe silent drifts: type, currency, timezone4 min▶

Renaming is the loud part and the easy part. Three drifts do the real damage because nothing errors:

  • Type. When the conform step fills buyer_zip with empty values for B and C, pandas turns the whole column into float64. The build-along prints it. A zip prefix is a label, not a number; as a float it loses its leading zeros and joins to nothing. Cast it to a string with the zero padding back, on purpose, in the conform step.
  • Currency. Olist is all Brazilian reais, so the build-along never meets this. Serai (constructed) runs stores in four markets, so its conformed schema needs a currency column next to every amount, and any cross-market total needs a reporting currency and a dated rate. Adding two currencies without one is the most common wrong number in a regional deck.
  • Timezone. Olist's timestamps carry no zone. Marketplace exports are often in the market's local time, and four markets can mean more than one offset. Conform to UTC and keep the local time as a second column; session 4 makes one clock a hard rule for the live channel.

Write each of these into the column map as a rule, not a comment. A comment is a hope.

Real world

Olist is itself an enabler. Olist describes its business as connecting small businesses across Brazil to marketplace channels "with a single contract". Its public file has one seller id per seller and no marketplace column at all: Olist issues the ids, so its own estate arrives already conformed. An enabler that sits on top of three marketplaces it does not run gets no such gift. The id comes from the platform, and the conforming is Serai's job. Source: Olist, "Brazilian E-Commerce Public Dataset by Olist", Kaggle, CC BY-NC-SA 4.0.

Part 2 · covers the platform column and the two mapping tables

The platform column and the two mapping tables

6 min live

Once the rows speak one language, two small things make them comparable. A platform column says where each row came from. Two mapping tables say which of Serai's stores and which of Serai's SKUs each platform id belongs to. Neither is interesting on its own, and together they are what turns three piles of orders into one store view and one SKU view.

PLATFORM IDS, THROUGH A TABLE SERAI OWNS, TO ONE MASTER ID A · shop_id B · seller_code C · shop_code shop mapping owner: store ops · checked weekly A · 001cca7ae9ae... · ST001cca7a B · 001cca7ae9ae... · ST001cca7a C · 001cca7ae9ae... · ST001cca7a identical only because Olist issued it store_id ST001cca7a one store, three platform ids THE TWO TABLES ON THE FULL FILE bar segments to scale · teal = on all three platforms shops · 6,911 rows 2,970 stores 1,665 on all three 611 on two 694 on one SKUs · 47,148 rows 32,216 SKUs 4,176 6,580 on two 21,460 on one More than half the stores can be read side by side on all three platforms. Only 13 percent of SKUs can.
🔍 Click to zoom - the fan is the whole point: three rows in a table, one row in every report
LivePlatform is a column, not a folder2 min▶

The first draft of every multi-marketplace warehouse has three folders, platform_a/, platform_b/, platform_c/, with a copy of every table in each. Every comparison is then a three-way join, every new metric is written three times, and the three copies drift within a month.

Make platform the first column of the conformed schema instead. A comparison becomes a groupby("platform"). A new marketplace becomes a new value and a new entry in the column map, not a new folder of tables. Session 1 put the platform in a column on the estate map; tonight it becomes a column in the data.

LiveTwo mapping tables, owned, with an owner3 min▶

A mapping table has one row per platform id and one column that says which Serai id it belongs to. It is Serai's own asset, obtainability word owned, and it is the only reason a cross-platform view exists at all.

TableKeyMaps toOwnerOn the full Olist file
Shop mappingplatform + platform_shop_idstore_idStore ops6,911 rows to 2,970 stores: 1,665 on all three platforms, 611 on two, 694 on one
SKU mappingplatform + platform_listing_idskuCatalogue47,148 rows to 32,216 SKUs: 4,176 on all three, 6,580 on two, 21,460 on one

The 1,665 is the number to say out loud. Those are the stores whose three platforms can be read on one screen, which is the first thing a brand asks an enabler for. The master ids in the build-along are "ST" and "SK" plus the first eight characters of the Olist id: a readable convention for teaching. In a real estate the master id is issued by the catalogue tool and never derived from a platform's id, because the platform can re-issue its id and yours must not move.

Self-studyThe weekly unmapped-rows check3 min▶

A mapping table decays the moment a brand launches a new listing on one platform and nobody adds the row. The line still arrives, joins to nothing, and falls out of the SKU view without an error. The fix is a scheduled check with a named owner: left-join every conformed line to both tables, count the lines with no master id, and send the count, by platform and by brand, to the owner every week.

On the full Olist file the count is zero for both tables, because the build-along builds the maps from the same lines it checks. That zero is the check working, not the check being unnecessary. The session 8 bench plants 219 lines on listings missing from Serai's SKU table (constructed), and they are the whole gap between a SKU match rate of 1.0 and 0.9748. Owning the key means maintaining it. When there is no key to own at all, as with buyers, the matching methods live in learn-entity-resolution, and session 3 asks whether you can use them.

Real world

Constructed: the Monday a brand's best seller vanished. In this constructed Serai story, a skincare brand re-lists its best seller on Platform B to fix a photo, and the marketplace issues a new listing id. Orders keep flowing. The SKU view shows the product's Platform B sales falling to zero, and the brand's account lead spends a morning drafting an explanation for a collapse that never happened. The weekly unmapped-rows check would have flagged one new listing id with rising lines and no SKU against it, and store ops would have added one row. Every detail of this story is constructed; the mechanism is the one every mapping table has.

Part 3 · covers one GMV from one schema, and its definition

One schema, one GMV

4 min live

With one schema you can finally add up the money. And the first sum raises the first definition question: does GMV include what the buyer paid for shipping? On the 110,197 delivered Olist lines the two answers are R$ 15,419,773.75 and R$ 13,221,498.11. Both are correct. Only one of them can be the number in the board pack.

DELIVERED GMV BY PLATFORM, ITEMS AND SHIPPING bar lengths to scale from the build-along's output · hachure = items · teal = shipping Platform A Platform B Platform C + shipping + shipping + shipping items R$ 6,050,000.43 items R$ 4,528,552.65 items R$ 2,642,945.03 R$ 13.22M items only, all three R$ 15.42M with shipping, all three 14.26% of GMV-with-shipping is shipping Same lines, same schema. The gap is one word in the definition, and it is not small.
🔍 Click to zoom - the teal slice is the part of GMV a definition either keeps or drops
LiveTwo GMVs from one table2 min▶
Measure, delivered linesValueWhat it counts
GMV with shippingR$ 15,419,773.75item_price + shipping, every delivered line
GMV without shippingR$ 13,221,498.11item_price only
Shipping share14.26 percentshipping / GMV with shipping
Platform A, items onlyR$ 6,050,000.4343,750 orders in the constructed split
Platform B, items onlyR$ 4,528,552.6533,360 orders
Platform C, items onlyR$ 2,642,945.0319,368 orders

The per-platform numbers are an artefact of the seeded split, so they tell you nothing about any real marketplace; what they show is that one conformed schema answers the per-platform question with one line of code. The 14.26 percent is real Olist behaviour: on these orders, one real in every seven the buyer paid went to shipping.

Self-studyWhere the definition lives, and the enabler twist2 min▶

Tonight is about building the schema that makes the definition computable. The definition itself belongs in one place, the metric registry that session 7 writes as code, with its name, its formula, its owner and a test that fails if the number drifts. The general GMV equation and its variants are taught in learn-ecommerce-metrics, session a1; link to it rather than re-deriving it.

The enabler twist is that "GMV" has an owner question on top of the formula question. Whose GMV is it: the brand's, run through Serai's stores? Placed or settled? With shipping or without? Serai's GMV-linked share (constructed, 2 percent) is paid on one of these, and a contract that says "GMV" without saying which is a dispute waiting for its quarter. Tonight's filter to delivered orders is already one choice; session 7 lines up the others.

Real world

A listed marketplace writes its GMV definition down. Sea Limited's annual report for fiscal 2019 defines Shopee's GMV as the value of orders on the marketplace and says its calculation "includes shipping and other charges". It also counts each confirmed order regardless of whether the transaction settles or the item is returned. So the headline GMV of a listed marketplace is the larger of tonight's two numbers, and it counts orders tonight's delivered filter would drop. Neither choice is wrong; the lesson is that Sea wrote its choice down. Source: Sea Limited, Annual Report on Form 20-F for the fiscal year 2019, "Conventions" definitions.

Build-along

Split, then conform, 110,197 real lines

★ 22 min · everyone builds

You will make the mess before you clean it: split the real delivered orders into three constructed exports, then conform them back and prove nothing was lost. Every number below is what the code prints on the public dataset, run on pandas 2.1 and numpy 1.26. The seed fixes the split, so your counts should match to the row; if they do not, check the file version first.

MAKE THE MESS, THEN CLEAN IT top row builds the three exports; bottom row conforms them back and counts 1 · load three CSVs orders, customers, items real Olist, via kagglehub 2 · filter delivered 96,478 orders 110,197 lines 3 · seeded split A 43,750 · B 33,360 C 19,368 (constructed) 4 · rename three dialects B and C: no address 5 · conform 110,197 lines buyer_zip on 45.35% 6 · two mapping tables shops 6,911 to 2,970 SKUs 47,148 to 32,216 7 · unmapped check 0 stores · 0 SKUs run it weekly 8 · GMV, two ways R$ 15,419,773.75 with R$ 13,221,498.11 without Line count in equals line count out at every step. That equality is the test that the conform lost nothing.
🔍 Click to zoom - step 5 is the one downstream code depends on; the other seven exist to prove it right

Load the three tables and keep delivered orders. The no-login route is kagglehub; run it from a scratch folder, not inside a repo. The customer table rides along because it holds the address columns that Platform A will keep and B will lose.

★ Step 1 · load and filterimport kagglehub, numpy as np, pandas as pd path = kagglehub.dataset_download("olistbr/brazilian-ecommerce") orders = pd.read_csv(f"{path}/olist_orders_dataset.csv") customers = pd.read_csv(f"{path}/olist_customers_dataset.csv") items = pd.read_csv(f"{path}/olist_order_items_dataset.csv") orders = orders[orders.order_status == "delivered"].merge(customers, on="customer_id") print(len(orders), "delivered orders") # 96478 delivered orders

Split the orders across three constructed platforms, with a seed. The split is at order grain, so every line of an order lands on the same platform, as it would in life. The 45 / 35 / 20 weights are constructed; the seed makes your split identical to this one.

★ Step 2 · the seeded splitrng = np.random.default_rng(20260929) orders["platform"] = rng.choice(["A", "B", "C"], len(orders), p=[.45, .35, .20]) print(orders.platform.value_counts().sort_index().to_dict()) lines = items.merge(orders[["order_id", "platform", "customer_zip_code_prefix", "customer_city"]], on="order_id") print(len(lines), "delivered lines", lines.platform.value_counts().sort_index().to_dict()) # {'A': 43750, 'B': 33360, 'C': 19368} # 110197 delivered lines {'A': 49977, 'B': 38204, 'C': 22016}

Rename each platform's rows into its own dialect. This is the mess the marketplaces hand you. B and C drop the address columns, which is the masking level of tonight's export.

★ Step 3 · three exportsA = lines[lines.platform == "A"].rename(columns={ "order_id": "ordersn", "product_id": "item_id", "seller_id": "shop_id", "price": "item_price", "freight_value": "shipping_fee", "customer_zip_code_prefix": "buyer_zip", "customer_city": "buyer_city"}) B = lines[lines.platform == "B"].rename(columns={ "order_id": "order_number", "product_id": "sku_id", "seller_id": "seller_code", "price": "paid_price", "freight_value": "shipping_amount"} ).drop(columns=["customer_zip_code_prefix", "customer_city"]) # B ships no address C = lines[lines.platform == "C"].rename(columns={ "order_id": "order_no", "product_id": "listing_id", "seller_id": "shop_code", "price": "sale_price", "freight_value": "shipping_fee"} ).drop(columns=["customer_zip_code_prefix", "customer_city"]) exports = {"A": A, "B": B, "C": C} for p, d in exports.items(): print(p, len(d), [c for c in d.columns if c not in ("order_item_id", "shipping_limit_date", "platform")]) # A 49977 ['ordersn', 'item_id', 'shop_id', 'item_price', 'shipping_fee', 'buyer_zip', 'buyer_city'] # B 38204 ['order_number', 'sku_id', 'seller_code', 'paid_price', 'shipping_amount'] # C 22016 ['order_no', 'listing_id', 'shop_code', 'sale_price', 'shipping_fee']

Conform with one column map per platform, and fix the type drift on purpose. The map is the only place the dialects are written down. reindex adds any schema column a platform does not send as an honest empty value, and that is exactly what turns buyer_zip into a float. Cast it back to a zero-padded string in the same step.

★ Step 4 · the conformed schemaCOLUMN_MAP = { "A": {"ordersn": "platform_order_id", "item_id": "platform_listing_id", "shop_id": "platform_shop_id", "item_price": "item_price", "shipping_fee": "shipping", "buyer_zip": "buyer_zip", "buyer_city": "buyer_city"}, "B": {"order_number": "platform_order_id", "sku_id": "platform_listing_id", "seller_code": "platform_shop_id", "paid_price": "item_price", "shipping_amount": "shipping"}, "C": {"order_no": "platform_order_id", "listing_id": "platform_listing_id", "shop_code": "platform_shop_id", "sale_price": "item_price", "shipping_fee": "shipping"}, } SCHEMA = ["platform", "platform_order_id", "platform_listing_id", "platform_shop_id", "item_price", "shipping", "buyer_zip", "buyer_city"] def conform(p, d): x = d[list(COLUMN_MAP[p])].rename(columns=COLUMN_MAP[p]) x["platform"] = p return x.reindex(columns=SCHEMA) # a missing column becomes an honest NaN conformed = pd.concat([conform(p, d) for p, d in exports.items()], ignore_index=True) print(len(conformed), "conformed lines") print("buyer_zip dtype after conform:", conformed.buyer_zip.dtype) conformed["buyer_zip"] = conformed.buyer_zip.astype("Int64").astype("string").str.zfill(5) print("buyer_zip dtype after fix: ", conformed.buyer_zip.dtype) print("buyer_zip present:", round(conformed.buyer_zip.notna().mean() * 100, 2), "percent") # 110197 conformed lines # buyer_zip dtype after conform: float64 # buyer_zip dtype after fix: string # buyer_zip present: 45.35 percent

Build the two mapping tables. One row per platform id, one master id per row. The "ST" and "SK" prefixes plus the first eight characters of the Olist id are a teaching convention; in a real estate the catalogue tool issues the master id. Olist uses one seller id across platforms, so here the fan is by construction; in a real estate each platform's id would differ and a person would have mapped it.

★ Step 5 · shop and SKU mappingshop_map = conformed[["platform", "platform_shop_id"]].drop_duplicates() shop_map["store_id"] = "ST" + shop_map.platform_shop_id.str[:8] sku_map = conformed[["platform", "platform_listing_id"]].drop_duplicates() sku_map["sku"] = "SK" + sku_map.platform_listing_id.str[:8] print("shop mapping", len(shop_map), "rows ->", shop_map.store_id.nunique(), "stores") print("sku mapping ", len(sku_map), "rows ->", sku_map.sku.nunique(), "SKUs") print("stores by platforms:", shop_map.groupby("store_id").platform.nunique().value_counts().sort_index().to_dict()) print("SKUs by platforms: ", sku_map.groupby("sku").platform.nunique().value_counts().sort_index().to_dict()) # shop mapping 6911 rows -> 2970 stores # sku mapping 47148 rows -> 32216 SKUs # stores by platforms: {1: 694, 2: 611, 3: 1665} # SKUs by platforms: {1: 21460, 2: 6580, 3: 4176}

Join, and count what did not map. Use a left join so an unmapped line stays in the frame with an empty master id instead of silently disappearing. This is the check store ops and catalogue run every week.

★ Step 6 · the unmapped-rows checkm = (conformed .merge(shop_map, on=["platform", "platform_shop_id"], how="left") .merge(sku_map, on=["platform", "platform_listing_id"], how="left")) print(len(m), "lines after mapping") print("unmapped store:", m.store_id.isna().sum(), " unmapped SKU:", m.sku.isna().sum()) # 110197 lines after mapping # unmapped store: 0 unmapped SKU: 0

Compute GMV both ways, and by platform. Print both, with names that say what they count. The per-platform split reflects the constructed weights, not any real marketplace.

★ Step 7 · one schema, two GMVswith_ship = (m.item_price + m.shipping).sum() items_only = m.item_price.sum() print(f"GMV with shipping R$ {with_ship:,.2f}") print(f"GMV without shipping R$ {items_only:,.2f}") print(f"shipping share {m.shipping.sum() / with_ship * 100:.2f} percent") print(m.groupby("platform").item_price.sum().round(2).to_dict()) # GMV with shipping R$ 15,419,773.75 # GMV without shipping R$ 13,221,498.11 # shipping share 14.26 percent # {'A': 6050000.43, 'B': 4528552.65, 'C': 2642945.03}

Keep COLUMN_MAP next to the session 1 registry, in the same file. The registry says what each table is; the column map says how each platform spells it. Session 3 reads the conformed frame to test join keys, and session 7 reads it to compute the registered GMV. One file, imported everywhere, or the dialects start leaking back into notebooks.

Homework

Before session 3

30 min
  1. Add a fourth constructed platform, D, with its own column names and a currency column. Extend COLUMN_MAP and SCHEMA so the conform step still prints 110,197 lines when you re-split four ways.
  2. Delete 1 percent of the rows from sku_map at random, with a seed, and re-run step 6. Report the unmapped count by platform, and write the one-line message you would send the catalogue owner.
  3. Compute GMV a third way: all order statuses rather than delivered only. Write the three definitions as three one-line sentences, each starting with the word it counts.
  4. In an estate you work on, find where the mapping from a platform's product id to your own lives. If the answer is a spreadsheet, write down who last edited it and when.
Check yourself

Three questions

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

1 · Three exports call the shipping fee shipping_fee, shipping_amount and shipping_fee. Why conform instead of concatenating the three files as they are?

The union is not wrong, it is expensive: the dialects leak into every notebook and drift in each. The column map puts them in one place, and the platform column is what the conformed schema keeps so the three can still be compared.

2 · On the full file the shop mapping has 6,911 rows and 2,970 stores, 1,665 of them on all three platforms. What makes a cross-platform store view possible?

In a real estate each platform issues its own shop id and none agrees with the others. Olist's ids agree only because Olist issued them. The mapping table is the asset that turns three ids into one store, and without its owner and its weekly check it decays.

3 · The same 110,197 delivered lines give R$ 15,419,773.75 or R$ 13,221,498.11. What is the right move?

Both numbers are correct; the 14.26 percent gap is a definition, not an error. Sea's choice shows a listed marketplace writing its definition down, not that one answer is right. The registry in session 7 is where the choice lives.

Sources covered

What this session teaches, and where it came from

✓The conformed schemaPractice: one column map per platform, one function, a platform column; every count run on the full Olist file in this build
✓Shop and SKU mapping tablesPractice; the 6,911 to 2,970 and 47,148 to 32,216 counts measured on Brazilian E-Commerce Public Dataset by Olist, Kaggle, CC BY-NC-SA 4.0
✓GMV with and without shippingSea Limited, Annual Report on Form 20-F for the fiscal year 2019, "Conventions" definitions, read at source; the two Olist totals measured
◐The three-way split and the export column namesConstructed for teaching; the orders, prices, shipping fees and sellers under them are real

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

Analyst session 2 cheat sheet · pin this

Conform, do not stackOne column map per platform, one function; downstream reads one schema.
Platform is a columnNever a folder. A new marketplace is a new value, not a new copy of every table.
The silent driftsType, currency, timezone. Nothing errors; write each as a rule in the conform step.
Two owned tablesShop and SKU mapping. 1,665 of 2,970 stores are on all three platforms.
Check unmapped weeklyLeft join, count the empty master ids, send it to a named owner.
Name the GMVR$ 15.42M with shipping, R$ 13.22M without. Write the choice once, in the registry.