1. Lighthouse

Level 6 · Boss: the clean 2025 report

The request

From Catherine Hale, Chief Executive Officer

“For the board pack I need 2025 revenue month by month and our real customer count by segment. Last time the numbers didn't match finance. This time, clean first.”

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_items DataFramenot done yet
  • Store a number in resultnot done yet

Step 1 · your code

Python

Loading the editor…

    ResultPreview of order_items · press Run to see yours
    Preview of order_items
    order_item_idorder_idproduct_idquantityunit_price
    11001121931.99
    21001154311.99
    31002154411.99
    410021941149.00

    Mote checks your result

    Press Run. Each row that matches the goal becomes a firefly in your lantern.

    Step 2 · your code

    Python

    Loading the editor…

      ResultPreview of orders · press Run to see yours
      Preview of orders
      order_idcustomer_idorder_datestatuschannel
      10011252023-01-01 20:51:19completedweb
      10021562023-01-02 07:58:46completedweb
      1003222023-01-02 10:25:27completedmarketplace
      1004482023-01-02 11:21:51completedmarketplace

      Mote checks your result

      Press Run. Each row that matches the goal becomes a firefly in your lantern.

      Step 3 · your code

      Python

      Loading the editor…

        ResultPreview of customers · press Run to see yours
        Preview of customers
        customer_idfirst_namelast_nameemailcity
        1OwenHudsonowen.hudson@icloud.comSan Francisco
        2DouglasMendozadmendoza69@gmail.comAtlanta
        3HenryBrookshbrooks@lakesidecafe.comLos Angeles
        4BrianGrahambgraham@bluecedarhomes.comSeattle

        Mote checks your result

        Press Run. Each row that matches the goal becomes a firefly in your lantern.

        DataFrames

        Loaded for you as pandas DataFrames, plus pd and np.

        order_items30,338 rows · 6 columns
        • order_item_idinteger
        • order_idinteger
        • product_idinteger
        • quantityinteger
        • unit_pricedecimal
        • discount_pctdecimal
        First 5 rows of order_items
        order_item_idorder_idproduct_idquantityunit_pricediscount_pct
        11001121931.990.00
        21001154311.990.00
        31002154411.990.00
        410021941149.000.00
        51002193219.990.00
        orders12,000 rows · 7 columns
        • order_idinteger
        • customer_idinteger
        • order_datetimestamp
        • statusvarchar
        • channelvarchar
        • shipping_feedecimal
        • coupon_codevarchar
        First 5 rows of orders
        order_idcustomer_idorder_datestatuschannelshipping_feecoupon_code
        10011252023-01-01 20:51:19completedweb0.00NaN
        10021562023-01-02 07:58:46completedweb0.00NaN
        1003222023-01-02 10:25:27completedmarketplace4.99WELCOME10
        1004482023-01-02 11:21:51completedmarketplace4.99WELCOME10
        1005822023-01-02 11:43:36completedweb0.00WELCOME10
        customers2,018 rows · 9 columns
        • customer_idinteger
        • first_namevarchar
        • last_namevarchar
        • emailvarchar
        • cityvarchar
        • statevarchar
        • signup_datedate
        • segmentvarchar
        • referral_sourcevarchar
        First 5 rows of customers
        customer_idfirst_namelast_nameemailcitystatesignup_datesegmentreferral_source
        1OwenHudsonowen.hudson@icloud.comSan FranciscoCA2022-06-02consumersearch
        2DouglasMendozadmendoza69@gmail.comAtlantaGA2022-06-02consumerNaN
        3HenryBrookshbrooks@lakesidecafe.comLos AngelesCA2022-06-04businessNaN
        4BrianGrahambgraham@bluecedarhomes.comSeattleWA2022-06-06businesspinterest
        5KyleJimenezkjimenez27@yahoo.comIndianapolisIN2022-06-07consumerNaN
        products120 rows · 8 columns
        • product_idinteger
        • namevarchar
        • categoryvarchar
        • subcategoryvarchar
        • unit_pricedecimal
        • unit_costdecimal
        • launched_ondate
        • is_activeboolean
        First 5 rows of products
        product_idnamecategorysubcategoryunit_priceunit_costlaunched_onis_active
        101Vale Velvet LoveseatFurnitureSofas1659.00NaN2023-09-11true
        102Alder Oak Coffee TableFurnitureTables919.00503.502024-05-16true
        103Orchard Rattan Dining ChairFurnitureChairs789.00429.872023-05-27true
        104Upland Oak BookcaseFurnitureStorage429.00214.702022-12-20true
        105Avery Leather Bed FrameFurnitureBeds449.00232.702021-08-31true
        returns1,673 rows · 5 columns
        • return_idinteger
        • order_item_idinteger
        • returned_ondate
        • reasonvarchar
        • refund_amountdecimal
        First 5 rows of returns
        return_idorder_item_idreturned_onreasonrefund_amount
        1142023-01-10damaged83.69
        2422023-01-10wrong_item19.99
        31052023-01-14wrong_item10.99
        4242023-01-16changed_mind26.99
        51262023-01-16late20.99
        employees60 rows · 8 columns
        • employee_idinteger
        • first_namevarchar
        • last_namevarchar
        • departmentvarchar
        • titlevarchar
        • manager_idinteger
        • hire_datedate
        • salaryinteger
        First 5 rows of employees
        employee_idfirst_namelast_namedepartmenttitlemanager_idhire_datesalary
        1CatherineHaleExecutiveChief Executive OfficerNaN2016-03-01285000
        2RobertKimExecutiveChief Operating Officer12017-01-09235000
        3MeganCollinsSalesHead of Sales12018-04-16172000
        4ChloeGutierrezSalesAccount Executive32020-01-2172000
        5FrankBellSalesAccount Executive32023-05-2173000
        web_sessions40,000 rows · 7 columns
        • session_idinteger
        • customer_idinteger
        • started_attimestamp
        • devicevarchar
        • sourcevarchar
        • pages_viewedinteger
        • convertedboolean
        First 5 rows of web_sessions
        session_idcustomer_idstarted_atdevicesourcepages_viewedconverted
        1NaN2023-01-01 23:02:24mobilepaid1false
        2182023-01-01 15:07:49desktoppaid1false
        3NaN2023-01-01 16:55:46desktopdirect7false
        4NaN2023-01-01 12:43:28mobileorganic3false
        5NaN2023-01-01 13:06:21mobilesocial2false
        marketing_spend3,276 rows · 3 columns
        • spend_datedate
        • channelvarchar
        • amountdecimal
        First 5 rows of marketing_spend
        spend_datechannelamount
        2023-01-01paid492.99
        2023-01-01email37.38
        2023-01-01social200.53
        2023-01-02paid709.13
        2023-01-02email64.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.month alone, 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.