1. Lighthouse

Level 5 · How long until a return?

The request

From Angela Rivera, Head of Customer Support

“We're thinking about a 30-day return window. For each return reason, how many days after the order do returns arrive on average, and what share would a 30-day window still accept?”

Your goal

Link each return to its order (returns → order_items on order_item_id → orders on order_id). Days to return = returned_on minus the calendar day of order_date (time of day dropped), in whole days. Set result to a DataFrame with columns reason, n_returns (number of returns), avg_days (mean days to return, rounded to 1) and share_30d (share of returns with returned_on on or before the order day + 30 days, between 0 and 1, rounded to 3), one row per reason, sorted by avg_days ascending, index reset.

  • Use the orders DataFramenot done yet
  • Use the order_items DataFramenot done yet
  • Show exactly reason n_returns avg_days share_30dnot done yet

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.

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

    Subtracting dates

    When you subtract one datetime column from another, pandas gives you a Timedelta column: a length of time, not a date.

    r = (
        returns.merge(order_items[["order_item_id", "order_id"]], on="order_item_id")
        .merge(orders[["order_id", "order_date"]], on="order_id")
    )
    (r["returned_on"] - r["order_date"]).head(2)
    # 0   7 days 12:16:24
    # 1   5 days 12:47:48

    The .dt accessor works on Timedeltas too: .dt.days gives the whole days, and .dt.total_seconds() the exact length in seconds (divide by 86,400 for fractional days).

    Watch the time of day

    returned_on is a date (midnight), but order_date is a timestamp with a time. The first return above belongs to an order placed at 11:43 on 2 January 2023 and came back on 10 January: “7 days 12:16” later. .dt.days drops the partial day and says 7, but on the calendar the return arrived 8 days after the order day. To count calendar days, set the order to midnight first with .dt.normalize():

    order_day = r["order_date"].dt.normalize()
    r["days"] = (r["returned_on"] - order_day).dt.days
    r["days"].mean()   # 16.29 (15.29 without normalize)

    A whole day of difference in the average from one missing method call: always check what kind of date each column holds.

    Moving dates: Timedelta and DateOffset

    To shift a date by a fixed length, add a pd.Timedelta:

    order_day + pd.Timedelta(days=30)    # 30 days later
    orders["order_date"] - pd.Timedelta(hours=6)

    For calendar-aware moves, use pd.DateOffset: pd.DateOffset(months=1) moves 31 January to 28 or 29 February, and pd.offsets.MonthEnd(0) rolls a date forward to the end of its month. Timedelta is a fixed number of days; DateOffset follows the calendar. A “30-day window” is a Timedelta; “until the end of next month” is a DateOffset.

    The return window per reason

    Once each row has days and a True/False within_30, a named aggregation answers Angela. The mean of a boolean column is the share of True values:

    #          reason  n_returns  avg_days  share_30d
    #      wrong_item        255       7.9      1.000
    #         damaged        477       8.0      1.000
    #    changed_mind        698      22.7      0.711
    #            late        243      22.9      0.741

    Damaged and wrong items come back within about a week, all inside 30 days. Change-of-mind and late returns take three weeks on average, and roughly a quarter of them arrive after day 30. Overall, 84.2% of returns would fit the window, so the policy hits exactly the customers who changed their mind.

    Common mistakes

    • Subtracting a timestamp with a time of day and reading .dt.days as calendar days.
    • Calling .days on the column instead of .dt.days (it works on one Timedelta, not on a Series).
    • Adding a plain number to a date (order_day + 30), which raises a TypeError. Wrap it in pd.Timedelta(days=30).
    • Using pd.DateOffset(months=1) when the rule really is a fixed number of days, or the reverse.

    Pick what may run on this site. You can change this any time from "Cookie settings" at the bottom of every page.