1. Lighthouse

Level 6 · Boss: where do returns come from?

The request

From Angela Rivera, Head of Customer Support

“Returns are eating our week. Which categories get returned most, which have the highest return rate once you account for how much they sell, and which sales channel carries the refunds?”

Your goal

Step 1 of 3. Set result to a DataFrame with columns category and n_returns (number of returns whose order line is in that category), sorted by n_returns descending then category ascending, index reset.

  • Use the products DataFramenot done yet
  • Use the order_items DataFramenot done yet
  • Show exactly category n_returnsnot done yet

Step 1 · your code

Python

Loading the editor…

    ResultPreview of products · press Run to see yours
    Preview of products
    product_idnamecategorysubcategoryunit_price
    101Vale Velvet LoveseatFurnitureSofas1659.00
    102Alder Oak Coffee TableFurnitureTables919.00
    103Orchard Rattan Dining ChairFurnitureChairs789.00
    104Upland Oak BookcaseFurnitureStorage429.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 products · press Run to see yours
      Preview of products
      product_idnamecategorysubcategoryunit_price
      101Vale Velvet LoveseatFurnitureSofas1659.00
      102Alder Oak Coffee TableFurnitureTables919.00
      103Orchard Rattan Dining ChairFurnitureChairs789.00
      104Upland Oak BookcaseFurnitureStorage429.00

      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 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.

        DataFrames

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

        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
        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
        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
        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
        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

        Map the path first

        returns only knows the order_item_id that came back. Everything else is a join away:

        returns --order_item_id--> order_items --product_id--> products   (category)
                                               --order_id----> orders     (channel)

        Sketching this path before you write code is a good habit for any multi-table question. Note the grain of each hop: one return per line at most (one-to-one), many lines per product and per order (many-to-one). Those are exactly the validate= values to use.

        Step 1: counts by category

        returned = (
            returns
            .merge(order_items[["order_item_id", "product_id"]], on="order_item_id", validate="one_to_one")
            .merge(products[["product_id", "category"]], on="product_id", validate="many_to_one")
        )
        returned.groupby("category", as_index=False).agg(n_returns=("return_id", "count"))

        len(returned) should equal len(returns). If it doesn’t, a join lost or duplicated returns.

        Step 2: rates, not counts

        Raw counts favour whatever sells most. A fair comparison divides by how much each category sold. That needs a denominator from all order lines, not just returned ones:

        lines = order_items.merge(products[["product_id", "category"]], on="product_id")
        lines = lines.assign(is_returned=lines["order_item_id"].isin(returns["order_item_id"]))

        The flag is_returned keeps the table at the line grain, with no merge that could fan out. Then one groupby gives both counts, since the sum of a boolean column counts its True values:

        (
            lines.groupby("category", as_index=False)
            .agg(n_lines=("order_item_id", "count"), n_returns=("is_returned", "sum"))
            .assign(return_rate=lambda d: (d["n_returns"] / d["n_lines"]).round(3))
        )

        assign with a lambda lets you compute a new column from the aggregated table in the same chain. The ranking by rate can differ from the ranking by count, and that difference is the insight: a category might generate many returns simply because it sells a lot, while another is returned unusually often for its volume.

        Returns here include every line of refunded orders, so the rate mixes two causes: whole-order refunds and individual returned items. A careful analysis would split them; mention it if you present this.

        Step 3: money by channel

        refunds = (
            returns
            .merge(order_items[["order_item_id", "order_id"]], on="order_item_id")
            .merge(orders[["order_id", "channel"]], on="order_id")
        )
        refunds.groupby("channel", as_index=False).agg(total_refund=("refund_amount", "sum"))

        refund_amount is a per-return value, so summing it on a table with one row per return is safe. Compare the total with returns["refund_amount"].sum() to confirm.

        Common mistakes

        • Dividing returns by the number of products or orders instead of order lines.
        • Getting return rates over 1, a sure sign of a fan-out in the numerator.
        • Using count on is_returned, which counts all lines, instead of sum.