Exercises — Week 2 — Pandas: SQL You Already Know, in Python¶
What you are building¶
A plan snapshot (churn, ARPU, headcount), a region mix after collapsing events to one row per user, and a five-line join validator.
Predict before you run¶
- Which plan is the leaky bucket?
- If you join raw
user_eventsonto subscriptions, do output rows stay ~49k or explode? - Will churn differ by region enough to change a staffing plan, or is it a small mix shift?
Each task has three hints, closed by default. Open only as far as you need.
Task¶
Work in starter.py. Run from the repo root:
1. Plan snapshot. Churn rate, mean MRR, and user count by plan_type. Which plan is the leaky bucket?
Hint 1 — a nudge
One row per plan, three numbers per row. is_churned is already 0/1 — what is the mean of a column of 0s and 1s?
Hint 2 — the approach
One groupby("plan_type").agg(...) with named aggregations — count for users, mean for churn and MRR — then sort_values so the worst plan is on top.
Hint 3 — most of the code
import pandas as pd
from lib.course_data import find_data_dir
DATA = find_data_dir()
subs = pd.read_csv(DATA / "subscriptions.csv")
snap = subs.groupby("plan_type").agg(
users=("user_id", "count"),
churn_rate=("is_churned", "mean"),
arpu=("mrr", "mean"),
).sort_values("churn_rate", ascending=False)
print(snap.round(3))
2. Region mix. From user_events, each user's most-common region. Left-join onto subscriptions. Does churn differ by region?
Hint 1 — a nudge
user_events has many rows per user; subscriptions has one. What happens to the row count if you join them as they are?
Hint 2 — the approach
Collapse first: one region per user_id (the mode, or value_counts().idxmax()). Drop null regions before the mode (an all-null group has no mode), and mode() can return a tie — take the first value. Then left-join. Users with no events get a null region; that is a segment to report, not a crash to paper over.
Hint 3 — most of the code
events = pd.read_csv(DATA / "user_events.csv", usecols=["user_id", "region"])
region = (
events.dropna(subset=["region"])
.groupby("user_id")["region"]
.agg(lambda s: s.mode().iat[0])
.rename("region")
)
subs_r = subs.merge(region, on="user_id", how="left")
print("null region share:", round(subs_r["region"].isna().mean(), 3))
print(subs_r.groupby("region", dropna=False)["is_churned"].agg(["count", "mean"]).round(3))
3. Quality check. Write a 5-line join validator: input rows, output rows, duplicate user_id count, null share of a key metric, and a raise if output rows > 1.01 × input rows.
Hint 1 — a nudge
This is the grain test you'll reuse every week: count in, count out, duplicates, nulls. Test it by feeding it a join you know is wrong.
Hint 2 — the approach
A function that takes the left frame, the joined frame, a key, and a metric column. Print four numbers, then raise on fan-out. Prove it fires by merging raw events onto subs.
Hint 3 — most of the code
def validate_join(left: pd.DataFrame, out: pd.DataFrame, key: str, metric: str) -> None:
n_in, n_out = len(left), len(out)
dups = int(out.duplicated(key).sum())
nulls = out[metric].isna().mean()
print(f"in={n_in:,} out={n_out:,} dup_{key}={dups:,} null_{metric}={nulls:.1%}")
# raise if the join fanned out
validate_join(subs, subs_r, "user_id", "region")
Success criteria¶
- Snapshot table sorted by churn.
- Region is one value per user before the join.
- Validator raises on a fan-out.
After you run¶
The leaky bucket is usually free in this file. The validator is the habit Week 3's as_of 360 depends on.