Mathew K Analytics

Lesson 3 · Pandas Projects

HR Analytics with Pandas: Turnover & Retention Metrics in Python

A complete, standalone tutorial: build two related synthetic tables, merge them, and analyze salary and attrition with pandas. No prior pandas experience…

⬇ Download notebookOpen in Colab ↗

What you'll learn

Data

No separate download needed — the notebook creates or downloads everything it uses.

📓 Full notebook

Download .ipynb

Pandas for HR Analytics: Employees and Departments#

  • A complete, standalone tutorial: build two related synthetic tables, merge them, and analyze salary and attrition with pandas.
  • No prior pandas experience needed. Let's jump straight in.

Before You Start#

  • Open a new Jupyter Notebook in VS Code and select your Python interpreter as the kernel.
  • If pandas isn't installed yet, open a terminal in VS Code and run: pip install pandas

Part 1: Building the Dataset#

import pandas as pd
import numpy as np
print(pd.__version__)
2.3.0

Building the Departments Table#

departments = pd.DataFrame({
    'department_id': [1, 2, 3, 4, 5],
    'department_name': ['Engineering', 'Sales', 'Marketing', 'Support', 'Finance'],
    'location': ['Adelaide', 'Sydney', 'Sydney', 'Adelaide', 'Melbourne']
})
departments
department_id department_name location
0 1 Engineering Adelaide
1 2 Sales Sydney
2 3 Marketing Sydney
3 4 Support Adelaide
4 5 Finance Melbourne

Generating the Employees Table#

rng = np.random.default_rng(seed=55)
n_employees = 120
first_names = ['Amir', 'Bianca', 'Carlos', 'Deepa', 'Elin', 'Farid', 'Grace', 'Hiro', 'Ines', 'Jonas', 'Kalani', 'Leila']
last_names = ['Nguyen', 'Okafor', 'Petrov', 'Quinn', 'Rossi', 'Suarez', 'Tanaka', 'Ueda', 'Vance', 'Wren']
print(n_employees)
120
rows = []
hire_start = pd.Timestamp('2019-01-01')
hire_end = pd.Timestamp('2025-12-31')
hire_range_days = (hire_end - hire_start).days

for emp_id in range(1, n_employees + 1):
    name = f'{rng.choice(first_names)} {rng.choice(last_names)}'
    dept_id = int(rng.integers(1, 6))
    hire_date = hire_start + pd.Timedelta(days=int(rng.integers(0, hire_range_days)))
    salary = round(float(rng.uniform(55000, 125000)), 2)
    performance_score = round(float(rng.normal(3.5, 0.6)), 1)
    has_left = rng.random() < 0.18
    if has_left:
        left_date = hire_date + pd.Timedelta(days=int(rng.integers(90, 1500)))
    else:
        left_date = pd.NaT
    rows.append([emp_id, name, dept_id, hire_date, salary, performance_score, left_date])

employees = pd.DataFrame(rows, columns=['employee_id', 'name', 'department_id', 'hire_date', 'salary', 'performance_score', 'left_date'])
print(employees.shape)
(120, 7)
employees.to_csv('employees.csv', index=False)
departments.to_csv('departments.csv', index=False)
employees = pd.read_csv('employees.csv', parse_dates=['hire_date', 'left_date'])
departments = pd.read_csv('departments.csv')
employees.head()
employee_id name department_id hire_date salary performance_score left_date
0 1 Leila Vance 4 2025-02-03 70285.33 4.4 NaT
1 2 Elin Ueda 3 2023-12-06 121296.02 4.0 NaT
2 3 Elin Quinn 2 2023-03-19 57960.00 3.2 2025-05-09
3 4 Leila Wren 3 2020-10-03 123652.47 4.2 NaT
4 5 Grace Suarez 3 2019-01-18 56689.35 2.6 NaT

Part 2: First Look at the Data#

print(employees.shape)
employees.info()
(120, 7)
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 120 entries, 0 to 119
Data columns (total 7 columns):
 #   Column             Non-Null Count  Dtype         
---  ------             --------------  -----         
 0   employee_id        120 non-null    int64         
 1   name               120 non-null    object        
 2   department_id      120 non-null    int64         
 3   hire_date          120 non-null    datetime64[ns]
 4   salary             120 non-null    float64       
 5   performance_score  120 non-null    float64       
 6   left_date          18 non-null     datetime64[ns]
dtypes: datetime64[ns](2), float64(2), int64(2), object(1)
memory usage: 6.7+ KB
employees['department_id'].value_counts().sort_index()
department_id
1    25
2    22
3    28
4    26
5    19
Name: count, dtype: int64

Part 3: Merging the Two Tables#

staff = employees.merge(departments, on='department_id', how='left')
staff[['name', 'department_name', 'location', 'salary']].head()
name department_name location salary
0 Leila Vance Support Adelaide 70285.33
1 Elin Ueda Marketing Sydney 121296.02
2 Elin Quinn Sales Sydney 57960.00
3 Leila Wren Marketing Sydney 123652.47
4 Grace Suarez Marketing Sydney 56689.35
print(staff.shape)
staff['department_name'].value_counts()
(120, 9)
department_name
Marketing      28
Support        26
Engineering    25
Sales          22
Finance        19
Name: count, dtype: int64

Part 4: Salary Bands#

bins = [0, 65000, 85000, 105000, float('inf')]
labels = ['Entry (<65k)', 'Mid (65k-85k)', 'Senior (85k-105k)', 'Lead (105k+)']
staff['salary_band'] = pd.cut(staff['salary'], bins=bins, labels=labels)
staff['salary_band'].value_counts().reindex(labels)
salary_band
Entry (<65k)         19
Mid (65k-85k)        35
Senior (85k-105k)    31
Lead (105k+)         35
Name: count, dtype: int64

Part 5: Attrition Analysis#

staff['has_left'] = staff['left_date'].notna()
print(staff['has_left'].sum())
print(f"Overall attrition rate: {staff['has_left'].mean() * 100:.1f}%")
18
Overall attrition rate: 15.0%
staff['tenure_days'] = (staff['left_date'].fillna(pd.Timestamp('2026-08-15')) - staff['hire_date']).dt.days
staff[['name', 'hire_date', 'left_date', 'tenure_days']].head()
name hire_date left_date tenure_days
0 Leila Vance 2025-02-03 NaT 558
1 Elin Ueda 2023-12-06 NaT 983
2 Elin Quinn 2023-03-19 2025-05-09 782
3 Leila Wren 2020-10-03 NaT 2142
4 Grace Suarez 2019-01-18 NaT 2766
attrition_by_dept = staff.groupby('department_name').agg(
    headcount=('employee_id', 'count'),
    attrition_rate_pct=('has_left', lambda s: round(s.mean() * 100, 1)),
    avg_tenure_days=('tenure_days', 'mean')
).round(1).sort_values('attrition_rate_pct', ascending=False)
attrition_by_dept
headcount attrition_rate_pct avg_tenure_days
department_name
Support 26 26.9 1285.5
Marketing 28 14.3 1366.6
Sales 22 13.6 1300.1
Finance 19 10.5 1267.8
Engineering 25 8.0 1264.6

Part 6: Department Salary Summary and a Cross-Tab#

salary_by_dept = staff.groupby('department_name')['salary'].agg(['mean', 'median', 'min', 'max']).round(0)
salary_by_dept.sort_values('mean', ascending=False)
mean median min max
department_name
Support 91999.0 92615.0 61869.0 122293.0
Sales 90801.0 95668.0 56321.0 119662.0
Marketing 90557.0 91764.0 56689.0 123652.0
Engineering 89544.0 89342.0 55530.0 124622.0
Finance 84491.0 80170.0 58334.0 124757.0
pd.crosstab(staff['department_name'], staff['salary_band'])
salary_band Entry (<65k) Mid (65k-85k) Senior (85k-105k) Lead (105k+)
department_name
Engineering 4 7 6 8
Finance 5 6 5 3
Marketing 3 10 7 8
Sales 4 3 9 6
Support 3 9 4 10

Part 7: Visualizing the Results#

import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(8, 5))
salary_by_dept['mean'].sort_values(ascending=False).plot(kind='bar', ax=ax, color='steelblue')
ax.set_title('Average Salary by Department')
ax.set_ylabel('Average Salary ($)')
plt.tight_layout()
plt.savefig('avg_salary_by_dept.png', dpi=150)
plt.close(fig)
print('Saved avg_salary_by_dept.png')
Saved avg_salary_by_dept.png
fig, ax = plt.subplots(figsize=(8, 5))
attrition_by_dept['attrition_rate_pct'].plot(kind='bar', ax=ax, color='indianred')
ax.set_title('Attrition Rate by Department')
ax.set_ylabel('Attrition Rate (%)')
plt.tight_layout()
plt.savefig('attrition_by_dept.png', dpi=150)
plt.close(fig)
print('Saved attrition_by_dept.png')
Saved attrition_by_dept.png

Wrap-Up: What You Learned#

  • Building two related synthetic tables and saving them as separate CSV files, like a real HR export.
  • Joining tables together with merge, on a shared ID column.
  • Bucketing a numeric column into readable salary bands with cut.
  • Attrition analysis: notna for a departure flag, tenure calculated from two dates, and a custom lambda inside agg.
  • Multi-statistic department summaries, plus crosstab for quick two-way counts.
  • Department-level, presentation-ready charts with matplotlib.
  • You went from two raw synthetic tables to a full HR report covering salary, tenure, and attrition. If you want the next dataset in this series to land in your feed automatically, subscribing is the move see you in the next one.

Found this useful?

All lessons, notebooks and datasets here are free. If they helped you, a coffee keeps new lessons coming.