The request

From Catherine Hale, CEO
Your goal
Step 1 of 3. Set result to a DataFrame with columns cohort_year (int, year of the first completed order), customers, repeat_customers (2 or more completed orders) and repeat_rate_pct (repeat_customers * 100 / customers, rounded to 2), sorted by cohort_year, with a default index.
- Use the
ordersDataFramenot done yet - Show exactly
cohort_yearcustomersrepeat_customersrepeat_rate_pctnot done yet - 3 rows, sorted as askednot done yet
Step 2 of 3. Only customers whose first completed order is before 2025-10-01 are eligible. Set result to a DataFrame with columns cohort_year, eligible_customers, repeat_90d (eligible customers whose second completed order is at most 90 days after the first: second_order <= first_order + pd.Timedelta(days=90)) and repeat_90d_pct (repeat_90d * 100 / eligible_customers, rounded to 2), sorted by cohort_year, with a default index. Order a customer's orders by order_date, then order_id.
- Use the
ordersDataFramenot done yet - Show exactly
cohort_yeareligible_customersrepeat_90drepeat_90d_pctnot done yet - 3 rows, sorted as askednot done yet
Step 3 of 3. Set result to a DataFrame with columns segment, customers (customers with at least one completed order), repeat_customers (2 or more completed orders), repeat_rate_pct (repeat_customers * 100 / customers, rounded to 2) and avg_orders (completed orders per customer, rounded to 2), sorted by segment, with a default index.
- Use the
ordersDataFramenot done yet - Show exactly
segmentcustomersrepeat_customersrepeat_rate_pctavg_ordersnot done yet - 2 rows, sorted as askednot done yet
This case unlocks when you finish:
The ticket and lesson are open to read now. Solve every level in those chapters, in any order, and this case opens.
Step 1 · your code
Loading the editor…
Reference solution
| order_id | customer_id | order_date | status | channel |
|---|---|---|---|---|
| 1001 | 125 | 2023-01-01 20:51:19 | completed | web |
| 1002 | 156 | 2023-01-02 07:58:46 | completed | web |
| 1003 | 22 | 2023-01-02 10:25:27 | completed | marketplace |
| 1004 | 48 | 2023-01-02 11:21:51 | completed | marketplace |
Mote checks your result
Press Run. Each row that matches the goal becomes a firefly in your lantern.
Step 2 · your code
Loading the editor…
Reference solution
| order_id | customer_id | order_date | status | channel |
|---|---|---|---|---|
| 1001 | 125 | 2023-01-01 20:51:19 | completed | web |
| 1002 | 156 | 2023-01-02 07:58:46 | completed | web |
| 1003 | 22 | 2023-01-02 10:25:27 | completed | marketplace |
| 1004 | 48 | 2023-01-02 11:21:51 | completed | marketplace |
Mote checks your result
Press Run. Each row that matches the goal becomes a firefly in your lantern.
Step 3 · your code
Loading the editor…
Reference solution
| order_id | customer_id | order_date | status | channel |
|---|---|---|---|---|
| 1001 | 125 | 2023-01-01 20:51:19 | completed | web |
| 1002 | 156 | 2023-01-02 07:58:46 | completed | web |
| 1003 | 22 | 2023-01-02 10:25:27 | completed | marketplace |
| 1004 | 48 | 2023-01-02 11:21:51 | completed | marketplace |
Mote checks your result
Press Run. Each row that matches the goal becomes a firefly in your lantern.
What Catherine gets, and why
DataFrames
Loaded for you as pandas DataFrames, plus pd and np.
orders12,000 rows · 7 columns
order_idintegercustomer_idintegerorder_datetimestampstatusvarcharchannelvarcharshipping_feedecimalcoupon_codevarchar
| order_id | customer_id | order_date | status | channel | shipping_fee | coupon_code |
|---|---|---|---|---|---|---|
| 1001 | 125 | 2023-01-01 20:51:19 | completed | web | 0.00 | NaN |
| 1002 | 156 | 2023-01-02 07:58:46 | completed | web | 0.00 | NaN |
| 1003 | 22 | 2023-01-02 10:25:27 | completed | marketplace | 4.99 | WELCOME10 |
| 1004 | 48 | 2023-01-02 11:21:51 | completed | marketplace | 4.99 | WELCOME10 |
| 1005 | 82 | 2023-01-02 11:43:36 | completed | web | 0.00 | WELCOME10 |
customers2,018 rows · 9 columns
customer_idintegerfirst_namevarcharlast_namevarcharemailvarcharcityvarcharstatevarcharsignup_datedatesegmentvarcharreferral_sourcevarchar
| customer_id | first_name | last_name | city | state | signup_date | segment | referral_source | |
|---|---|---|---|---|---|---|---|---|
| 1 | Owen | Hudson | owen.hudson@icloud.com | San Francisco | CA | 2022-06-02 | consumer | search |
| 2 | Douglas | Mendoza | dmendoza69@gmail.com | Atlanta | GA | 2022-06-02 | consumer | NaN |
| 3 | Henry | Brooks | hbrooks@lakesidecafe.com | Los Angeles | CA | 2022-06-04 | business | NaN |
| 4 | Brian | Graham | bgraham@bluecedarhomes.com | Seattle | WA | 2022-06-06 | business | |
| 5 | Kyle | Jimenez | kjimenez27@yahoo.com | Indianapolis | IN | 2022-06-07 | consumer | NaN |
products120 rows · 8 columns
product_idintegernamevarcharcategoryvarcharsubcategoryvarcharunit_pricedecimalunit_costdecimallaunched_ondateis_activeboolean
| product_id | name | category | subcategory | unit_price | unit_cost | launched_on | is_active |
|---|---|---|---|---|---|---|---|
| 101 | Vale Velvet Loveseat | Furniture | Sofas | 1659.00 | NaN | 2023-09-11 | true |
| 102 | Alder Oak Coffee Table | Furniture | Tables | 919.00 | 503.50 | 2024-05-16 | true |
| 103 | Orchard Rattan Dining Chair | Furniture | Chairs | 789.00 | 429.87 | 2023-05-27 | true |
| 104 | Upland Oak Bookcase | Furniture | Storage | 429.00 | 214.70 | 2022-12-20 | true |
| 105 | Avery Leather Bed Frame | Furniture | Beds | 449.00 | 232.70 | 2021-08-31 | true |
order_items30,338 rows · 6 columns
order_item_idintegerorder_idintegerproduct_idintegerquantityintegerunit_pricedecimaldiscount_pctdecimal
| order_item_id | order_id | product_id | quantity | unit_price | discount_pct |
|---|---|---|---|---|---|
| 1 | 1001 | 121 | 9 | 31.99 | 0.00 |
| 2 | 1001 | 154 | 3 | 11.99 | 0.00 |
| 3 | 1002 | 154 | 4 | 11.99 | 0.00 |
| 4 | 1002 | 194 | 1 | 149.00 | 0.00 |
| 5 | 1002 | 193 | 2 | 19.99 | 0.00 |
returns1,673 rows · 5 columns
return_idintegerorder_item_idintegerreturned_ondatereasonvarcharrefund_amountdecimal
| return_id | order_item_id | returned_on | reason | refund_amount |
|---|---|---|---|---|
| 1 | 14 | 2023-01-10 | damaged | 83.69 |
| 2 | 42 | 2023-01-10 | wrong_item | 19.99 |
| 3 | 105 | 2023-01-14 | wrong_item | 10.99 |
| 4 | 24 | 2023-01-16 | changed_mind | 26.99 |
| 5 | 126 | 2023-01-16 | late | 20.99 |
employees60 rows · 8 columns
employee_idintegerfirst_namevarcharlast_namevarchardepartmentvarchartitlevarcharmanager_idintegerhire_datedatesalaryinteger
| employee_id | first_name | last_name | department | title | manager_id | hire_date | salary |
|---|---|---|---|---|---|---|---|
| 1 | Catherine | Hale | Executive | Chief Executive Officer | NaN | 2016-03-01 | 285000 |
| 2 | Robert | Kim | Executive | Chief Operating Officer | 1 | 2017-01-09 | 235000 |
| 3 | Megan | Collins | Sales | Head of Sales | 1 | 2018-04-16 | 172000 |
| 4 | Chloe | Gutierrez | Sales | Account Executive | 3 | 2020-01-21 | 72000 |
| 5 | Frank | Bell | Sales | Account Executive | 3 | 2023-05-21 | 73000 |
web_sessions40,000 rows · 7 columns
session_idintegercustomer_idintegerstarted_attimestampdevicevarcharsourcevarcharpages_viewedintegerconvertedboolean
| session_id | customer_id | started_at | device | source | pages_viewed | converted |
|---|---|---|---|---|---|---|
| 1 | NaN | 2023-01-01 23:02:24 | mobile | paid | 1 | false |
| 2 | 18 | 2023-01-01 15:07:49 | desktop | paid | 1 | false |
| 3 | NaN | 2023-01-01 16:55:46 | desktop | direct | 7 | false |
| 4 | NaN | 2023-01-01 12:43:28 | mobile | organic | 3 | false |
| 5 | NaN | 2023-01-01 13:06:21 | mobile | social | 2 | false |
marketing_spend3,276 rows · 3 columns
spend_datedatechannelvarcharamountdecimal
| spend_date | channel | amount |
|---|---|---|
| 2023-01-01 | paid | 492.99 |
| 2023-01-01 | 37.38 | |
| 2023-01-01 | social | 200.53 |
| 2023-01-02 | paid | 709.13 |
| 2023-01-02 | 64.13 |
The lesson
Same question, same definitions
Catherine’s retention review in pandas, with the same definitions as the SQL level of this case: completed orders only, cohorts by the year of the first completed order, “repeat” means two or more completed orders, and the 90-day rate only counts customers who had 90 days to come back.
Step 1: one row per customer
One row per customer is a groupby without as_index=False, so customer_id stays as the index:
completed = orders[orders["status"] == "completed"]
per_customer = completed.groupby("customer_id").agg(
first_order=("order_date", "min"), n_orders=("order_id", "count")
)
From here every question is a second groupby over this small table. A boolean is_repeat column
makes the counting readable: "size" counts every customer in the cohort, "sum" counts the True
values. .dt.year gives an integer year, the same as SQL’s year().
Look at the 2025 cohort before moving on: its all-time repeat rate is much lower. That is right-censoring, not necessarily a collapse. Someone who first bought in November 2025 has had a few weeks to come back, a 2023 customer three years, so any all-time rate is unfair to recent cohorts. Step 2 fixes that with a fixed 90-day window.
Step 2: the second order with cumcount
To find each customer’s second order, number their orders. groupby(...).cumcount() numbers the
rows inside each group 0, 1, 2, … in the order they currently sit, so sort first, then number:
completed = orders[orders["status"] == "completed"].sort_values(["customer_id", "order_date", "order_id"])
completed["n"] = completed.groupby("customer_id").cumcount() # 0, 1, 2, ...
cumcount starts at 0, so the first order is n == 0 and the second is n == 1. Sorting by
order_id as well as the date breaks ties exactly as the SQL version does.
pivot(index="customer_id", columns="n", values="order_date") turns the two rows per customer into
two columns. It is pivot_table without an aggregation, for when each index/column pair has one
value. The new columns are named after the values of n, 0 and 1, so
.rename(columns={0: "first_order", 1: "second_order"}) gives them readable names (old name on the
left, new name on the right). One-time buyers get NaT (missing timestamp) as their second order. Comparisons with
NaT return False, so they fail the 90-day test with no extra code.
pd.Timedelta(days=90) is a length of time, exactly 90 × 24 hours, and adding it to a datetime
moves it forward: pd.Timestamp("2025-01-10 14:00") + pd.Timedelta(days=90) is
2025-04-10 14:00. It is the pandas spelling of SQL’s INTERVAL 90 DAY. Using (second - first).dt.days <= 90 would also be reasonable but can differ by a day
from the SQL interval near midnight; keeping the same arithmetic in both is what makes the
numbers match.
Step 3: merge for the segment
customers has one row per customer and so does per_customer, so validate="one_to_one" should
pass, and it does. If the duplicate customer records (ids 2001–2018) had orders, this is where
they would show up; they don’t, but the check costs nothing.
("n_orders", "mean") gives orders per customer, the same as SQL’s AVG(n_orders). Select the
columns in the order the task lists before returning.
SQL and pandas side by side
| Idea | SQL | pandas |
|---|---|---|
| customers in a cohort | COUNT(*) |
("is_repeat", "size") |
| repeaters | COUNT(*) FILTER (WHERE n_orders >= 2) |
("is_repeat", "sum") |
| nth order | ROW_NUMBER() |
sort_values + cumcount() |
| rows to columns | MAX(CASE WHEN rn = 2 ...) |
pivot(columns="n") |
| missing second order | NULL |
NaT |
Pitfalls
- Forgetting to sort before
cumcount. It numbers rows in their current order, which is not guaranteed to be by date after a filter or merge. countvssize."count"skips missing values,"size"does not. On a column with no missing values they agree; know which one you mean.- Eligibility on the wrong date. Filter on the first order, not on signup date: customers sign up before they buy, sometimes months before.
Lantern lit!
Practised: cohortrepeat ratecumcounttimedeltamergeright censoring
- Solved
- No hints
- First run
