The request

From Laura Bennett, Head of Finance
Your goal
Step 1 of 3. Check the discounts. Merge order_items with orders and label each line with coupon = coupon_code, NaN filled with 'none'. Set result to a DataFrame with columns coupon, orders (distinct order ids), lines, min_discount and max_discount (the smallest and largest discount_pct), sorted by coupon, default index. Use all orders, whatever the status.
- Use the
order_itemsDataFramenot done yet - Show exactly
couponorderslinesmin_discountmax_discountnot done yet - 7 rows, sorted as askednot done yet
Step 2 of 3. Total each coupon. Use completed orders and lines with quantity > 0. Build order_totals, one row per order with coupon (as in step 1), gross (value before discount) and net (value after it). Set result to a DataFrame with columns coupon, orders, revenue (sum of net, rounded to 2), aov (revenue per order, rounded to 2), discount_cost (gross minus net, rounded to 2) and revenue_share_pct (the coupon's share of all net revenue, rounded to 1), sorted by coupon, default index.
- Use the
ordersDataFramenot done yet - Use the
order_itemsDataFramenot done yet - Show exactly
couponordersrevenueaovdiscount_costrevenue_share_pctnot done yet
Step 3 of 3. Compare like for like. Build order_totals as in step 2 and add day = order_date normalised to midnight (.dt.normalize()). For each seasonal coupon (BFCM25, HOLIDAY20, SPRING15, SUMMER15), find the days it was used, then look at every completed order placed on those days. Set result to a DataFrame with columns coupon, coupon_orders and coupon_basket (that coupon's orders and their average value before discount), coupon_aov (their average net value), no_coupon_orders and no_coupon_aov (orders with no code on the same days and their average net value). Round averages to 2, sort by coupon, default index.
- Use the
ordersDataFramenot done yet - Use the
order_itemsDataFramenot done yet - Show exactly
couponcoupon_orderscoupon_basketcoupon_aovno_coupon_ordersno_coupon_aovnot done yet
This case unlocks when you finish any one of:
- SQL · Aggregates and GROUP BY
- Python · New columns, groupby and aggregation
- Excel · Conditional totals
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
| 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
| 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 Laura 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
What you are checking, and why
The coupon lives in two places: the code on orders["coupon_code"] and the discount on every line
in order_items["discount_pct"]. Every number in this case is built from the second, so first
prove it agrees with the first:
lines = order_items.merge(orders[["order_id", "coupon_code"]], on="order_id")
lines["coupon"] = lines["coupon_code"].fillna("none")
lines.groupby("coupon")["discount_pct"].agg(["min", "max"])
If a code’s minimum and maximum are equal, all its lines carry the same discount. Look for three
other things in the same result: a code written two ways (welcome10 next to WELCOME10), orders
without a code that still carry a discount, and how many orders have no code at all. The
fillna("none") matters: groupby drops NaN keys unless you tell it otherwise.
What each coupon cost
Step 2 rolls the lines up to one row per order first, because a coupon is an order-level choice: the average order value must divide by orders, not by lines.
order_totals = lines.groupby(["order_id", "coupon"], as_index=False)[["gross", "net"]].sum()
Then group order_totals by coupon. Revenue is the net sum, AOV is net per order, the discount cost
is gross minus net, and the share divides each coupon’s net by the grand total g["net"].sum(),
the same total for every row.
Like for like: the same days
Every coupon order averages less than an order without a code. That sounds damning, but the seasonal codes run in particular weeks, and Black Friday baskets are smaller across the board. The fair comparison puts each coupon next to the orders placed on the same days without a code.
coupon_days lists the days each seasonal code was used. Merging it with order_totals on day
gives every order placed on those days, once per coupon. Because both tables have a coupon
column, suffixes=("", "_order") keeps the coupon being studied as coupon and names the order’s
own code coupon_order. Two boolean filters then split the rows into that coupon’s orders and the
orders without a code.
WELCOME10 and FREESHIP are left out of this step because they run every day: for them, “the same days” means the whole year.
Reading the result
| coupon | coupon_basket | coupon_aov | no_coupon_aov |
|---|---|---|---|
| BFCM25 | 536.54 | 402.41 | 483.06 |
| HOLIDAY20 | 482.84 | 386.27 | 479.56 |
| SPRING15 | 634.59 | 539.40 | 574.96 |
| SUMMER15 | 671.75 | 570.98 | 658.07 |
The basket before the discount is about the same as, or a little above, a normal order on those days. So the codes did not make people buy much more; they mostly paid less for a similar basket.
Pitfalls
.dt.dategives Python date objects;.dt.normalize()keeps a datetime column that merges fast.- Filtering
quantity > 0matters: three coupon lines have a zero or negative quantity. - FREESHIP’s cost is shipping, which is not in these totals. Mention it rather than reporting $0.
Lantern lit!
Practised: data validationfillnagroupbynamed aggregationmergelike for like
- Solved
- No hints
- First run
