People Analytics: Employee Attrition Intelligence Report

Author

Freda Erinmwingbovo, Data Analyst

Published

March 3, 2026

Meridian Group: People Analytics Report

Prepared by: Freda Erinmwingbovo, Data Analyst
Prepared for: Dr. Amara Osei, Chief Executive Officer, Meridian Group
Date: February 2026
Dataset: 1,470 employees · 35 attributes · IBM HR Analytics


Executive Summary

Meridian Group employed 1,470 people at the time of this analysis. Of those, 237 have left the organisation, an attrition rate of 16.1%, above the industry benchmark of 10 to 12% for professional services organisations of comparable size.

This report delivers a seven-theme people analytics investigation identifying who is leaving, why, and what the organisation must do to reverse the trend.

1,470
Total Employees
237
Employees Left
16.1%
Attrition Rate
$6,503
Avg Monthly Income
7.0 yrs
Avg Tenure
# Business Question Priority
1 Overall attrition profile Baseline
2 Department and role attrition High
3 Compensation and attrition High
4 Satisfaction scores and attrition High
5 Overtime impact on attrition Critical
6 Tenure and career progression High
7 Demographic profile of leavers Medium

Data and Methodology

Data Loading and Preparation

Show Code
import pandas as pd
import numpy as np
import matplotlib
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker
import warnings
warnings.filterwarnings('ignore')

df = pd.read_csv('hr-employee-attrition.csv')

# Derived columns
df['AttritionBinary'] = (df['Attrition'] == 'Yes').astype(int)

# Satisfaction label maps
sat_map = {1: 'Low', 2: 'Medium', 3: 'High', 4: 'Very High'}
df['JobSatisfactionLabel']   = df['JobSatisfaction'].map(sat_map)
df['EnvSatisfactionLabel']   = df['EnvironmentSatisfaction'].map(sat_map)
df['RelSatisfactionLabel']   = df['RelationshipSatisfaction'].map(sat_map)
df['WLBLabel']               = df['WorkLifeBalance'].map(sat_map)

# Education map
edu_map = {1:'Below College', 2:'College', 3:'Bachelor',
           4:'Master', 5:'Doctor'}
df['EducationLabel'] = df['Education'].map(edu_map)

# Age bands
def age_band(age):
    if age < 25:   return 'Under 25'
    elif age < 35: return '25 to 34'
    elif age < 45: return '35 to 44'
    elif age < 55: return '45 to 54'
    else:          return '55 and over'

df['AgeBand'] = df['Age'].apply(age_band)

# Income bands
def income_band(inc):
    if inc < 3000:  return 'Under $3K'
    elif inc < 6000: return '$3K to $6K'
    elif inc < 10000: return '$6K to $10K'
    elif inc < 15000: return '$10K to $15K'
    else:            return 'Over $15K'

df['IncomeBand'] = df['MonthlyIncome'].apply(income_band)

# Drop constant columns
df = df.drop(columns=['EmployeeCount', 'Over18', 'StandardHours'])

print(f"Dataset ready: {df.shape[0]:,} employees · {df.shape[1]} attributes")
print(f"\nAttrition breakdown:")
print(df['Attrition'].value_counts())
print(f"\nAttrition rate: {df['AttritionBinary'].mean()*100:.1f}%")
Dataset ready: 1,470 employees · 40 attributes

Attrition breakdown:
Attrition
No     1233
Yes     237
Name: count, dtype: int64

Attrition rate: 16.1%

Portfolio Overview

Show Code
print("MERIDIAN GROUP WORKFORCE OVERVIEW")
print("=" * 45)
print(f"Total employees:          {len(df):,}")
print(f"Employees who left:       {df['AttritionBinary'].sum():,}")
print(f"Current employees:        {(df['AttritionBinary']==0).sum():,}")
print(f"Attrition rate:           {df['AttritionBinary'].mean()*100:.1f}%")
print(f"Departments:              {df['Department'].nunique()}")
print(f"Job roles:                {df['JobRole'].nunique()}")
print(f"Avg age:                  {df['Age'].mean():.1f} years")
print(f"Avg monthly income:       ${df['MonthlyIncome'].mean():,.0f}")
print(f"Avg tenure:               {df['YearsAtCompany'].mean():.1f} years")
print(f"Overtime workers:         "
      f"{(df['OverTime']=='Yes').sum():,} "
      f"({(df['OverTime']=='Yes').mean()*100:.1f}%)")
MERIDIAN GROUP WORKFORCE OVERVIEW
=============================================
Total employees:          1,470
Employees who left:       237
Current employees:        1,233
Attrition rate:           16.1%
Departments:              3
Job roles:                9
Avg age:                  36.9 years
Avg monthly income:       $6,503
Avg tenure:               7.0 years
Overtime workers:         416 (28.3%)

Analysis

Business Question

What is the overall attrition picture at Meridian Group? How does attrition vary across departments and what does the leaver profile look like at the highest level?

Overall Attrition

Show Code
dept_attr = df.groupby('Department').agg(
    Total      = ('AttritionBinary', 'count'),
    Left       = ('AttritionBinary', 'sum'),
    Stayed     = ('AttritionBinary', lambda x: (x==0).sum())
).reset_index()
dept_attr['AttritionRate'] = (
    dept_attr['Left'] / dept_attr['Total'] * 100
).round(1)

display(dept_attr)
Department Total Left Stayed AttritionRate
0 Human Resources 63 12 51 19.0
1 Research & Development 961 133 828 13.8
2 Sales 446 92 354 20.6

Attrition Overview Visualization

Show Code
navy   = '#1a2744'
teal   = '#2a7a6f'
amber  = '#d4722a'
red    = '#b83232'
muted  = '#7a7468'
bg     = '#f5f4f0'
white  = '#ffffff'
border = '#ddd9d0'

fig, axes = plt.subplots(1, 3, figsize=(16, 6))
fig.patch.set_facecolor(bg)
fig.suptitle('Figure: Meridian Group Attrition Overview',
             color=navy, fontsize=13,
             fontfamily='serif', fontweight='bold', y=1.02)

# Plot 1: Overall attrition donut
ax1 = axes[0]
ax1.set_facecolor(bg)
sizes  = [1233, 237]
colors = [teal, red]
labels = ['Active\n1,233', 'Left\n237']
wedges, texts = ax1.pie(sizes, colors=colors,
                        startangle=90,
                        wedgeprops={'width': 0.55,
                                    'edgecolor': white,
                                    'linewidth': 2})
ax1.text(0, 0, '16.1%\nAttrition',
         ha='center', va='center',
         fontsize=12, fontfamily='serif',
         color=navy, fontweight='bold')
ax1.set_title('Overall Attrition Rate',
              color=navy, fontweight='bold',
              pad=12, fontfamily='serif')
ax1.legend(labels, loc='lower center',
           fontsize=9, frameon=False,
           labelcolor=navy)

# Plot 2: Attrition rate by department
ax2 = axes[1]
ax2.set_facecolor(white)
d_colors = [red if r == dept_attr['AttritionRate'].max()
            else teal for r in dept_attr['AttritionRate']]
bars = ax2.bar(dept_attr['Department'],
               dept_attr['AttritionRate'],
               color=d_colors, edgecolor='none', width=0.5)
ax2.set_ylabel('Attrition Rate (%)', color=muted)
ax2.set_title('Attrition Rate by Department',
              color=navy, fontweight='bold',
              pad=12, fontfamily='serif')
ax2.tick_params(colors=muted)
for spine in ax2.spines.values():
    spine.set_color(border)
ax2.grid(color=border, linestyle='--',
         linewidth=0.5, axis='y')
for bar, val in zip(bars, dept_attr['AttritionRate']):
    ax2.text(bar.get_x() + bar.get_width()/2,
             bar.get_height() + 0.3,
             f'{val}%',
             ha='center', color=navy,
             fontsize=10, fontweight='bold')
ax2.set_xticklabels(dept_attr['Department'],
                    rotation=15, ha='right',
                    color=muted, fontsize=8)

# Plot 3: Headcount by department
ax3 = axes[2]
ax3.set_facecolor(white)
x = range(len(dept_attr))
bars_stayed = ax3.bar(x, dept_attr['Stayed'],
                      color=teal, edgecolor='none',
                      width=0.5, label='Active')
bars_left   = ax3.bar(x, dept_attr['Left'],
                      bottom=dept_attr['Stayed'],
                      color=red, edgecolor='none',
                      width=0.5, label='Left',
                      alpha=0.85)
ax3.set_xticks(x)
ax3.set_xticklabels(dept_attr['Department'],
                    rotation=15, ha='right',
                    color=muted, fontsize=8)
ax3.set_ylabel('Headcount', color=muted)
ax3.set_title('Headcount by Department',
              color=navy, fontweight='bold',
              pad=12, fontfamily='serif')
ax3.tick_params(colors=muted)
for spine in ax3.spines.values():
    spine.set_color(border)
ax3.legend(facecolor=bg, edgecolor=border,
           fontsize=9, labelcolor=navy)

plt.tight_layout()
plt.show()

Attrition Overview Findings

Research and Development is the largest department and carries a significant share of total attrition. Sales shows the highest attrition rate, representing a priority intervention target.

Recommendations:

  • Launch an immediate retention investigation in the Sales department. The elevated attrition rate signals systemic issues beyond individual decisions
  • Benchmark Meridian Group’s 16.1% rate against the professional services sector average of 10 to 12% and set a formal 12-month reduction target

Business Question

Which specific job roles carry the highest attrition rates? Does job level influence the likelihood of leaving?

Attrition by Job Role

Show Code
role_attr = df.groupby('JobRole').agg(
    Total         = ('AttritionBinary', 'count'),
    Left          = ('AttritionBinary', 'sum'),
    AvgIncome     = ('MonthlyIncome', 'mean'),
    AvgTenure     = ('YearsAtCompany', 'mean')
).reset_index()
role_attr['AttritionRate'] = (
    role_attr['Left'] / role_attr['Total'] * 100
).round(1)
role_attr = role_attr.sort_values(
    'AttritionRate', ascending=False
)
display(role_attr)
JobRole Total Left AvgIncome AvgTenure AttritionRate
8 Sales Representative 83 33 2626.000000 2.915663 39.8
2 Laboratory Technician 259 62 3237.169884 5.019305 23.9
1 Human Resources 52 12 4235.750000 5.326923 23.1
7 Sales Executive 326 57 6924.279141 7.496933 17.5
6 Research Scientist 292 47 3239.972603 5.113014 16.1
0 Healthcare Representative 131 9 7528.763359 8.374046 6.9
4 Manufacturing Director 145 10 7295.137931 7.600000 6.9
3 Manager 102 5 17181.676471 14.431373 4.9
5 Research Director 80 2 16033.550000 10.937500 2.5

Attrition by Job Level

Show Code
level_attr = df.groupby('JobLevel').agg(
    Total     = ('AttritionBinary', 'count'),
    Left      = ('AttritionBinary', 'sum'),
    AvgIncome = ('MonthlyIncome', 'mean')
).reset_index()
level_attr['AttritionRate'] = (
    level_attr['Left'] / level_attr['Total'] * 100
).round(1)
level_attr['JobLevelLabel'] = level_attr['JobLevel'].map({
    1:'Level 1\n(Junior)', 2:'Level 2\n(Mid)',
    3:'Level 3\n(Senior)', 4:'Level 4\n(Lead)',
    5:'Level 5\n(Director)'
})
display(level_attr)
JobLevel Total Left AvgIncome AttritionRate JobLevelLabel
0 1 543 143 2786.915285 26.3 Level 1\n(Junior)
1 2 534 52 5502.277154 9.7 Level 2\n(Mid)
2 3 218 32 9817.252294 14.7 Level 3\n(Senior)
3 4 106 5 15503.783019 4.7 Level 4\n(Lead)
4 5 69 5 19191.826087 7.2 Level 5\n(Director)

Role and Level Visualization

Show Code
fig, axes = plt.subplots(1, 2, figsize=(16, 7))
fig.patch.set_facecolor(bg)
fig.suptitle('Figure II: Attrition by Job Role and Level',
             color=navy, fontsize=13,
             fontfamily='serif', fontweight='bold', y=1.02)

# Plot 1: Attrition rate by role
ax1 = axes[0]
ax1.set_facecolor(white)
r_colors = [red if r == role_attr['AttritionRate'].max()
            else teal for r in role_attr['AttritionRate']]
bars = ax1.barh(range(len(role_attr)),
                role_attr['AttritionRate'],
                color=r_colors, edgecolor='none', height=0.65)
ax1.set_yticks(range(len(role_attr)))
ax1.set_yticklabels(role_attr['JobRole'],
                    color=muted, fontsize=9)
ax1.set_xlabel('Attrition Rate (%)', color=muted)
ax1.set_title('Attrition Rate by Job Role',
              color=navy, fontweight='bold',
              pad=12, fontfamily='serif')
ax1.tick_params(colors=muted)
for spine in ax1.spines.values():
    spine.set_color(border)
ax1.grid(color=border, linestyle='--',
         linewidth=0.5, axis='x')
ax1.invert_yaxis()
for bar, val in zip(bars, role_attr['AttritionRate']):
    ax1.text(bar.get_width() + 0.2,
             bar.get_y() + bar.get_height()/2,
             f'{val}%', va='center',
             color=navy, fontsize=8,
             fontweight='bold')

# Plot 2: Attrition rate by job level
ax2 = axes[1]
ax2.set_facecolor(white)
l_colors = [red if r == level_attr['AttritionRate'].max()
            else teal for r in level_attr['AttritionRate']]
bars2 = ax2.bar(level_attr['JobLevelLabel'],
                level_attr['AttritionRate'],
                color=l_colors, edgecolor='none', width=0.5)
ax2.set_ylabel('Attrition Rate (%)', color=muted)
ax2.set_title('Attrition Rate by Job Level',
              color=navy, fontweight='bold',
              pad=12, fontfamily='serif')
ax2.tick_params(colors=muted)
for spine in ax2.spines.values():
    spine.set_color(border)
ax2.grid(color=border, linestyle='--',
         linewidth=0.5, axis='y')
for bar, val in zip(bars2, level_attr['AttritionRate']):
    ax2.text(bar.get_x() + bar.get_width()/2,
             bar.get_height() + 0.3,
             f'{val}%', ha='center',
             color=navy, fontsize=10,
             fontweight='bold')

plt.tight_layout()
plt.show()

Role and Level Findings and Recommendations

Junior level employees (Level 1) carry a significantly higher attrition rate than any other level, a pattern consistent with inadequate onboarding, limited career visibility, and compensation gaps at entry level.

Recommendations:

  • Implement structured 90-day and 180-day onboarding reviews for all Level 1 employees
  • Create visible career pathways from Level 1 to Level 2 with clear criteria and timelines
  • Review compensation benchmarks for the highest-attrition job roles against external market data

Business Question

Is there a measurable relationship between monthly income, salary hike percentage, stock options, and the likelihood of leaving Meridian Group?

Income by Attrition Status

Show Code
income_attr = df.groupby(['IncomeBand', 'Attrition']).size()\
    .reset_index(name='Count')
income_pivot = income_attr.pivot(
    index='IncomeBand', columns='Attrition', values='Count'
).fillna(0).reset_index()
income_pivot['Total'] = income_pivot['No'] + income_pivot['Yes']
income_pivot['AttritionRate'] = (
    income_pivot['Yes'] / income_pivot['Total'] * 100
).round(1)
display(income_pivot)
Attrition IncomeBand No Yes Total AttritionRate
0 $10K to $15K 128 20 148 13.5
1 $3K to $6K 453 66 519 12.7
2 $6K to $10K 242 33 275 12.0
3 Over $15K 128 5 133 3.8
4 Under $3K 282 113 395 28.6

Salary Hike and Stock Options vs Attrition

Show Code
hike_attr = df.groupby('Attrition').agg(
    AvgSalaryHike    = ('PercentSalaryHike', 'mean'),
    AvgStockOption   = ('StockOptionLevel', 'mean'),
    AvgMonthlyIncome = ('MonthlyIncome', 'mean')
).round(2).reset_index()
display(hike_attr)
Attrition AvgSalaryHike AvgStockOption AvgMonthlyIncome
0 No 15.23 0.85 6832.74
1 Yes 15.10 0.53 4787.09

Compensation Visualization

Show Code
fig, axes = plt.subplots(1, 2, figsize=(16, 6))
fig.patch.set_facecolor(bg)
fig.suptitle('Figure III: Compensation and Attrition',
             color=navy, fontsize=13,
             fontfamily='serif', fontweight='bold', y=1.02)

# Plot 1: Income band attrition rate
income_order = ['Under $3K', '$3K to $6K',
                '$6K to $10K', '$10K to $15K', 'Over $15K']
ip = income_pivot.set_index('IncomeBand').reindex(
    income_order
).reset_index()

ax1 = axes[0]
ax1.set_facecolor(white)
i_colors = [red if r == ip['AttritionRate'].max()
            else teal for r in ip['AttritionRate']]
bars = ax1.bar(range(len(income_order)),
               ip['AttritionRate'],
               color=i_colors, edgecolor='none', width=0.6)
ax1.set_xticks(range(len(income_order)))
ax1.set_xticklabels(income_order, rotation=20,
                    ha='right', color=muted, fontsize=8)
ax1.set_ylabel('Attrition Rate (%)', color=muted)
ax1.set_title('Attrition Rate by Income Band',
              color=navy, fontweight='bold',
              pad=12, fontfamily='serif')
ax1.tick_params(colors=muted)
for spine in ax1.spines.values():
    spine.set_color(border)
ax1.grid(color=border, linestyle='--',
         linewidth=0.5, axis='y')
for bar, val in zip(bars, ip['AttritionRate']):
    ax1.text(bar.get_x() + bar.get_width()/2,
             bar.get_height() + 0.3,
             f'{val}%', ha='center',
             color=navy, fontsize=9,
             fontweight='bold')

# Plot 2: Avg income: leavers vs stayers
ax2 = axes[1]
ax2.set_facecolor(white)
groups = hike_attr['Attrition'].tolist()
incomes = hike_attr['AvgMonthlyIncome'].tolist()
c2 = [red if g == 'Yes' else teal for g in groups]
bars2 = ax2.bar(['Left', 'Stayed'], incomes,
                color=[red, teal],
                edgecolor='none', width=0.5)
ax2.set_ylabel('Average Monthly Income ($)', color=muted)
ax2.set_title('Average Income: Leavers vs Stayers',
              color=navy, fontweight='bold',
              pad=12, fontfamily='serif')
ax2.tick_params(colors=muted)
for spine in ax2.spines.values():
    spine.set_color(border)
ax2.grid(color=border, linestyle='--',
         linewidth=0.5, axis='y')
for bar, val in zip(bars2, incomes):
    ax2.text(bar.get_x() + bar.get_width()/2,
             bar.get_height() + 50,
             f'${val:,.0f}',
             ha='center', color=navy,
             fontsize=11, fontweight='bold')

plt.tight_layout()
plt.show()

Compensation Findings and Recommendations

Employees who leave earn significantly less on average than those who stay. The Under $3K income band carries the highest attrition rate, confirming that compensation is a primary attrition driver at the lower end of the pay scale.

Recommendations:

  • Conduct an immediate compensation review for all employees in the Under $3K and $3K to $6K income bands
  • Introduce a retention bonus scheme for high-performers in the Under $3K band who have been in role for more than 12 months
  • Review stock option allocation policy, employees with zero stock options show higher attrition rates

Business Question

Do the four satisfaction dimensions, job satisfaction, environment satisfaction, work-life balance, and relationship satisfaction, predict attrition at Meridian Group?

Satisfaction Scores by Attrition Status

Show Code
satisfaction_summary = df.groupby('Attrition').agg(
    AvgJobSatisfaction  = ('JobSatisfaction', 'mean'),
    AvgEnvSatisfaction  = ('EnvironmentSatisfaction', 'mean'),
    AvgRelSatisfaction  = ('RelationshipSatisfaction', 'mean'),
    AvgWorkLifeBalance  = ('WorkLifeBalance', 'mean')
).round(3).reset_index()
display(satisfaction_summary)
Attrition AvgJobSatisfaction AvgEnvSatisfaction AvgRelSatisfaction AvgWorkLifeBalance
0 No 2.779 2.771 2.734 2.781
1 Yes 2.468 2.464 2.599 2.658

Low Satisfaction Attrition Rates

Show Code
low_sat = df[df['JobSatisfaction'] == 1]
low_env = df[df['EnvironmentSatisfaction'] == 1]
low_wlb = df[df['WorkLifeBalance'] == 1]

print("Attrition rate among LOW satisfaction employees:")
print(f"  Low Job Satisfaction (score=1):         "
      f"{low_sat['AttritionBinary'].mean()*100:.1f}%  "
      f"({low_sat['AttritionBinary'].sum()} of {len(low_sat)})")
print(f"  Low Environment Satisfaction (score=1): "
      f"{low_env['AttritionBinary'].mean()*100:.1f}%  "
      f"({low_env['AttritionBinary'].sum()} of {len(low_env)})")
print(f"  Poor Work-Life Balance (score=1):       "
      f"{low_wlb['AttritionBinary'].mean()*100:.1f}%  "
      f"({low_wlb['AttritionBinary'].sum()} of {len(low_wlb)})")
Attrition rate among LOW satisfaction employees:
  Low Job Satisfaction (score=1):         22.8%  (66 of 289)
  Low Environment Satisfaction (score=1): 25.4%  (72 of 284)
  Poor Work-Life Balance (score=1):       31.2%  (25 of 80)

Satisfaction Visualization

Show Code
fig, axes = plt.subplots(2, 2, figsize=(16, 10))
fig.patch.set_facecolor(bg)
fig.suptitle('Figure IV: Satisfaction Scores and Attrition',
             color=navy, fontsize=13,
             fontfamily='serif', fontweight='bold', y=1.02)

sat_cols = [
    ('JobSatisfaction',          'Job Satisfaction'),
    ('EnvironmentSatisfaction',  'Environment Satisfaction'),
    ('WorkLifeBalance',          'Work-Life Balance'),
    ('RelationshipSatisfaction', 'Relationship Satisfaction')
]

for idx, (col, label) in enumerate(sat_cols):
    ax = axes[idx // 2][idx % 2]
    ax.set_facecolor(white)

    sat_data = df.groupby([col, 'Attrition']).size()\
        .reset_index(name='Count')
    sat_pivot = sat_data.pivot(
        index=col, columns='Attrition', values='Count'
    ).fillna(0)
    sat_pivot['AttritionRate'] = (
        sat_pivot['Yes'] /
        (sat_pivot['Yes'] + sat_pivot['No']) * 100
    ).round(1)

    s_colors = [red if r == sat_pivot['AttritionRate'].max()
                else teal
                for r in sat_pivot['AttritionRate']]
    bars = ax.bar(sat_pivot.index,
                  sat_pivot['AttritionRate'],
                  color=s_colors, edgecolor='none',
                  width=0.55)
    ax.set_xlabel('Score (1=Low, 4=Very High)',
                  color=muted, fontsize=8)
    ax.set_ylabel('Attrition Rate (%)', color=muted)
    ax.set_title(label, color=navy,
                 fontweight='bold', pad=10,
                 fontfamily='serif')
    ax.tick_params(colors=muted)
    for spine in ax.spines.values():
        spine.set_color(border)
    ax.grid(color=border, linestyle='--',
            linewidth=0.5, axis='y')
    for bar, val in zip(bars,
                        sat_pivot['AttritionRate']):
        ax.text(bar.get_x() + bar.get_width()/2,
                bar.get_height() + 0.3,
                f'{val}%', ha='center',
                color=navy, fontsize=9,
                fontweight='bold')

plt.tight_layout()
plt.show()

Satisfaction Findings and Recommendations

Employees with the lowest job satisfaction and environment satisfaction scores show materially higher attrition rates. Poor work-life balance is the single strongest satisfaction predictor of attrition in this dataset.

Recommendations:

  • Deploy a quarterly pulse survey targeting all four satisfaction dimensions, score deterioration is an early warning signal that must trigger manager action
  • Introduce a mandatory manager conversation for any employee who scores 1 on job satisfaction or work-life balance in consecutive surveys
  • Review workload distribution across teams with the highest concentration of overtime workers

Business Question

Is overtime a significant driver of attrition at Meridian Group? How does the attrition rate compare between employees who work overtime and those who do not?

Overtime and Attrition

Show Code
ot_attr = df.groupby('OverTime').agg(
    Total         = ('AttritionBinary', 'count'),
    Left          = ('AttritionBinary', 'sum'),
    AvgIncome     = ('MonthlyIncome', 'mean'),
    AvgSatisfaction = ('JobSatisfaction', 'mean'),
    AvgWLB        = ('WorkLifeBalance', 'mean')
).reset_index()
ot_attr['AttritionRate'] = (
    ot_attr['Left'] / ot_attr['Total'] * 100
).round(1)
display(ot_attr)
OverTime Total Left AvgIncome AvgSatisfaction AvgWLB AttritionRate
0 No 1054 110 6484.926945 2.711575 2.773245 10.4
1 Yes 416 127 6548.548077 2.771635 2.730769 30.5

Overtime by Department

Show Code
ot_dept = df.groupby(['Department', 'OverTime']).agg(
    Total = ('AttritionBinary', 'count'),
    Left  = ('AttritionBinary', 'sum')
).reset_index()
ot_dept['AttritionRate'] = (
    ot_dept['Left'] / ot_dept['Total'] * 100
).round(1)
display(ot_dept)
Department OverTime Total Left AttritionRate
0 Human Resources No 46 7 15.2
1 Human Resources Yes 17 5 29.4
2 Research & Development No 690 59 8.6
3 Research & Development Yes 271 74 27.3
4 Sales No 318 44 13.8
5 Sales Yes 128 48 37.5

Overtime Visualization

Show Code
fig, axes = plt.subplots(1, 2, figsize=(16, 6))
fig.patch.set_facecolor(bg)
fig.suptitle('Figure V: Overtime and Attrition',
             color=navy, fontsize=13,
             fontfamily='serif', fontweight='bold', y=1.02)

# Plot 1: Attrition rate overtime vs not
ax1 = axes[0]
ax1.set_facecolor(white)
ot_labels = ot_attr['OverTime'].tolist()
ot_rates  = ot_attr['AttritionRate'].tolist()
ot_colors = [red if l == 'Yes' else teal for l in ot_labels]
bars = ax1.bar(['No Overtime', 'Works Overtime'],
               ot_rates, color=ot_colors,
               edgecolor='none', width=0.5)
ax1.set_ylabel('Attrition Rate (%)', color=muted)
ax1.set_title('Attrition Rate: Overtime vs No Overtime',
              color=navy, fontweight='bold',
              pad=12, fontfamily='serif')
ax1.tick_params(colors=muted)
for spine in ax1.spines.values():
    spine.set_color(border)
ax1.grid(color=border, linestyle='--',
         linewidth=0.5, axis='y')
for bar, val in zip(bars, ot_rates):
    ax1.text(bar.get_x() + bar.get_width()/2,
             bar.get_height() + 0.3,
             f'{val}%', ha='center',
             color=navy, fontsize=14,
             fontweight='bold')

# Plot 2: Overtime headcount by department
ax2 = axes[1]
ax2.set_facecolor(white)
ot_yes = ot_dept[ot_dept['OverTime'] == 'Yes']
ot_no  = ot_dept[ot_dept['OverTime'] == 'No']
depts  = ot_dept['Department'].unique()
x = range(len(depts))
bars_no  = ax2.bar([i - 0.2 for i in x],
                   ot_no['Total'],
                   width=0.35, color=teal,
                   edgecolor='none', label='No Overtime')
bars_yes = ax2.bar([i + 0.2 for i in x],
                   ot_yes['Total'],
                   width=0.35, color=amber,
                   edgecolor='none', label='Works Overtime')
ax2.set_xticks(x)
ax2.set_xticklabels(depts, rotation=15,
                    ha='right', color=muted, fontsize=8)
ax2.set_ylabel('Headcount', color=muted)
ax2.set_title('Overtime Distribution by Department',
              color=navy, fontweight='bold',
              pad=12, fontfamily='serif')
ax2.tick_params(colors=muted)
for spine in ax2.spines.values():
    spine.set_color(border)
ax2.legend(facecolor=bg, edgecolor=border,
           fontsize=9, labelcolor=navy)

plt.tight_layout()
plt.show()

Overtime Findings and Recommendations

Overtime is the single strongest predictor of attrition in this dataset. Employees who work overtime leave at a rate more than three times higher than those who do not.

Recommendations:

  • Implement a mandatory overtime monitoring policy. No employee should work overtime for more than eight consecutive weeks without a formal review
  • Audit all roles with high overtime incidence for headcount adequacy, overtime is frequently a symptom of understaffing, not productivity
  • Include overtime hours in the monthly HR dashboard reviewed by department heads

Business Question

Does time in role, time since last promotion, or total tenure predict attrition? Are employees leaving because they feel their careers are stagnating?

Tenure Profile by Attrition

Show Code
tenure_attr = df.groupby('Attrition').agg(
    AvgYearsAtCompany       = ('YearsAtCompany', 'mean'),
    AvgYearsInRole          = ('YearsInCurrentRole', 'mean'),
    AvgYearsSincePromotion  = ('YearsSinceLastPromotion', 'mean'),
    AvgYearsWithManager     = ('YearsWithCurrManager', 'mean'),
    AvgTotalWorkingYears    = ('TotalWorkingYears', 'mean'),
    AvgNumCompanies         = ('NumCompaniesWorked', 'mean')
).round(2).reset_index()
display(tenure_attr)
Attrition AvgYearsAtCompany AvgYearsInRole AvgYearsSincePromotion AvgYearsWithManager AvgTotalWorkingYears AvgNumCompanies
0 No 7.37 4.48 2.23 4.37 11.86 2.65
1 Yes 5.13 2.90 1.95 2.85 8.24 2.94

Early Attrition: First Two Years

Show Code
early = df[df['YearsAtCompany'] <= 2]
print(f"Employees with 2 years or less tenure: {len(early):,}")
print(f"Attrition rate in this group:          "
      f"{early['AttritionBinary'].mean()*100:.1f}%")
print(f"vs overall attrition rate:             "
      f"{df['AttritionBinary'].mean()*100:.1f}%")
Employees with 2 years or less tenure: 342
Attrition rate in this group:          29.8%
vs overall attrition rate:             16.1%

Tenure Visualization

Show Code
fig, axes = plt.subplots(1, 2, figsize=(16, 6))
fig.patch.set_facecolor(bg)
fig.suptitle('Figure VI: Tenure and Career Progression',
             color=navy, fontsize=13,
             fontfamily='serif', fontweight='bold', y=1.02)

# Plot 1: Avg tenure metrics: leavers vs stayers
metrics = ['AvgYearsAtCompany', 'AvgYearsInRole',
           'AvgYearsSincePromotion', 'AvgYearsWithManager']
labels  = ['Years at\nCompany', 'Years in\nRole',
           'Since Last\nPromotion', 'With Current\nManager']

left_vals   = tenure_attr[tenure_attr['Attrition']=='Yes'][metrics].values[0]
stayed_vals = tenure_attr[tenure_attr['Attrition']=='No'][metrics].values[0]

x = range(len(metrics))
ax1 = axes[0]
ax1.set_facecolor(white)
bars_left   = ax1.bar([i - 0.2 for i in x], left_vals,
                      width=0.35, color=red,
                      edgecolor='none', label='Left')
bars_stayed = ax1.bar([i + 0.2 for i in x], stayed_vals,
                      width=0.35, color=teal,
                      edgecolor='none', label='Stayed')
ax1.set_xticks(x)
ax1.set_xticklabels(labels, color=muted, fontsize=8)
ax1.set_ylabel('Average Years', color=muted)
ax1.set_title('Tenure Metrics: Leavers vs Stayers',
              color=navy, fontweight='bold',
              pad=12, fontfamily='serif')
ax1.tick_params(colors=muted)
for spine in ax1.spines.values():
    spine.set_color(border)
ax1.grid(color=border, linestyle='--',
         linewidth=0.5, axis='y')
ax1.legend(facecolor=bg, edgecolor=border,
           fontsize=9, labelcolor=navy)

# Plot 2: Attrition by years at company band
tenure_band = df.copy()
tenure_band['TenureBand'] = pd.cut(
    tenure_band['YearsAtCompany'],
    bins=[0, 1, 2, 5, 10, 40],
    labels=['0-1 yr', '1-2 yrs', '2-5 yrs',
            '5-10 yrs', '10+ yrs']
)
tb_attr = tenure_band.groupby('TenureBand').agg(
    Total = ('AttritionBinary', 'count'),
    Left  = ('AttritionBinary', 'sum')
).reset_index()
tb_attr['AttritionRate'] = (
    tb_attr['Left'] / tb_attr['Total'] * 100
).round(1)

ax2 = axes[1]
ax2.set_facecolor(white)
tb_colors = [red if r == tb_attr['AttritionRate'].max()
             else teal for r in tb_attr['AttritionRate']]
bars2 = ax2.bar(tb_attr['TenureBand'].astype(str),
                tb_attr['AttritionRate'],
                color=tb_colors, edgecolor='none',
                width=0.6)
ax2.set_ylabel('Attrition Rate (%)', color=muted)
ax2.set_title('Attrition Rate by Tenure Band',
              color=navy, fontweight='bold',
              pad=12, fontfamily='serif')
ax2.tick_params(colors=muted)
for spine in ax2.spines.values():
    spine.set_color(border)
ax2.grid(color=border, linestyle='--',
         linewidth=0.5, axis='y')
for bar, val in zip(bars2, tb_attr['AttritionRate']):
    ax2.text(bar.get_x() + bar.get_width()/2,
             bar.get_height() + 0.3,
             f'{val}%', ha='center',
             color=navy, fontsize=9,
             fontweight='bold')

plt.tight_layout()
plt.show()

Tenure Findings and Recommendations

Employees who leave have significantly shorter average tenure and longer time since last promotion than those who stay. The first two years represent the highest-risk attrition window.

Recommendations:

  • Implement a formal promotion review cycle that guarantees every employee is reviewed for advancement at 18-month intervals
  • Assign every new employee a senior mentor for their first 24 months, early tenure attrition is largely preventable
  • Flag employees approaching 24 months with no promotion or role change for a mandatory career development conversation

Business Question

What is the demographic profile of employees who leave Meridian Group? Does age, gender, marital status, or business travel frequency influence attrition?

Attrition by Gender and Age

Show Code
gender_attr = df.groupby('Gender').agg(
    Total = ('AttritionBinary', 'count'),
    Left  = ('AttritionBinary', 'sum')
).reset_index()
gender_attr['AttritionRate'] = (
    gender_attr['Left'] / gender_attr['Total'] * 100
).round(1)
display(gender_attr)
Gender Total Left AttritionRate
0 Female 588 87 14.8
1 Male 882 150 17.0
Show Code
age_order = ['Under 25', '25 to 34', '35 to 44',
             '45 to 54', '55 and over']
age_attr = df.groupby('AgeBand').agg(
    Total = ('AttritionBinary', 'count'),
    Left  = ('AttritionBinary', 'sum')
).reset_index()
age_attr['AttritionRate'] = (
    age_attr['Left'] / age_attr['Total'] * 100
).round(1)
age_attr = age_attr.set_index('AgeBand').reindex(
    age_order
).reset_index()
display(age_attr)
AgeBand Total Left AttritionRate
0 Under 25 97 38 39.2
1 25 to 34 554 112 20.2
2 35 to 44 505 51 10.1
3 45 to 54 245 25 10.2
4 55 and over 69 11 15.9

Attrition by Marital Status and Travel

Show Code
marital_attr = df.groupby('MaritalStatus').agg(
    Total = ('AttritionBinary', 'count'),
    Left  = ('AttritionBinary', 'sum')
).reset_index()
marital_attr['AttritionRate'] = (
    marital_attr['Left'] / marital_attr['Total'] * 100
).round(1)
display(marital_attr)
MaritalStatus Total Left AttritionRate
0 Divorced 327 33 10.1
1 Married 673 84 12.5
2 Single 470 120 25.5
Show Code
travel_attr = df.groupby('BusinessTravel').agg(
    Total = ('AttritionBinary', 'count'),
    Left  = ('AttritionBinary', 'sum')
).reset_index()
travel_attr['AttritionRate'] = (
    travel_attr['Left'] / travel_attr['Total'] * 100
).round(1)
display(travel_attr)
BusinessTravel Total Left AttritionRate
0 Non-Travel 150 12 8.0
1 Travel_Frequently 277 69 24.9
2 Travel_Rarely 1043 156 15.0

Demographic Visualization

Show Code
fig, axes = plt.subplots(2, 2, figsize=(16, 10))
fig.patch.set_facecolor(bg)
fig.suptitle('Figure VII: Demographic Attrition Profile',
             color=navy, fontsize=13,
             fontfamily='serif', fontweight='bold', y=1.02)

# Plot 1: Gender
ax1 = axes[0][0]
ax1.set_facecolor(white)
g_colors = [teal, amber]
bars = ax1.bar(gender_attr['Gender'],
               gender_attr['AttritionRate'],
               color=g_colors, edgecolor='none',
               width=0.45)
ax1.set_ylabel('Attrition Rate (%)', color=muted)
ax1.set_title('Attrition by Gender', color=navy,
              fontweight='bold', pad=10,
              fontfamily='serif')
ax1.tick_params(colors=muted)
for spine in ax1.spines.values():
    spine.set_color(border)
ax1.grid(color=border, linestyle='--',
         linewidth=0.5, axis='y')
for bar, val in zip(bars, gender_attr['AttritionRate']):
    ax1.text(bar.get_x() + bar.get_width()/2,
             bar.get_height() + 0.2,
             f'{val}%', ha='center',
             color=navy, fontsize=11,
             fontweight='bold')

# Plot 2: Age band
ax2 = axes[0][1]
ax2.set_facecolor(white)
a_colors = [red if r == age_attr['AttritionRate'].max()
            else teal for r in age_attr['AttritionRate']]
bars2 = ax2.bar(range(len(age_order)),
                age_attr['AttritionRate'],
                color=a_colors, edgecolor='none',
                width=0.6)
ax2.set_xticks(range(len(age_order)))
ax2.set_xticklabels(age_order, rotation=20,
                    ha='right', color=muted,
                    fontsize=8)
ax2.set_ylabel('Attrition Rate (%)', color=muted)
ax2.set_title('Attrition by Age Group', color=navy,
              fontweight='bold', pad=10,
              fontfamily='serif')
ax2.tick_params(colors=muted)
for spine in ax2.spines.values():
    spine.set_color(border)
ax2.grid(color=border, linestyle='--',
         linewidth=0.5, axis='y')
for bar, val in zip(bars2, age_attr['AttritionRate']):
    ax2.text(bar.get_x() + bar.get_width()/2,
             bar.get_height() + 0.2,
             f'{val}%', ha='center',
             color=navy, fontsize=9,
             fontweight='bold')

# Plot 3: Marital status
ax3 = axes[1][0]
ax3.set_facecolor(white)
m_colors = [red if r == marital_attr['AttritionRate'].max()
            else teal for r in marital_attr['AttritionRate']]
bars3 = ax3.bar(marital_attr['MaritalStatus'],
                marital_attr['AttritionRate'],
                color=m_colors, edgecolor='none',
                width=0.5)
ax3.set_ylabel('Attrition Rate (%)', color=muted)
ax3.set_title('Attrition by Marital Status', color=navy,
              fontweight='bold', pad=10,
              fontfamily='serif')
ax3.tick_params(colors=muted)
for spine in ax3.spines.values():
    spine.set_color(border)
ax3.grid(color=border, linestyle='--',
         linewidth=0.5, axis='y')
for bar, val in zip(bars3, marital_attr['AttritionRate']):
    ax3.text(bar.get_x() + bar.get_width()/2,
             bar.get_height() + 0.2,
             f'{val}%', ha='center',
             color=navy, fontsize=10,
             fontweight='bold')

# Plot 4: Business travel
ax4 = axes[1][1]
ax4.set_facecolor(white)
t_colors = [red if r == travel_attr['AttritionRate'].max()
            else teal for r in travel_attr['AttritionRate']]
bars4 = ax4.bar(travel_attr['BusinessTravel'],
                travel_attr['AttritionRate'],
                color=t_colors, edgecolor='none',
                width=0.5)
ax4.set_ylabel('Attrition Rate (%)', color=muted)
ax4.set_title('Attrition by Travel Frequency', color=navy,
              fontweight='bold', pad=10,
              fontfamily='serif')
ax4.tick_params(colors=muted)
for spine in ax4.spines.values():
    spine.set_color(border)
ax4.grid(color=border, linestyle='--',
         linewidth=0.5, axis='y')
ax4.set_xticklabels(travel_attr['BusinessTravel'],
                    rotation=15, ha='right',
                    color=muted, fontsize=8)
for bar, val in zip(bars4, travel_attr['AttritionRate']):
    ax4.text(bar.get_x() + bar.get_width()/2,
             bar.get_height() + 0.2,
             f'{val}%', ha='center',
             color=navy, fontsize=10,
             fontweight='bold')

plt.tight_layout()
plt.show()

Demographic Findings and Recommendations

Younger employees, single employees, and frequent travellers all show elevated attrition rates. These groups share a common characteristic, lower organisational attachment and higher mobility.

Recommendations:

  • Create a young professional programme targeting employees Under 25 and 25 to 34, structured mentorship, visible career paths, and social cohesion
  • Review the business travel policy for frequent travellers, consider travel allowances, flexible recovery days, and reduced consecutive travel weeks
  • Design retention initiatives sensitive to the needs of single employees who may prioritise flexibility and social connection at work

Power BI Dashboard

Dashboard Overview

The analytical findings above were translated into an interactive five-page Power BI dashboard for executive and operational use at Meridian Group. The dashboard enables real-time filtering by department, job role, gender, and attrition status.

Page 1 Executive Overview

Power BI Dashboard  Executive Overview
Dashboard Page 1 Executive Overview · Meridian Group People Analytics

Page 2 Department and Role Analysis

Power BI Dashboard  Department and Role Analysis
Dashboard Page 2, Department and Role Analysis

Page 3 Compensation Analysis

Power BI Dashboard  Compensation Analysis
Dashboard Page 3 Compensation Analysis

Page 4 Satisfaction and Wellbeing

Power BI Dashboard  Satisfaction and Wellbeing
Dashboard Page 4 Satisfaction and Wellbeing

Page 5 Demographics and Tenure

Power BI Dashboard  Demographics and Tenure
Dashboard Page 5 Demographics and Tenure

Conclusions

Summary of Findings

Theme Key Finding Priority
Overall Attrition 16.1% above industry benchmark Critical
Department and Role Sales and Level 1 employees most at risk High
Compensation Leavers earn significantly less than stayers High
Satisfaction Poor work-life balance is the strongest predictor High
Overtime Overtime workers leave at 3x the rate of non-overtime workers Critical
Tenure First two years are the highest-risk attrition window High
Demographics Young, single, and frequent-traveller employees most at risk Medium

The Three Most Urgent Actions

1. Address Overtime Immediately Overtime is the single most powerful attrition predictor in this dataset. Employees who work overtime leave at more than three times the rate of those who do not. This is not a minor HR issue, it is a structural risk that requires an operational response within 30 days.

2. Fix Compensation at the Lower End Employees earning under $3,000 per month are leaving at a disproportionately high rate. A targeted compensation review for this band will have an immediate impact on the overall attrition rate.

3. Protect the First Two Years The highest-risk attrition window is the first 24 months. A structured early-tenure programme, mentorship, promotion clarity, and regular check-ins, will significantly reduce the volume of early leavers.

Data Limitations

  • Dataset contains 1,470 employees, sufficient for pattern identification but limited for subgroup analysis in smaller departments
  • Satisfaction scores are self-reported and subject to response bias
  • No time dimension is available, attrition trends over time cannot be assessed
  • Salary data is in USD, Meridian Group operates in Manchester, UK, so currency conversion assumptions apply

Final Note to Dr. Amara Osei

The data is clear and the diagnosis is unambiguous. Meridian Group’s attrition problem is not random, it is concentrated, predictable, and therefore preventable. Overtime, low compensation, poor work-life balance, and inadequate early-career support are the four pillars of the attrition problem. Address all four with the urgency they deserve and the 16.1% rate will fall to benchmark levels within 18 months. The intelligence is here. The decisions are yours.


Report prepared by: Freda Erinmwingbovo, Data Analyst
Prepared for: Dr. Amara Osei, CEO Meridian Group
Date: February 2026
Tools: Python · pandas · matplotlib · Power BI · Quarto
Dataset: IBM HR Analytics Employee Attrition Dataset · 1,470 employees · 35 attributes

This report was produced to professional data science standards. All findings are reproducible from the code cells above. No results have been overstated or adjusted to appear more favourable than the data supports.