The request

From Catherine Hale, Chief Executive Officer
Your goal
Step 1 of 3. Set result to the number of rows in order_items (an int) with a quantity of 0 or less.
- Use the
order_itemsDataFramenot done yet - Store a number in
resultnot done yet
Step 2 of 3. Using only order lines with quantity greater than 0 that belong to orders with status 'completed' placed in 2025, set result to a DataFrame with columns month (text 'YYYY-MM') and revenue (sum of quantity * unit_price * (1 - discount_pct / 100), rounded to 2), one row per month, sorted by month ascending, index reset.
- Use the
ordersDataFramenot done yet - Use the
order_itemsDataFramenot done yet - Show exactly
monthrevenuenot done yet
Step 3 of 3. Deduplicate customers by normalised email (stripped, lowercased), keeping the row with the lowest customer_id in each group. Set result to a DataFrame with columns segment and n_customers (deduplicated customers per segment), sorted by segment ascending, index reset.
- Use the
customersDataFramenot done yet - Show exactly
segmentn_customersnot done yet - 2 rows, sorted as askednot done yet
Step 1 · your code
Loading the editor…
Reference solution
| order_item_id | order_id | product_id | quantity | unit_price |
|---|---|---|---|---|
| 1 | 1001 | 121 | 9 | 31.99 |
| 2 | 1001 | 154 | 3 | 11.99 |
| 3 | 1002 | 154 | 4 | 11.99 |
| 4 | 1002 | 194 | 1 | 149.00 |
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
| 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.
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 |
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 |
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
Clean, then count
Board numbers get compared with finance’s numbers, last quarter’s deck and the CEO’s memory. When they disagree, the first question is always “what did you include?”. A clean report answers that question before anyone asks: it removes known-bad rows, uses a stated definition of revenue, and counts real customers, not records.
Step 1: size the problem
Before excluding anything, measure it:
(order_items["quantity"] <= 0).sum()
order_items[order_items["quantity"] <= 0].head()
Zero and negative quantities are data-entry errors here (they’re never returned items). Knowing it’s 24 lines out of about 30,000 tells you the effect is small but real, and lets you write a one-line footnote: “24 order lines with invalid quantities excluded”.
Never fix bad rows by guessing (flipping negatives to positive, say) unless someone who owns the data confirms that’s the right correction.
Step 2: a clean monthly revenue series
The definition, written down first: line revenue after discount, valid quantities only, completed orders only, by month of the order date, 2025.
good = order_items[order_items["quantity"] > 0]
good = good.assign(line_revenue=good["quantity"] * good["unit_price"] * (1 - good["discount_pct"] / 100))
completed = orders.loc[orders["status"] == "completed", ["order_id", "order_date"]]
lines = good.merge(completed, on="order_id", how="inner", validate="many_to_one")
The inner merge does two jobs: it brings in the order date and drops lines from cancelled, refunded and pending orders. Then filter to 2025 and group by a month label:
lines_2025 = lines[lines["order_date"].dt.year == 2025]
lines_2025.assign(month=lines_2025["order_date"].dt.strftime("%Y-%m")).groupby("month", as_index=False)...
strftime("%Y-%m") gives text like '2025-11', which sorts correctly and reads well. Expect November
and December to stand out: Black Friday, Cyber Monday and the holidays lift them well above every
other month, while February is the quietest.
order_date includes a time, and that’s fine for month grouping. If you ever filter with
<= "2025-12-31", remember that means midnight at the start of December 31, which drops that
whole day. Use < "2026-01-01" or .dt.year == 2025 instead.
Step 3: real customers
deduped = (
customers.assign(email_key=customers["email"].str.strip().str.lower())
.sort_values("customer_id")
.drop_duplicates(subset="email_key", keep="first")
)
deduped.groupby("segment", as_index=False).agg(n_customers=("customer_id", "count"))
Sorting by customer_id before dropping makes “keep the original signup” an explicit rule rather
than an accident of file order. The deduplicated total should be exactly 18 lower than
len(customers); check it.
Note this counts everyone who signed up. If the board wants active customers, that’s a different definition (customers with a completed order in 2025) and a different number. Say which one the slide shows.
What a good cleaning step looks like
- It’s measured: you know how many rows it touches.
- It’s explained: one sentence on why.
- It’s reproducible: code, not manual edits in a spreadsheet.
- It’s checked: totals before and after reconcile.
Common mistakes
- Filtering quantity after aggregating, when the bad lines are already inside the totals.
- Grouping by
dt.monthalone, which merges months from different years if the year filter is missing. - Dropping duplicates without sorting, so a duplicate record might survive instead of the original.
Lantern lit!
Practised: data qualityfilteringdatesmergegroupbydrop duplicates
- Solved
- No hints
- First run
