AWPF Summer Semester 2022 · Machine Learning

Will this person earn more than $65 an hour?

We trained a decision tree on IBM's HR dataset to answer one binary question from six facts about a person's career. This page lets you drive the model yourself — move the inputs and watch the tree walk its checkpoints, one split at a time, to a verdict.

01 — The goal

One question, two possible answers

Predict whether a person would receive more or less than $65 per hour, based on a chosen set of career factors.

Our first idea was to predict a person's exact salary. That turned out to be a much harder problem than we could do justice to. Constraining the output to a single true/false boundary made the problem tractable — and made a decision tree the natural fit, because a tree is a chain of yes/no questions that ends in exactly that kind of answer.

The $65 threshold isn't arbitrary: it sits almost exactly at the median hourly rate in the dataset (), which splits the employees into two groups of near-equal size. That matters — a balanced target means a model can't score well just by always guessing the same answer.

02 — The data

1,470 fictional IBM employees

The source is the IBM HR Analytics Employee Attrition & Performance dataset — a fictional HR dataset published by IBM and widely used for teaching. Each row is one employee, with 35 attributes covering their role, tenure, education, satisfaction and pay.

Turning words into numbers

Most of the useful columns arrive as text. A decision tree needs numbers, so every non-numeric factor was mapped through a Python dictionary before training. Some columns were already scored on a scale — Education and Performance Rating run from 1 (worst) to 5 (best) as published, so they passed through untouched.

The mappings applied before training
FactorMapping
Business TravelTravel_Frequently 1 · Travel_Rarely 2 · Non-Travel 3
DepartmentResearch & Development 1 · Sales 2 · Human Resources 3
MajorsLife Sciences 1 · Medical 2 · Human Resources 3 · Technical Degree 4 · Marketing 5 · Other 6
GenderMale 1 · Female 2
Marital StatusMarried 0 · Divorced 1 · Single 2
Ability to Work Over TimeNo 0 · Yes 1
Job role0–8, alphabetical from Healthcare Representative to Sales Representative

How the data was split

The 1,470 rows were divided into 1,100 for training and 300 held back for testing, with 70 left unused. The tree never sees the test rows while learning, so its score on them is an honest estimate of how it would handle a stranger.

The draw is stratified: rows are sampled at random from within each salary class separately, rather than sliced off one shuffled list. That guarantees both halves carry the same balance as the source data — — so the test set can't accidentally end up easier or harder than what the tree trained on.

Train 1,100 Test 300 Unused 70

03 — What was used

The toolkit

The original work lives in two Jupyter notebooks. Everything below runs in Python; this page is the trained tree exported to JSON and re-implemented in about forty lines of JavaScript, so the prediction happens in your browser with no server involved.

pandas

Reads the CSV and applies the dictionary mappings that turn text columns into numbers.

NumPy

The array maths underneath everything else — scikit-learn is built on it.

scikit-learn

DecisionTreeClassifier for the model itself, plus the train/test split and the accuracy metrics.

statsmodels

Ordinary least squares, used to check which factors looked worth keeping.

pydotplus + Graphviz

Renders the fitted tree as a diagram — the wide image further down this page.

Vanilla JS

No framework, no build step. The tree ships as JSON and is walked client-side.

04 — The interactive predictor

Drive the tree yourself

Set the six factors below. The tree starts at its root and, at every checkpoint, compares one of your values against a threshold it learned during training — going left or right until it runs out of questions. The verdict is whatever the training rows that landed in that final leaf mostly did.

Which tree?

The tree predicts

The checkpoints it passed through

    05 — How it splits

    Gini impurity picks every checkpoint

    At each node the algorithm tries every factor and every possible cut-point, and keeps the one that leaves the two resulting groups as pure as possible — meaning each side contains mostly one answer rather than a mix.

    Purity is scored with the Gini index. For a group where a fraction pi of rows belong to class i:

    Gini = 1 − Σ pi2

    A group that is entirely one class scores 0. A 50/50 group scores 0.5 — the worst possible for two classes. A split is judged by the weighted average Gini of the two groups it creates, and the algorithm keeps splitting until it can't lower that number any further. That stopping rule is precisely why the tree grows as deep as it does.

    You can see this working in the predictor above: the Gini value shown at each checkpoint generally falls as you move down the path.

    Which factors the tree leaned on

    Gini importance — the total impurity each factor removed across the whole tree, normalised to sum to 1.

    The fully-grown decision tree rendered by Graphviz: a single extremely wide diagram of hundreds of small nodes, far too dense to read individual labels.
    The fully-grown tree as Graphviz drew it for the original 2022 notebook — hundreds of leaves spread across more than twenty levels, in a canvas 25,444 pixels wide. It is reproduced here at true proportions and is genuinely illegible at any size that fits a screen. That illegibility is the finding, not a rendering problem: a model this large has stopped describing a pattern and started memorising rows.

    06 — Does it actually work?

    Honestly: no — and that is the interesting part

    A model is only worth as much as its performance on data it has never seen. Held back from training, those 300 test rows give the verdict. This section reports what they say, including where the original 2022 write-up got it wrong.

    The R² of 0.902 was an artefact

    The report's central evidence for the chosen factors was a multivariable regression reporting R² = 0.902, read as "90% of the variation is explained." The regression was run as sm.OLS(y, x) — with no constant term added.

    Without an intercept, statsmodels reports an uncentered R², which measures variation around zero rather than around the mean. Because hourly rates are all large positive numbers ($30–$100), a model predicting roughly "$65-ish" for everyone scores near-perfectly on that measure while explaining nothing at all. Refit with an intercept, the same five factors give R² = 0.0014.

    This is a genuinely easy mistake to make — statsmodels flags it only in a footnote reading "R² is computed without centering."

    One split isn't evidence — so we ran thirty

    Each mark is one complete re-run: a fresh stratified 1,100/300 draw, a tree fitted from scratch, scored on that run's own held-out rows. The vertical rule is the always-guess baseline.

    Correlation of each factor with hourly rate

    Pearson r across all 1,470 rows. The axis spans just ±0.05; a factor worth modelling would usually reach ±0.3 or beyond.

    Training accuracy climbs, test accuracy doesn't

    Accuracy against tree depth. The gap between the two lines is overfitting, drawn.

    Where the 300 test predictions landed

    Rows are the truth, columns are the prediction.

    Reading the result

    And the reason is visible in the correlation chart. Every factor correlates with hourly rate at |r| < 0.023. HourlyRate in this dataset was generated at random by IBM — it was never linked to the other columns. There is no signal in it to find, so no algorithm and no amount of tuning would have found one.

    What we would do differently

    • Plot the target against each feature before modelling — five minutes that would have caught this on day one.
    • Use sm.add_constant(x), and treat any R² above 0.9 on messy human data as a red flag rather than a result.
    • Compare against the majority-class baseline from the start; accuracy alone is meaningless without it.
    • Predict MonthlyIncome instead — it correlates strongly with job level and tenure, and is the column with real structure in it.

    07 — In closing

    What the project actually demonstrates

    The pipeline is sound: acquire a dataset, map its categorical columns to numbers, select factors, fit a DecisionTreeClassifier, render it, and interrogate the result. Every one of those steps does what it should, and the predictor above is the real fitted tree — the same splits, thresholds and leaf counts, exported node by node.

    What it also demonstrates, unintentionally but usefully, is the single most important habit in applied machine learning: a model that fits is not the same as a model that works. The training accuracy near 89% was real. So was the R² of 0.902. Both were measuring the wrong thing, and only held-out data said so.

    Move the sliders above and you can watch a long chain of perfectly logical checkpoints arrive at an answer worth about as much as a coin flip. That is a more useful thing to have built than a model that quietly worked.