IBM's HR department was losing people and nobody could say why. I took the question to the data: 1,470 employees, 35 columns describing each one, and a single yes/no flag recording whether they left.
The interesting part of this analysis was not the answer. It was discovering how easily you can produce a confident, statistically valid, completely wrong one.
The data
The dataset is IBM's HR Analytics Employee Attrition set, published on Kaggle. It was built by IBM data scientists as a synthetic dataset rather than real employee records, which matters for interpretation: the relationships in it are cleaner than anything a real HR team would encounter.
Each row is one employee, described across demographics, job details, compensation, and tenure. Of the 1,470 employees, 237 left and 1,233 stayed, an attrition rate of 16%.
The documentation does not specify whether attrition covers voluntary departures, layoffs, or both. This analysis treats it as voluntary.
All work was done in R.
hrdata <- read.csv("HR-Employee-Attrition.csv")
dim(hrdata)
Three columns that mean nothing
Before asking why people left, I checked how the numeric columns behave among themselves.
round(cor(hrdata_num), 2)
Only two pairs cleared a correlation of 0.5. Total working years and monthly income sit at 0.77, and age and total working years at 0.68. Both are unsurprising: experience accumulates with age, and pay accumulates with experience.
The useful finding was in what came back empty. DailyRate, HourlyRate, and MonthlyRate correlate with nothing at all, including each other and including MonthlyIncome. In any real payroll system a daily rate is an arithmetic multiple of an hourly rate. Here they are unrelated, which tells you they were generated randomly when the dataset was synthesised.
Three of the ten numeric columns are noise. Finding that out in the first ten minutes is worth more than any chart produced afterward, because it prevents an entire section of analysis built on nothing.
What the numbers hide
Correlation gives you a number. It does not tell you the shape of the relationship behind it.
pairs(~ MonthlyIncome + Age + TotalWorkingYears,
data = hrdata,
main = "Scatterplot Matrix")
Three things showed up that the correlation coefficient had flattened out.
Income fans outward with experience. Employees with under five years of experience cluster tightly around 2,700 a month. Past twenty years, the spread is more than three times wider. Experience raises the floor of what someone earns far more reliably than it raises the ceiling.
Income falls into horizontal bands. Rather than spreading smoothly, the points stack into distinct stripes. Those stripes are job levels, and they occupy nearly separate pay ranges: level 1 tops out below 5,000 while level 5 starts above 18,000. This observation turned out to matter later.
Income is capped at 19,999. An artifact of the synthetic data, and a reminder not to read anything into the top of the range.
Education explains less than expected
Education is stored as codes 1 through 5, from Below College to Doctor. Codes are not measurements, so a scatterplot renders them as vertical strands. Boxplots are the right tool.
hrdata$EducationLabel <- factor(hrdata$Education,
levels = 1:5,
labels = c("Below College", "College", "Bachelor", "Master", "Doctor"),
ordered = TRUE)
boxplot(MonthlyIncome ~ EducationLabel, data = hrdata,
main = "Monthly Income by Education Level",
xlab = "Education", ylab = "Monthly Income")
The expected result is a staircase. What appears is one step and then a plateau.
| Education | Median age | Median working years | Median income |
|---|---|---|---|
| Below College | 30 | 6 | 3,849 |
| College | 36 | 9 | 4,892 |
| Bachelor | 35 | 10 | 4,762 |
| Master | 37 | 10 | 5,342 |
| Doctor | 38 | 10 | 6,203 |
Median working years is identical at 10 for Bachelor, Master, and Doctor. College out-earns Bachelor, breaking the expected order. And the boxes overlap so heavily that plenty of employees without a degree earn more than the typical Doctor.
Education separates the least-educated group from everyone else. Above that line it tells you very little. The Doctor group also contains only 48 of 1,470 employees, so its position is provisional.
Testing two claims
Two accusations were on the table, each one testable rather than arguable.
"Older employees were pushed out"
yes_age <- hrdata[(hrdata$Attrition == "Yes"), 'Age'] no_age <- hrdata[(hrdata$Attrition != "Yes"), 'Age'] t.test(yes_age, no_age)
The result is significant and points the other way. Employees who left averaged 33.6 years against 37.6 for those who stayed, with the entire confidence interval below zero. The people leaving are younger, not older.
Worth adding that four years is a modest gap and the distributions overlap substantially. Significant does not mean large.
"Newer employees were pushed out"
The obvious test uses EmployeeNumber, and it comes back with a p-value of 0.677. No evidence of any difference. Claim dismissed.
Except the test is meaningless. EmployeeNumber is a unique identifier running from 1 to 2068 with no repeated values. It correlates with age at -0.01, with years at company at -0.01, and with total working years at -0.01. It contains no information about an employee, so a null result is the only outcome the test could have produced.
Testing the column that actually measures tenure gives the opposite answer:
yes_tenure <- hrdata[(hrdata$Attrition == "Yes"), 'YearsAtCompany'] no_tenure <- hrdata[(hrdata$Attrition != "Yes"), 'YearsAtCompany'] t.test(yes_tenure, no_tenure)
Employees who left averaged 5.1 years at the company against 7.4 for those who stayed, significant well beyond the 0.05 threshold. The claim holds. It was only dismissed because the first test asked about an ID number rather than about tenure.
A statistically valid test on the wrong column produces a statistically valid wrong answer. Nothing in the output warns you.
What actually determines pay
The last question was whether monthly income can be predicted.
Age alone explains 24.8% of the variation, with each year of age associated with about 257 more per month.
Age plus total working years raises that to 59.9%. Experience is worth about 489 per year. Age turns slightly negative at -27, statistically significant at p = 0.021 but practically negligible against a mean income near 6,500. This is multicollinearity: age and experience correlate at 0.68, so in the first model age was standing in for experience it does not directly measure.
Adding job level raises it to 90.5%.
model3 <- lm(MonthlyIncome ~ Age + TotalWorkingYears + JobLevel, data = hrdata) summary(model3)
Each job level is worth roughly 3,785 per month. Experience collapses from 489 to 53. Age loses significance entirely at p = 0.18.
This is the horizontal banding from the scatterplot, confirmed numerically. Pay at this company is set almost mechanically by job level. Experience matters because it leads to promotion, not because it earns raises within a grade. An employee who accumulates years without moving up is on a nearly flat pay curve, which is a plausible reason to leave and connects directly to the tenure finding above.
Limitations
The dataset is synthetic, so the relationships in it are cleaner and more deterministic than real HR data would be, and three of its columns are outright noise. Nothing here establishes causation: every model describes association only. And no model in this analysis predicts attrition itself, since linear regression requires a continuous target and attrition is binary. Logistic regression would be the correct next step.
Conclusions
The employees leaving IBM skew younger and shorter-tenured. They also earn less: 4,787 a month on average against 6,833 for those who stayed.
Those three facts are one fact. Pay is determined by job level, level comes from promotion, and promotion takes time. Early-career employees sit at the bottom of a pay structure where waiting is the only mechanism for advancement, and a meaningful share of them decline to wait.
The methodological lesson was sharper than the finding. The EmployeeNumber test was correctly specified, correctly executed, and correctly interpreted. It was also worthless, because the column had nothing to do with the question. Statistics validates the test you ran, never the question you meant to ask.
Data: IBM HR Analytics Employee Attrition, Kaggle. Analysis in R using base R statistical functions.
Seeking Data Analyst & BI Analyst roles
I'm currently building my data analytics portfolio and actively exploring Data Analyst and Business Intelligence roles. If you work with data or are hiring in this space, I'd love to connect.