1. Lighthouse

Level 2 · Check and summarise headcount and pay in pandas

The request

From Laura Bennett, Head of Finance

“We agreed the plan: headcount, payroll and typical pay by department, then direct reports per manager. The HR export has been hand-edited over the years, so first tell me whether it holds together: anyone listed twice, managers who aren't on the list, salaries that look wrong.”

Your goal

Step 1 of 3. Check the table. Merge employees with a copy of its own employee_id column (renamed mgr_id) using how='left', left_on='manager_id', right_on='mgr_id' and indicator=True. Set result to a one-row DataFrame with employees (rows), distinct_ids, distinct_names (distinct first_name + ' ' + last_name), no_manager (manager_id is missing), unknown_manager (manager_id present but no match) and min_salary and max_salary.

  • Use the employees DataFramenot done yet
  • Show exactly employees distinct_ids distinct_names no_manager unknown_manager min_salary max_salarynot done yet
  • 1 row, sorted as askednot done yet

Step 1 · your code

Python

Loading the editor…

    ResultPreview of employees · press Run to see yours
    Preview of employees
    employee_idfirst_namelast_namedepartmenttitle
    1CatherineHaleExecutiveChief Executive Officer
    2RobertKimExecutiveChief Operating Officer
    3MeganCollinsSalesHead of Sales
    4ChloeGutierrezSalesAccount Executive

    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 employees · press Run to see yours
      Preview of employees
      employee_idfirst_namelast_namedepartmenttitle
      1CatherineHaleExecutiveChief Executive Officer
      2RobertKimExecutiveChief Operating Officer
      3MeganCollinsSalesHead of Sales
      4ChloeGutierrezSalesAccount Executive

      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 employees · press Run to see yours
        Preview of employees
        employee_idfirst_namelast_namedepartmenttitle
        1CatherineHaleExecutiveChief Executive Officer
        2RobertKimExecutiveChief Operating Officer
        3MeganCollinsSalesHead of Sales
        4ChloeGutierrezSalesAccount Executive

        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.

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

        What you are checking, and why

        An HR export looks tidy, but it is often edited by hand: people are re-entered after a role change, managers leave and their reports keep pointing at them. Before you total anything, check three things.

        • Is anyone listed twice? Ids are unique by design, so count distinct names as well.
        • Does every reporting line lead somewhere? A manager_id that matches no employee_id is an orphan. Only the CEO should have no manager at all.
        • Are the salaries plausible? min() and max() catch a monthly figure typed as yearly, or a missing zero.

        The reporting check needs a self-merge: the table merged with a copy of itself, one side for the employee and one for the manager. how="left" keeps every employee, and indicator=True adds a _merge column that says whether a manager row was found:

        checked = e.merge(managers, how="left", left_on="manager_id", right_on="mgr_id", indicator=True)
        checked["_merge"].value_counts()   # both 59, left_only 1

        The one left_only row is the CEO, whose manager_id is missing. An orphan would be a left_only row that does have a manager_id. Here there are none.

        Headcount, payroll, mean and median

        Step 2 is one groupby with named aggregation. The interesting pair is mean and median. The mean adds every salary and divides; the median is the middle salary when they are sorted. In a department of seven where one person is the head, the head’s salary moves the mean a lot and the median hardly at all:

        department headcount mean_salary median_salary
        Engineering 8 149,750 139,500
        Data & Analytics 7 108,000 92,000
        Sales 9 84,000 73,000
        Customer Support 9 59,333 50,000

        For “what does a typical person here earn”, report the median. Payroll is still the sum: that is what the budget pays.

        Span of control

        Span of control is the number of people who report directly to a manager. It is a common input to org design: too narrow and the company is top-heavy, too wide and managers cannot keep up. With an inner merge, every row is one reporting line, so counting rows per manager gives the span. Keep only the columns you need on the left side, or the two title columns collide.

        manager_id is a float column because of the CEO’s missing value. Merging it with the integer mgr_id still matches 3.0 with 3; pandas compares the values, not the types.

        The spans here run from 2 (Anna Chen, Sales Manager) to 7. Support’s two managers have 5 and 3.

        Pitfalls

        • An inner merge in the check hides the very rows you are looking for.
        • Grouping by name merges two managers who share one; group by id in a big company.
        • Department means of 2 or 5 people can reveal individual salaries. Keep them out of reports that go beyond Finance and HR.

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