Skip to content

joins.ipynb: the shipped aggregates are inflated by join fan-out (store 42 revenue 8x too high) #2

Description

@cdelmonte-zg

Where

pyspark/joins.ipynb, the "Ship it to Dev" section and "Solution 1 - loyalty attachment rate per store".

The problem

reconciled joins pos_transactions to the loyalty redemptions on store_id alone:

reconciled = pos_transactions.join(loyalty_normalized, on="store_id", how="left")

Both tables have multiple rows per store, so this is a many-to-many join and every transaction row is duplicated once per redemption at that store. With the notebook's own data, store 42 has 10 transactions and 8 redemptions, so reconciled contains 10 x 8 = 80 rows for store 42. The by_store aggregate that the story presents as the final, correct deliverable therefore reports:

  • total_revenue = the real revenue multiplied by 8 (each amount summed once per redemption);
  • tx_count = 80 instead of 10 (F.count("tx_id") counts joined rows, not transactions).

The narrative then celebrates the result ("ok this should work... YOU ARE A LIFESAVER"), so a reader walks away having learned to hand finance numbers inflated by fan-out - which is exactly the class of bug the notebook is trying to teach people to avoid.

The same fan-out breaks Solution 1 (the loyalty attachment rate). After the same join, for any store with at least one redemption every joined row has a non-null redemption_id, so

F.count("redemption_id") / F.count("tx_id")

is 80/80 = 1.00 for store 42 (the intended answer is 8/10 = 0.8). The metric degenerates to exactly 1.0-or-0.0 for every store, while the surrounding text says "the counts work out correctly".

Suggested fix

Join on the real relationship key - with this data, ["store_id", "customer_id"] - or keep the store-level join but aggregate the two tables separately before joining:

tx_by_store = pos_transactions.groupBy("store_id").agg(
    F.sum("amount").alias("total_revenue"),
    F.count("tx_id").alias("tx_count"),
)
red_by_store = loyalty_normalized.groupBy("store_id").agg(
    F.count("redemption_id").alias("redemption_count"),
    F.sum("points_redeemed").alias("total_points_redeemed"),
)
by_store = tx_by_store.join(red_by_store, "store_id", "left")

(Aggregating before joining also fixes the attachment rate: redemption_count / tx_count becomes 8/10.)

This could even be turned into a teaching moment: run the naive store-only join first, show the inflated numbers, and let the reader catch the fan-out - it is a better interview lesson than the type-mismatch bug the notebook already covers.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions