The request

From Jordan Reyes, Head of Marketing
Your goal
Step 1 of 3. Check the data. Set result to a one-row DataFrame with columns sessions (all rows), distinct_ids (distinct session_id), anonymous (customer_id is null), anonymous_converted (anonymous and converted), empty_sessions (pages_viewed < 1), converted_under_3_pages (converted with pages_viewed < 3) and converted (all converted sessions).
- Use the
web_sessionsDataFramenot done yet - Show exactly
sessionsdistinct_idsanonymousanonymous_convertedempty_sessionsconverted_under_3_pagesconvertednot done yet - 1 row, sorted as askednot done yet
Step 2 of 3. Build the funnel by device. Set result to a DataFrame with columns device, sessions, engaged (pages_viewed >= 3), known_engaged (engaged and customer_id not null), converted, engaged_pct (engaged * 100 / sessions, rounded to 1), close_pct (converted * 100 / known_engaged, rounded to 1) and conversion_pct (converted * 100 / sessions, rounded to 2). Sort by device, default index.
- Use the
web_sessionsDataFramenot done yet - Show exactly
devicesessionsengagedknown_engagedconvertedengaged_pctclose_pctconversion_pctnot done yet - 3 rows, sorted as askednot done yet
Step 3 of 3. Follow the mix by year. Set result to a DataFrame with columns yr (year of started_at), sessions, mobile_share_pct (mobile sessions * 100 / sessions, rounded to 1), desktop_conv_pct, mobile_conv_pct and tablet_conv_pct (each device's converted * 100 / its sessions, rounded to 2) and overall_conv_pct (all converted * 100 / all sessions, rounded to 2). Sort by yr, default index.
- Use the
web_sessionsDataFramenot done yet - Show exactly
yrsessionsmobile_share_pctdesktop_conv_pctmobile_conv_pcttablet_conv_pctoverall_conv_pctnot done yet - 3 rows, sorted as askednot done yet
This case unlocks when you finish any one of:
- SQL · CASE, dates and strings
- 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
| session_id | customer_id | started_at | device | source |
|---|---|---|---|---|
| 1 | NaN | 2023-01-01 23:02:24 | mobile | paid |
| 2 | 18 | 2023-01-01 15:07:49 | desktop | paid |
| 3 | NaN | 2023-01-01 16:55:46 | desktop | direct |
| 4 | NaN | 2023-01-01 12:43:28 | mobile | organic |
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
| session_id | customer_id | started_at | device | source |
|---|---|---|---|---|
| 1 | NaN | 2023-01-01 23:02:24 | mobile | paid |
| 2 | 18 | 2023-01-01 15:07:49 | desktop | paid |
| 3 | NaN | 2023-01-01 16:55:46 | desktop | direct |
| 4 | NaN | 2023-01-01 12:43:28 | mobile | organic |
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
| session_id | customer_id | started_at | device | source |
|---|---|---|---|---|
| 1 | NaN | 2023-01-01 23:02:24 | mobile | paid |
| 2 | 18 | 2023-01-01 15:07:49 | desktop | paid |
| 3 | NaN | 2023-01-01 16:55:46 | desktop | direct |
| 4 | NaN | 2023-01-01 12:43:28 | mobile | organic |
Mote checks your result
Press Run. Each row that matches the goal becomes a firefly in your lantern.
What Jordan gets, and why
DataFrames
Loaded for you as pandas DataFrames, plus pd and np.
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 |
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 |
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 |
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 |
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
web_sessions is a tracking table: every visit to the site, its device and source, how many pages
it saw and whether it ended in a purchase. Tracking tables are full of holes, so before you build a
funnel on one, find out what it can and cannot show. Step 1 asks four questions in one pass:
- Is any session counted twice? Compare
len(s)withs["session_id"].nunique(). - Are there empty sessions? A visit with zero pages is a tracking glitch.
- Who can convert? Count anonymous sessions, and how many of them converted.
- Does the definition hold? Count conversions with fewer than three pages.
A condition on a column gives a boolean Series, and summing it counts the True values:
anon = s["customer_id"].isna()
(anon & s["converted"]).sum() # 0
The zero is the most important number in the case. More than half the sessions can never show a
purchase, because purchases are only recorded against an account. The rates you build next are
rates of recorded purchases, and the funnel needs a stage for signing in. Wrapping each count in
int() turns NumPy integers into plain numbers for the one-row DataFrame.
A funnel is a chain of boolean columns
A funnel follows the same sessions through stages that narrow down: all sessions, engaged
(pages_viewed >= 3), engaged and signed in, converted. In pandas each stage is a boolean column,
and named aggregation sums them per device in one call:
result = s.groupby("device", as_index=False).agg(
sessions=("session_id", "count"),
engaged=("engaged", "sum"),
known_engaged=("known_engaged", "sum"),
converted=("converted", "sum"),
)
The rates divide a stage by the one before it, so you can see where people drop out:
engaged_pct is the browsing step, close_pct is the checkout step.
Reading the funnel
| device | engaged_pct | close_pct | conversion_pct |
|---|---|---|---|
| desktop | 44.1 | 21.4 | 3.87 |
| mobile | 44.1 | 12.9 | 2.41 |
| tablet | 44.8 | 14.6 | 2.81 |
The browsing step is the same on every device. The checkout step is where mobile falls behind. That changes the brief for the mobile team: the product pages are doing their job; the basket, the sign-in and the payment form are where to look.
Why the year view matters
Step 3 repeats the conversion rate by year, one column per device. A neat trick: multiply a
boolean by 100 and its mean is a percentage. unstack() then turns the device level of the index
into columns. Mobile’s share of traffic grew every year, and mobile converts less, so the overall
rate is a weighted average whose weights keep moving towards the low rate. Every device improved
from 2023 to 2025, while the overall rate grew much less. If you kept 2023’s device mix and applied
2025’s rates, the overall rate would be about 3.24% instead of 3.08%. Always split a rate by its
segments before you call it a trend.
Pitfalls
== Noneand== np.nannever match; use.isna()and.notna().&binds tighter than<, so wrap each comparison in brackets.- Dividing by
sessionsfor the close rate mixes stages: divide by the stage just before.
Lantern lit!
Practised: data validationgroupbynamed aggregationboolean maskfunnelconversion ratemix shiftsimpsons paradox
- Solved
- No hints
- First run
