Building a dataset: cleaning, labels and the split
The situation: 300 rows, nothing useful
Chapter 37 left behind ish_en/raw/listings_raw.csv. It has 300 rows, but not a single column is in a state you could hand to a model: the price is text in three formats, the area looks like "45.5 m²", and the duplicates haven't been removed.
This chapter turns the raw file into a dataset. A dataset isn't just a clean table. A dataset is:
- every column's type and range are known
- what each row means is written down
- the target is clearly defined
- train/val/test are split correctly
- all of it is reproducible
import re import sys import numpy as np import pandas as pd # We reuse the collector we wrote in Chapter 37: the raw data comes from there. # Every chapter has to work on its own, so we go back to the SOURCE, not to the file. sys.path.insert(0, str(ISH)) from collect.parse import parse_page FIX = DATA / "fixture" records = [] for page in sorted(FIX.glob("elonlar_*.html")): records += parse_page(page.read_text(encoding="utf-8")) raw = pd.DataFrame(records) print(f"Raw data: {raw.shape[0]} rows, {raw.shape[1]} columns") print() print(raw.dtypes.to_string()) print() print("Every column is `object`, meaning TEXT. Not a single number.")
Raw data: 300 rows, 7 columns source_id object title object price_raw object rooms_raw object area_raw object district object floor_raw object Every column is `object`, meaning TEXT. Not a single number.
The rule: count the rows at every step
Rows that vanish silently: the most dangerous kind of mistake
Cleaning applies dropna(), drop_duplicates() and filters one after another. Each of them removes rows.
The most common mistake I've seen: a student starts from a 10,000-row file, does 6 cleaning steps, ends up with 400 rows, and doesn't notice. Then asks "why is my model bad?"
The fix is a step log. After every operation the row count is recorded, and the lost rows are accounted for with a reason.
If one step loses 50% of the rows, that demands a decision from you: do you just drop them, or fill them in? Without a log you won't even see the question.
class StepLog: """Records every cleaning step.""" def __init__(self, initial): self.steps = [{"step": "start", "rows": initial, "lost": 0}] def record(self, name, rows): previous = self.steps[-1]["rows"] self.steps.append({"step": name, "rows": rows, "lost": previous - rows}) return rows def table(self): t = pd.DataFrame(self.steps) t["kept %"] = (t["rows"] / t["rows"].iloc[0] * 100).round(1) return t log = StepLog(len(raw)) print(f"Log started: {len(raw)} rows")
Log started: 300 rows
Turning the price into a number
The hardest column. Three formats, three rules.
print("Raw price values (first 10 unique):") for v in raw["price_raw"].dropna().unique()[:10]: print(f" {v!r}")
Raw price values (first 10 unique): "10 625 000 so'm" "12 500 000 so'm" '500\xa0у.е.' 'Kelishilgan' '450\xa0у.е.' "8 750 000 so'm" '700\xa0у.е.' '400\xa0у.е.' '350\xa0у.е.' '850\xa0у.е.'
create this filecleaning/text_to_number.py
"""Turning values written as text into numbers.""" import re import numpy as np SUM_RATE = 12500 # 1 USD = 12500 so'm (the rate when the dataset was made) def parse_price(text, rate=SUM_RATE): """Converts a price string to USD. Three formats are supported: "500 у.е." -> 500.0 "6 250 000 so'm" -> 500.0 "Kelishilgan" -> nan (NO number, nothing is made up) """ if not isinstance(text, str): return np.nan if "Kelishilgan" in text: return np.nan # no price, we don't guess digits = re.sub(r"[^\d]", "", text.replace("\xa0", " ")) if not digits: return np.nan value = int(digits) return value / rate if "so'm" in text else float(value) def first_number(text, pattern=r"([\d.]+)"): """Extracts the first number from a string: "45.5 m²" -> 45.5, "11-qavat" -> 11.0""" if not isinstance(text, str): return np.nan found = re.search(pattern, text) return float(found.group(1)) if found else np.nan
from cleaning.text_to_number import parse_price, first_number tests = ["500\xa0у.е.", "6 250 000 so'm", "Kelishilgan", None, "12 500 000 so'm"] print(f"{'input':<22} {'result (USD)':>14}") print("-" * 38) for s in tests: print(f"{repr(s):<22} {parse_price(s)!s:>14}") print() INTEGER = r"(\d+)" area_test = first_number("45.5 m²") floor_test = first_number("11-qavat", INTEGER) print(f"first_number('45.5 m²') = {area_test}") print(f"first_number('11-qavat') = {floor_test}")
input result (USD)
--------------------------------------
'500\xa0у.е.' 500.0
"6 250 000 so'm" 500.0
'Kelishilgan' nan
None nan
"12 500 000 so'm" 1000.0
first_number('45.5 m²') = 45.5
first_number('11-qavat') = 11.0
Don't make up missing values
When you see "Kelishilgan" ("negotiable"), it's very tempting to put in the average price: the row is kept, the NaN disappears.
Don't. You don't know this flat's price. If you put in the average, you give the model a false fact: "this flat is average-priced". We saw imputation in Chapter 5, but imputing the target is a different thing: the target is the model's source of truth. Making it up breaks the whole training.
The rule: features can be filled in, the target can't. If the target is missing, the row is no good for training.
clean = pd.DataFrame({ "source_id": raw["source_id"], "district": raw["district"], "rooms": raw["rooms_raw"].apply(lambda s: first_number(s, r"(\d+)")), "area_m2": raw["area_raw"].apply(first_number), "floor": raw["floor_raw"].apply(lambda s: first_number(s, r"(\d+)")), "price_usd": raw["price_raw"].apply(parse_price), }) print("Missing values after conversion:") for c in ["district", "rooms", "area_m2", "floor", "price_usd"]: n = int(clean[c].isna().sum()) print(f" {c:<12} {n:>4} missing ({n / len(clean):.1%})")
Missing values after conversion: district 0 missing (0.0%) rooms 26 missing (8.7%) area_m2 35 missing (11.7%) floor 0 missing (0.0%) price_usd 46 missing (15.3%)
Setting aside the rows without a target
no_price = int(clean["price_usd"].isna().sum()) clean_t = clean[clean["price_usd"].notna()].copy() log.record("rows without a price removed", len(clean_t)) print(f"No price ('Kelishilgan' and empty): {no_price} rows") print(f"Kept: {len(clean_t)} rows") print() print("These rows are NOT useless: they're used for prediction.") print("They're just no good for TRAINING. So they can be stored separately.")
No price ('Kelishilgan' and empty): 46 rows
Kept: 254 rows
These rows are NOT useless: they're used for prediction.
They're just no good for TRAINING. So they can be stored separately.
before = len(clean_t) clean_t = clean_t.dropna(subset=["rooms", "area_m2", "floor"]) log.record("rows with incomplete features", len(clean_t)) print(f"Rows missing a feature: {before - len(clean_t)}") print(f"Kept: {len(clean_t)} rows")
Rows missing a feature: 47 Kept: 207 rows
Checking: the data contract
You have to check that the cleaned data is within the expected range. Otherwise the error shows up later, while the model is training.
create this filecleaning/contract.py
"""Dataset contract: type and range for every column.""" CONTRACT = { "district": {"type": "text", "allowed": {"Yunusobod", "Chilonzor", "Mirzo Ulug'bek", "Shayxontohur", "Yakkasaroy", "Olmazor", "Sergeli", "Bektemir"}}, "rooms": {"type": "number", "min": 1, "max": 10}, "area_m2": {"type": "number", "min": 15, "max": 400}, "floor": {"type": "number", "min": 1, "max": 50}, "price_usd": {"type": "number", "min": 50, "max": 20000}, } def check(df, contract=CONTRACT): """Returns a list of contract violations. An empty list means all is well.""" problems = [] for column, rule in contract.items(): if column not in df.columns: problems.append(f"{column}: column MISSING") continue value = df[column] if rule["type"] == "number": out_of_range = ((value < rule["min"]) | (value > rule["max"])).sum() if out_of_range: problems.append( f"{column}: {out_of_range} values outside [{rule['min']}, {rule['max']}]") else: wrong = set(value.dropna().unique()) - rule["allowed"] if wrong: problems.append(f"{column}: value not allowed {sorted(wrong)[:5]}") if value.isna().any(): problems.append(f"{column}: {int(value.isna().sum())} missing values") return problems
from cleaning.contract import check problems = check(clean_t) print(f"Contract check: {len(problems)} problems") for m in problems: print(f" - {m}") if not problems: print(" (everything matches the contract)") print() print("The contract is an EXPECTATION written in code. When it breaks you know at once,") print("instead of thinking 'my model is weird' 3 days later.")
Contract check: 0 problems (everything matches the contract) The contract is an EXPECTATION written in code. When it breaks you know at once, instead of thinking 'my model is weird' 3 days later.
Duplicates: a group identifier
We measured it in Chapter 37: even though source_id is unique, the same flat was listed several times. Now we give each flat a group number.
clean_t["group"] = clean_t.groupby(["district", "area_m2", "rooms"]).ngroup() unique_flats = clean_t["group"].nunique() print(f"Rows : {len(clean_t)}") print(f"Unique flats (group): {unique_flats}") print(f"Extra records : {len(clean_t) - unique_flats} ({(len(clean_t) - unique_flats) / len(clean_t):.1%})") print() largest = clean_t["group"].value_counts() print(f"The most repeated flat was listed {int(largest.iloc[0])} times:") show(clean_t[clean_t["group"] == largest.index[0]][["source_id", "district", "area_m2", "rooms", "price_usd"]])
Rows : 207 Unique flats (group): 176 Extra records : 31 (15.0%) The most repeated flat was listed 5 times:
| source_id | district | area_m2 | rooms | price_usd |
|---|---|---|---|---|
| 131 | Mirzo Ulug'bek | 60.4 | 2 | 600 |
| 176 | Mirzo Ulug'bek | 60.4 | 2 | 600 |
| 190 | Mirzo Ulug'bek | 60.4 | 2 | 600 |
| 250 | Mirzo Ulug'bek | 60.4 | 2 | 600 |
| 284 | Mirzo Ulug'bek | 60.4 | 2 | 600 |
You don't have to DELETE duplicates, you have to account for them in the split
The first thought: delete the duplicates with drop_duplicates(). Sometimes that's right.
But a repeated listing is information too: it shows the flat stayed unrented for a long time. Delete it and you lose that signal.
What matters is that the same flat doesn't land in both train and test. The way to do that: keep the group column and split by group. In the section below we measure what happens.
The split: now we measure why the group matters
from sklearn.ensemble import RandomForestRegressor from sklearn.neighbors import KNeighborsRegressor from sklearn.model_selection import train_test_split, GroupShuffleSplit from sklearn.metrics import r2_score X = pd.get_dummies(clean_t[["district", "rooms", "area_m2", "floor"]], columns=["district"]) y = clean_t["price_usd"].values groups = clean_t["group"].values result = [] for name, make in [("KNN (k=1)", lambda: KNeighborsRegressor(1)), ("KNN (k=3)", lambda: KNeighborsRegressor(3)), ("random forest", lambda: RandomForestRegressor(200, random_state=0))]: random_r2 = [r2_score(yb, make().fit(Xa, ya).predict(Xb)) for Xa, Xb, ya, yb in (train_test_split(X, y, test_size=.25, random_state=s) for s in range(5))] grouped = [] for s in range(5): tr, te = next(GroupShuffleSplit(n_splits=1, test_size=.25, random_state=s).split(X, y, groups=groups)) grouped.append(r2_score(y[te], make().fit(X.iloc[tr], y[tr]).predict(X.iloc[te]))) result.append({"model": name, "random split R2": round(float(np.mean(random_r2)), 3), "group split R2": round(float(np.mean(grouped)), 3), "fake gap": round(float(np.mean(random_r2) - np.mean(grouped)), 3)}) show(pd.DataFrame(result))
| model | random split R2 | group split R2 | fake gap |
|---|---|---|---|
| KNN (k=1) | -0.796 | -0.97 | 0.174 |
| KNN (k=3) | -0.28 | -0.378 | 0.098 |
| random forest | -0.126 | -0.297 | 0.171 |
Three models, three different internal mechanisms, but the sign of the gap is the same: the random split looks better every time.
R2 is negative on this dataset, and that's the RIGHT result
The R2 values in the table are negative. That isn't a bug, and it matters to understand it.
In the datasets/fixture_qur.py generator the price is chosen at random: it doesn't depend on the district, the area or the rooms. So there is no pattern to learn in this data.
What a negative R2 means: the model does worse than just saying the average. The baseline from Chapter 7 is exactly that: DummyRegressor says the average and gets R2 = 0. Our model is below 0, so it has fitted the noise (overfitting).
Why that's convenient for us: since there's no pattern, any positive result can only come from leakage. So the higher "random split" column is pure, measured leakage and nothing else.
In a real project you won't see this: there you have a pattern and leakage at the same time, mixed together. That's why the honest split comes first, not after you've seen the result.
Splitting into three parts
# Stage 1: split off the test set BY GROUP tr_idx, te_idx = next(GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42).split(X, y, groups=groups)) rest = clean_t.iloc[tr_idx] test = clean_t.iloc[te_idx] # Stage 2: from the rest, split off validation, also BY GROUP X2 = X.iloc[tr_idx] tr2, va2 = next(GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42).split(X2, y[tr_idx], groups=rest["group"].values)) train = rest.iloc[tr2] val = rest.iloc[va2] print(f"{'part':<8} {'rows':>7} {'unique flats':>13} {'share':>8}") print("-" * 39) for name, b in [("train", train), ("val", val), ("test", test)]: print(f"{name:<8} {len(b):>7} {b['group'].nunique():>13} {len(b) / len(clean_t):>7.1%}") print() overlap = (set(train["group"]) & set(test["group"])) | (set(train["group"]) & set(val["group"])) print(f"Flats shared between parts: {len(overlap)} <- MUST be 0") log.record("split into three parts", len(train) + len(val) + len(test))
part rows unique flats share --------------------------------------- train 136 112 65.7% val 33 28 15.9% test 38 36 18.4% Flats shared between parts: 0 <- MUST be 0
The step log: every row accounted for
show(log.table()) print(f"Started: {log.steps[0]['rows']} rows") print(f"Kept : {log.steps[-1]['rows']} rows") print(f"Lost : {log.steps[0]['rows'] - log.steps[-1]['rows']} rows, each with its reason above")
Started: 300 rows Kept : 207 rows Lost : 93 rows, each with its reason above
| step | rows | lost | kept % |
|---|---|---|---|
| start | 300 | 0 | 100 |
| rows without a price removed | 254 | 46 | 84.7 |
| rows with incomplete features | 207 | 47 | 69 |
| split into three parts | 207 | 0 | 69 |
The datasheet: the dataset's passport
An undocumented dataset is a time bomb
Six months from now you (or a colleague) open this file and ask: what currency is the price in? when was it collected? why are 93 rows missing? where are the Kelishilgan rows?
If the answers aren't written down, the dataset becomes untrustworthy and has to be rebuilt.
So every dataset is stored together with a datasheet: a plain text file, 10 minutes of work.
datasheet = f"""# listings dataset: datasheet ## What Tashkent rental listings. One row = one listing. ## Source `datasets/fixture/elonlar_*.html`: SYNTHETIC saved pages (`datasets/fixture_qur.py`, fixed seed). NOT a live site. That's why the numbers come out the same every time. ## Size - raw records: {len(raw)} - after cleaning: {len(clean_t)} - unique flats: {clean_t['group'].nunique()} - train / val / test: {len(train)} / {len(val)} / {len(test)} ## Target `price_usd`: monthly rent in US dollars. Rate: 1 USD = 12500 so'm (listings priced in so'm were converted at this rate). ## IMPORTANT warning In the generator the price was chosen INDEPENDENTLY of the features. So there is NO real pattern to learn in this dataset. It is only for teaching: cleaning, duplicates, splitting by group and measuring leakage. Trying to get a good R2 on this dataset is pointless. ## Removed - rows without a price ("Kelishilgan"): no good for training, the target is not made up - rows missing a feature ## Personal data Phone numbers were NOT COLLECTED (`collect/rules.py` -> `drop_personal`). The dataset has no personal fields of any kind. ## Split rule GroupShuffleSplit, group = (district, area_m2, rooms). Each flat lands in exactly one part. A random split is NOT USED. """ path = ISH / "dataset" path.mkdir(exist_ok=True) (path / "DATASHEET.md").write_text(datasheet, encoding="utf-8") print(datasheet)
# listings dataset: datasheet
## What
Tashkent rental listings. One row = one listing.
## Source
`datasets/fixture/elonlar_*.html`: SYNTHETIC saved pages
(`datasets/fixture_qur.py`, fixed seed). NOT a live site.
That's why the numbers come out the same every time.
## Size
- raw records: 300
- after cleaning: 207
- unique flats: 176
- train / val / test: 136 / 33 / 38
## Target
`price_usd`: monthly rent in US dollars.
Rate: 1 USD = 12500 so'm (listings priced in so'm were converted at this rate).
## IMPORTANT warning
In the generator the price was chosen INDEPENDENTLY of the features. So there is
NO real pattern to learn in this dataset. It is only for teaching:
cleaning, duplicates, splitting by group and measuring leakage.
Trying to get a good R2 on this dataset is pointless.
## Removed
- rows without a price ("Kelishilgan"): no good for training, the target is not made up
- rows missing a feature
## Personal data
Phone numbers were NOT COLLECTED (`collect/rules.py` -> `drop_personal`).
The dataset has no personal fields of any kind.
## Split rule
GroupShuffleSplit, group = (district, area_m2, rooms).
Each flat lands in exactly one part. A random split is NOT USED.
for name, b in [("train", train), ("val", val), ("test", test)]: b.to_csv(path / f"{name}.csv", index=False) no_price_df = clean[clean["price_usd"].isna()] no_price_df.to_csv(path / "no_price_for_prediction.csv", index=False) print("Saved to the ish_en/dataset/ folder:") for p in sorted(path.iterdir()): print(f" {p.name:<32} {p.stat().st_size:>7} bytes") print() print("Now this folder can be handed to someone else: data + documentation + split rule.")
Saved to the ish_en/dataset/ folder: DATASHEET.md 1193 bytes no_price_for_prediction.csv 1360 bytes test.csv 1485 bytes train.csv 5154 bytes val.csv 1279 bytes Now this folder can be handed to someone else: data + documentation + split rule.
Connecting to the protocol
This chapter carried out steps 1–4 of the protocol from Chapter 1, but on your own data:
show(pd.DataFrame([ {"Step": "1. Work out column types", "In this chapter": "text -> number, three price formats"}, {"Step": "2. Count missing values", "In this chapter": "with a log, at every step"}, {"Step": "3. Define the target", "In this chapter": "price_usd; the target is not made up"}, {"Step": "4. Split", "In this chapter": "GroupShuffleSplit: leakage measured"}, {"Step": "5. Build a baseline", "Next": "Chapter 39: fine-tuning; Chapter 7: the baseline rule"}, ]))
| Step | In this chapter | Next |
|---|---|---|
| 1. Work out column types | text -> number, three price formats | NaN |
| 2. Count missing values | with a log, at every step | NaN |
| 3. Define the target | price_usd; the target is not made up | NaN |
| 4. Split | GroupShuffleSplit: leakage measured | NaN |
| 5. Build a baseline | NaN | Chapter 39: fine-tuning; Chapter 7: the baseline rule |
What we learned today
show(pd.DataFrame([ {"Concept": "Step log", "In one sentence": "Rows are counted after every operation: nothing vanishes silently"}, {"Concept": "The target is not made up", "In one sentence": "Features are filled in, the target isn't"}, {"Concept": "Data contract", "In one sentence": "Type and range as an expectation written in code"}, {"Concept": "Group identifier", "In one sentence": "One flat = one group, built from stable fields"}, {"Concept": "GroupShuffleSplit", "In one sentence": "Measured: a random split inflates R2"}, {"Concept": "Negative R2", "In one sentence": "Worse than the average: below Chapter 7's baseline"}, {"Concept": "Datasheet", "In one sentence": "The dataset's passport: 10 minutes now, a year saved later"}, {"Concept": "Rows without a price", "In one sentence": "No good for training, good for prediction"}, ]))
| Concept | In one sentence |
|---|---|
| Step log | Rows are counted after every operation: nothing vanishes silently |
| The target is not made up | Features are filled in, the target isn't |
| Data contract | Type and range as an expectation written in code |
| Group identifier | One flat = one group, built from stable fields |
| GroupShuffleSplit | Measured: a random split inflates R2 |
| Negative R2 | Worse than the average: below Chapter 7's baseline |
| Datasheet | The dataset's passport: 10 minutes now, a year saved later |
| Rows without a price | No good for training, good for prediction |
In the next chapter we adapt a pretrained model to our own data: fine-tuning.
Questions about this chapter?
The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.