The request

From Catherine Hale, CEO
Your goal
Step 1 of 3. Measure the mess in customers. Set result to a one-row DataFrame with columns customer_rows (all rows), people (distinct emails after .str.strip().str.lower()), raw_states and clean_states (distinct state before and after .str.strip().str.upper()), raw_cities and clean_cities (the same for city) and missing_city (rows with a missing city).
- Use the
customersDataFramenot done yet - Show exactly
customer_rowspeopleraw_statesclean_statesraw_citiesclean_citiesmissing_citynot done yet - 1 row, sorted as askednot done yet
Step 2 of 3. Rank cleaned states. Set result to a DataFrame with columns state (cleaned), customers (distinct people in that state, from customers, whether they ordered or not), buyers_2025 (distinct customer_id with a counted line in 2025), revenue_2024 and revenue_2025 (net revenue, rounded to 2), growth_pct ((2025 - 2024) * 100 / 2024 from unrounded sums, rounded to 1) and revenue_per_buyer (2025 revenue / buyers_2025, rounded to 2). Keep the top 10 states by revenue_2025, highest first, default index.
- Use the
customersDataFramenot done yet - Use the
ordersDataFramenot done yet - Show exactly
statecustomersbuyers_2025revenue_2024revenue_2025growth_pctrevenue_per_buyernot done yet
Step 3 of 3. Rank metros for a showroom. Use consumer customers only (segment == 'consumer') with a known city. Set result to a DataFrame with columns city (cleaned), state (cleaned), buyers_2025, revenue_2024, revenue_2025 (rounded to 2) and growth_pct (rounded to 1), computed as in step 2. Keep the top 8 metros by revenue_2025, highest first, default index.
- Use the
customersDataFramenot done yet - Use the
ordersDataFramenot done yet - Show exactly
citystatebuyers_2025revenue_2024revenue_2025growth_pctnot done yet
This case unlocks when you finish any one of:
The ticket and lesson are open to read now. Solve every level in one of those chapters, in whichever track you prefer, and this case opens.
Step 1 · your code
Loading the editor…
Reference solution
| customer_id | first_name | last_name | city | |
|---|---|---|---|---|
| 1 | Owen | Hudson | owen.hudson@icloud.com | San Francisco |
| 2 | Douglas | Mendoza | dmendoza69@gmail.com | Atlanta |
| 3 | Henry | Brooks | hbrooks@lakesidecafe.com | Los Angeles |
| 4 | Brian | Graham | bgraham@bluecedarhomes.com | Seattle |
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
| customer_id | first_name | last_name | city | |
|---|---|---|---|---|
| 1 | Owen | Hudson | owen.hudson@icloud.com | San Francisco |
| 2 | Douglas | Mendoza | dmendoza69@gmail.com | Atlanta |
| 3 | Henry | Brooks | hbrooks@lakesidecafe.com | Los Angeles |
| 4 | Brian | Graham | bgraham@bluecedarhomes.com | Seattle |
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
| customer_id | first_name | last_name | city | |
|---|---|---|---|---|
| 1 | Owen | Hudson | owen.hudson@icloud.com | San Francisco |
| 2 | Douglas | Mendoza | dmendoza69@gmail.com | Atlanta |
| 3 | Henry | Brooks | hbrooks@lakesidecafe.com | Los Angeles |
| 4 | Brian | Graham | bgraham@bluecedarhomes.com | Seattle |
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.
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 |
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 |
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 |
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 |
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
What you are checking, and why
Customer addresses are typed by customers, so the same place arrives in several spellings. Before
you group by anything, measure how bad it is. nunique() on the raw column against the same count
on the cleaned values tells you in two lines:
customers["state"].nunique() # 69 labels
customers["state"].str.strip().str.upper().nunique() # 39 states
.str.strip() removes the stray spaces and .str.upper() puts every label in one case. People are
matched the same way, on the cleaned email, because an email is the closest thing the table has to
a person’s identity: the customer_id changes when someone signs up again.
.str methods leave NaN as NaN and nunique() skips it, so the 14 blank cities stay out of the
count instead of becoming a city called “NAN”. isna().sum() counts them; they cannot be placed in a
metro and drop out of step 3.
States first, then metros
Step 2 gives the board the familiar state view. It mixes two sources: customers for how many
people live in each state, and the order lines for who bought and how much. Each side is aggregated
on its own, as a Series indexed by state, and pd.DataFrame({...}) lines them up by that index.
Step 3 is the answer to the showroom question. It keeps consumers only, because business accounts order online and would not visit, and it ranks metros, because a showroom serves a city:
c = customers[(customers["segment"] == "consumer") & customers["city"].notna()]
r = lines.pivot_table(index=["city", "state"], columns="yr", values="revenue", aggfunc="sum")
The pivot columns are the years as integers, so r[2025] - r[2024] subtracts row by row. Compute
the growth on these unrounded totals and round last.
Reading the result
| city | buyers_2025 | revenue_2025 | growth_pct |
|---|---|---|---|
| NEW YORK | 104 | 96,984.37 | −5.9 |
| LOS ANGELES | 78 | 71,631.90 | 30.7 |
| DALLAS | 58 | 66,362.59 | 17.2 |
New York is the largest and Los Angeles the fastest growing of the leaders. The state table would have pointed at California as a whole, and its top-line growth of 0.3% would have hidden that Los Angeles is growing fast.
Pitfalls
- Cleaning a copy and then merging the raw table splits the groups again. Clean once, on
customers, and merge the cleaned columns. customers["city"] != customers["city"].str.strip()isTruefor every NaN, because NaN never equals anything. Count messy values with.notna()in the mask.head(10)withoutsort_valuesreturns the first ten states in alphabetical order.
Lantern lit!
Practised: data cleaningstring methodsnuniquededuplicationmergepivot tableyoy
- Solved
- No hints
- First run
