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…
- CoursePandas Projects
- Lesson3 of 10
- Video19 min
- FormatJupyter notebook · 17 code cells
What you'll learn
Data
No separate download needed — the notebook creates or downloads everything it uses.
📓 Full notebook
Download .ipynbPandas 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__)
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
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)
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)
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()
Part 2: First Look at the Data#
print(employees.shape)
employees.info()
employees['department_id'].value_counts().sort_index()
Part 3: Merging the Two Tables#
staff = employees.merge(departments, on='department_id', how='left')
staff[['name', 'department_name', 'location', 'salary']].head()
print(staff.shape)
staff['department_name'].value_counts()
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)
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}%")
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()
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
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)
pd.crosstab(staff['department_name'], staff['salary_band'])
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')
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')
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.



