Housing Price-Tier Prediction: an ML project from start to finish
This is a practice run for the Kaggle Workstation Rental Price Tier Prediction competition.
We use your Housing.csv (545 houses), turn the price into 3 tiers (budget / mid / premium), and solve it the same way you'd solve the real competition.
Every number in this guide comes from actually running the notebooks.
0. What's in the folder
housing-ml-tutorial/
├── raw/Housing.csv ← your original dataset
├── prepare_data.py ← plays "Kaggle": makes train/test files (run once, then ignore)
├── data/
│ ├── train.csv ← 408 houses WITH the answer (price_tier)
│ ├── test.csv ← 137 houses WITHOUT the answer
│ └── sample_submission.csv ← the format to submit in
├── _hidden/test_labels.csv ← the answers for test. Don't look! Only score.py reads it
├── score.py ← our fake Kaggle leaderboard (public + private)
├── utils.py ← code written once in a notebook, reused later
├── 01_eda.ipynb … 06_ensemble_and_final.ipynb ← the project, in order
├── submissions/ ← every CSV we "submitted"
└── extra_california/ ← bonus: the same 6 steps on a 16,500-row dataset
How our fake Kaggle works. prepare_data.py hides 137 houses as the test set. score.py scores your submission against their real tiers.
Like on Kaggle, the test set is split into a public part (62 houses, the score you see during the competition) and a private part (75 houses, revealed only at the end and used for the final ranking).
Running it
Install once (from this folder):
uv venv .venv --python 3.12
uv pip install --python .venv/Scripts/python.exe -r requirements.txt
Then open the notebooks in VS Code or Jupyter, choose the .venv Python as the kernel, and run them in order, 01 → 06.
(On Kaggle itself everything is pre-installed; just upload the notebook.)
The workflow (memorise this)
understand the data → baseline → honest validation → improve ONE thing → check CV → repeat → blend → submit
01 02 03 04 / 05 (every step) 06
Every notebook is one step of that loop. The rest of this guide walks through them.
01 · EDA: look before you model
Question: what is in this data, and what seems to drive the price tier?
What we found:
| finding | evidence |
|---|---|
| Small dataset | 408 training rows, 12 feature columns |
| Missing values | area has 10 blanks, furnishingstatus has 15 |
| Text columns | 6 yes/no columns plus furnishing (3 values) need converting to numbers |
| Balanced target | budget 139 / mid 133 / premium 136, so random guessing ≈ 33% and accuracy is a fair metric |
area is the strongest signal |
correlation with tier 0.55; boxes climb clearly from budget → premium |
| Then bathrooms, bedrooms, AC, stories | correlations 0.49, 0.43, 0.42, 0.41 |
| Neighbourhood and AC matter | prefarea=yes: 8% of budget houses vs 45% of premium. AC: 10% vs 57% |
| Furnishing matters | 62% of unfurnished houses are budget; furnished ones lean premium |
Concept: hypotheses. EDA ends with a list of guesses ("bigger + AC + good area = pricier"). Later steps test these guesses.
02 · Baseline: get on the leaderboard fast
Two submissions:
- Dumb baseline: predict "budget" for every house. Public: 0.403. This is the floor every real model must beat.
- First real model: a Random Forest with minimal cleaning:
- missing
area→ train median, missing furnishing → most common value (always learned from train, then applied to test), - yes/no → 1/0,
- furnishing → 0/1/2 in its natural order (ordinal encoding),
- target → 0/1/2.
- missing
| accuracy | |
|---|---|
| Random Forest on its own training data | 1.000 |
| Random Forest on the leaderboard | 0.629 |
Concept: overfitting. A 100% training score means the model memorised the rows. That's like grading a student on the exact questions they studied. The training score is useless as a measure, so we need an honest one → 03.
03 · Validation: an honest score without submitting
- Hold-out: hide 20% of train and score on it → 0.671, close to the leaderboard. Now we have a leaderboard we control.
- Overfitting picture: a decision tree's train accuracy climbs to 1.0 as it gets deeper, while validation flattens and drops. The gap between the two curves is overfitting.
- One split is noisy: the same model on 10 random 80/20 splits scored anywhere from 0.659 to 0.780. With ~80 houses in validation, each house is worth >1%.
- K-fold CV: 5 folds, each takes a turn as the exam → 0.703 ± 0.034.
- Repeated K-fold: 5-fold done 3 times with different shuffles (15 scores averaged) → 0.696 ± 0.030. This is
cv_score()inutils.py, used for everything after this.
Rule: trust your CV, not the public leaderboard. The public leaderboard here is 62 houses (one house ≈ 1.6%). CV uses all 408 houses, three times over.
Noise level to remember: std 0.03 / √15 ≈ ±0.8%. Differences smaller than that are probably luck.
04 · Feature engineering: make new columns, keep only what CV likes
Method: change one thing → re-run CV with the same model → keep it only if CV clearly improves. Log everything.
| experiment | CV | vs baseline | verdict |
|---|---|---|---|
| 0. raw columns | 0.696 | — | |
1. + amenities (count of "yes" boxes) |
0.700 | +0.4% | within noise |
2. + room ratios (total_rooms, area_per_room, bath_per_bed) |
0.689 | −0.7% | within noise / worse |
3. + log_area |
0.696 | 0.0% | exactly 0, as predicted: trees only care about order, and a log keeps the order |
| 4a. + "same description" price (LEAKY) | 0.800 | +10.4% | ⚠️ too good to be true |
| 4b. same idea, done correctly in-fold | 0.700 | +0.4% | the "magic" disappears |
The leakage lesson (the most important thing in this project)
Idea 4: for each house, take the average tier of houses with the same description (same bedrooms, bathrooms, stories, parking, furnishing, AC, area). Done the wrong way (averages computed on all of train, including the house itself), CV jumped to 0.80, our "best idea".
| CV | public | private | |
|---|---|---|---|
| leaky feature | 0.800 | 0.532 | 0.453 |
| our first plain Random Forest | 0.696 | 0.629 | 0.653 |
Most descriptions appear only once, so the feature was the answer. The model learned to copy it, and on test houses the shortcut doesn't exist.
Data leakage = information about the answer sneaking into the features. Fix: compute anything that uses the target inside each CV fold, from the training part only (add_target_encoding() + cv_score(..., fold_fn=...)).
Decision: nothing beat the noise, so we keep the 12 raw columns. Simpler wins when the evidence is unclear.
(In extra_california/, which has 40× more data, distance-to-city features gave a real +3%. Feature engineering matters more with more data, and when domain knowledge adds something the raw columns don't show.)
05 · Models and tuning
Defaults shoot-out (same features, same CV)
| model | CV |
|---|---|
| Logistic Regression | 0.719 |
| CatBoost | 0.713 |
| Random Forest | 0.696 |
| LightGBM | 0.695 |
| XGBoost | 0.688 |
The simplest model wins with default settings. With 400 rows, flexible models memorise noise. LightGBM and XGBoost defaults are built for thousands to millions of rows. Fix: regularise, i.e. force the models to stay simpler.
Early stopping
LightGBM watching a validation set found the best point at 167 trees (learning rate 0.03). After that, validation error rises again. It's the same overfitting curve as in 03.
Tuning (small manual grids, scored by CV)
| model | best setting | CV |
|---|---|---|
| CatBoost | depth=4 | 0.7345 |
| LightGBM | num_leaves=4 (tiny trees) | 0.7296 |
| XGBoost | max_depth=2 (tiny trees) | 0.7296 |
| Random Forest | min_samples_leaf=5 | 0.7214 |
| Logistic Regression | C=1.0 (the default) | 0.7190 |
Every tree model got better when it was restricted: RF went from 0.693 to 0.721 just by requiring 5 houses per leaf. After tuning, all five are within ~1.5% of each other, which is close to the noise level.
Other things tried
log_areafor Logistic Regression (a non-tree model, where a log can matter): 0.719 → 0.721, within noise, so it stays dropped.- Ordinal trick (predict 0–2 as a number, then round): 0.724 vs 0.730 for the classifier, within noise. It's always worth one try when the target is ordered.
06 · Ensemble and the final reveal
Why blend: models make different mistakes, and averaging their probabilities cancels some of them. How often the models disagree: Logistic Regression vs the tree models 16–19% of houses. LightGBM vs XGBoost only 2%, because they're near-identical cousins. Diversity comes from different kinds of model.
Final submission = equal-weight average of all 5 tuned models (OOF accuracy 0.738).
🎉 Private leaderboard revealed
| submission | CV | public | private |
|---|---|---|---|
| 02 majority | — | 0.403 | 0.293 |
| 02 random forest (default) | 0.696 | 0.629 | 0.653 |
| 04 leaky target encoding | 0.800 | 0.532 | 0.453 |
| 04 honest target encoding | 0.700 | 0.726 | 0.600 |
| 06 logistic regression | 0.719 | 0.710 | 0.733 |
| 06 random forest (tuned) | 0.721 | 0.694 | 0.733 |
| 06 LightGBM (tuned) | 0.730 | 0.710 | 0.733 |
| 06 XGBoost (tuned) | 0.730 | 0.694 | 0.733 |
| 06 CatBoost (tuned) | 0.735 | 0.694 | 0.707 |
| 06 final blend | 0.738 | 0.694 | 0.747 ← best |
What this table teaches:
- Leakage: highest CV, worst private score of any real model.
- Shake-up: "04 honest TE" had the best public score (0.726) and dropped to 0.600 on private. Anyone choosing by the public leaderboard would have picked it.
- Public is mostly luck on small test sets. Every tuned model scored 0.69–0.71 on public; private reorders them.
- The blend, chosen only by honest CV, ended up best on private. Following the process paid off.
Overall progress: 0.29 (dumb) → 0.65 (first model) → 0.75 (final), with no peeking at the private set.
Glossary
| term | in one line |
|---|---|
| feature / target | input columns / the column we predict |
| train / test | rows with answers / rows we must predict |
| overfitting | memorising training rows instead of learning the pattern |
| validation set | training rows hidden from the model so we can grade it honestly |
| cross-validation (K-fold) | every fold takes a turn as the validation set; average the scores |
| OOF predictions | each training row predicted by a model that never saw it |
| encoding | turning text categories into numbers (ordinal, one-hot, target encoding) |
| imputation | filling missing values (median / most common), learned from train only |
| data leakage | answer information sneaking into features → CV lies |
| regularisation | forcing a model to stay simpler (smaller trees, bigger leaves, smaller C) |
| hyperparameters | settings you choose (depth, learning rate…) as opposed to what the model learns |
| early stopping | stop adding trees when validation stops improving |
| ensembling / blending | averaging several models' predictions |
| public / private LB | the part of test you see during the competition / the part that decides the final ranking |
Mapping this to the real competition
The workstation rental competition should be the same shape: tabular rows, a price tier target, and some categorical columns.
| step | what she does there |
|---|---|
| 01 | read the Overview/Data/Evaluation tabs (check the metric, which may not be accuracy!), then info(), missing values, target counts, boxplots per tier |
| 02 | majority-class submission, then a default model, submitted the same day |
| 03 | set up (repeated if the data is small) stratified K-fold with the competition's metric |
| 04 | feature ideas from rental common sense (specs per price, brand/CPU tier groups, duration…), one at a time, logged; target-encode only inside folds |
| 05 | compare LogReg / RF / LightGBM / XGBoost / CatBoost; regularise if data is small; tune lightly |
| 06 | blend different model types; pick final submissions by CV, not by the public LB |
Differences to watch for: more rows (→ boosting usually wins, like in extra_california/), more or high-cardinality categoricals (→ CatBoost or in-fold target encoding), and a different metric (→ change accuracy_score in cv_score).
How to teach it
- One notebook per session. Before running a cell, ask her: "what do you expect this to print?" Then run it.
- Let her break things. Good exercises:
- 02: submit the training-set accuracy as if it were real, then compare.
- 03: change
n_repeatsto 1 and watch the CV numbers jump around. - 04: invent one feature of her own and run it through
experiment(). - 05: set
min_samples_leaf=1vs20and explain the CV change. - 06: blend only LightGBM + XGBoost and see that it barely helps (they agree 98% of the time).
- Make her keep a log (idea → CV → kept?) from day one. It's the habit that matters most in a real competition.
- Then compare with
extra_california/. Same steps on 16,500 rows: there feature engineering does help, tree models beat Logistic Regression by 15 points, and fold scores vary by ±0.8% instead of ±3%. Same process, different conclusions. That's why we measure instead of assuming.