Preparing the data: encoding, missing values, scaling
The scene: the model doesn't understand text
You looked at the data (Chapter 4) and found the problems. Now you hand it to the model:
model.fit(df[["tuman", "maydon_m2"]], y)
And you get an error:
ValueError: could not convert string to float: 'Yunusobod'
A model understands only numbers. "Yunusobod" is text. This chapter teaches you to turn text into numbers, gaps into values, and different scales into one scale.
This is steps 7 and 8 of the protocol.
Column names
The data is synthetic Tashkent rental listings, and its column names are Uzbek. tuman district, xonalar rooms, maydon_m2 area in m², qavat floor, qavatlar_soni floors in the building, metro_km distance to the metro, mebel furnished (0/1), qurilgan_yil year built, elon_id listing id, narx_matn price as text, agent_komissiyasi agent fee, narx_usd monthly rent in USD. The target ijaraga_berildi_30k means rented within 30 days.
First we look: what needs fixing
import pandas as pd import numpy as np df = pd.read_csv(DATA / "uy_ijara.csv") X = df.drop(columns=["ijaraga_berildi_30k", "narx_usd"]) print("Column types:") print(X.dtypes.to_string()) print() print("List of problems:") text = X.select_dtypes("object").columns.tolist() print(f" 1. Text columns (need encoding): {text}") missing = X.isna().sum() print(f" 2. Missing values: {missing[missing > 0].to_dict()}") print(f" 3. Duplicate rows: {int(X.duplicated().sum())}") scale = X.select_dtypes(include=[np.number]).agg(["min", "max"]).T scale["range"] = scale["max"] - scale["min"] print(f" 4. Different scales: smallest range {scale.range.min():.1f}, largest {scale.range.max():.1f}")
Column types:
elon_id object
tuman object
xonalar int64
maydon_m2 float64
qavat int64
qavatlar_soni int64
metro_km float64
mebel int64
qurilgan_yil float64
narx_matn object
agent_komissiyasi int64
List of problems:
1. Text columns (need encoding): ['elon_id', 'tuman', 'narx_matn']
2. Missing values: {'qurilgan_yil': 8}
3. Duplicate rows: 0
4. Different scales: smallest range 1.0, largest 610.0
Text into numbers: two kinds of category
Not all text columns are alike. They come in two kinds, and they have to be encoded differently.
show(pd.DataFrame([ {"Kind": "Ordinal (ordered)", "Example": "small < medium < large; 1st year < 2nd year", "Is there an order": "YES", "Method": "OrdinalEncoder or .map()"}, {"Kind": "Nominal (unordered)", "Example": "district, colour, country", "Is there an order": "NO", "Method": "OneHotEncoder"}, ]))
| Kind | Example | Is there an order | Method |
|---|---|---|---|
| Ordinal (ordered) | small < medium < large; 1st year < 2nd year | YES | OrdinalEncoder or .map() |
| Nominal (unordered) | district, colour, country | NO | OneHotEncoder |
Why does the difference matter? Let me show you.
from sklearn.preprocessing import OrdinalEncoder, OneHotEncoder districts = X[["tuman"]].head(6) print("Original values:") print(districts.tuman.tolist()) print() # WRONG: giving a nominal column numbers ord_code = OrdinalEncoder().fit_transform(X[["tuman"]]) print("If we use OrdinalEncoder:") for t, k in zip(districts.tuman, ord_code[:6].ravel()): print(f" {t:16s} -> {k:.0f}") print() print("PROBLEM: the model now thinks 'Yunusobod (7) > Bektemir (0)'.") print(" That is MEANINGLESS: districts cannot be ordered.")
Original values:
['Chilonzor', 'Yakkasaroy', "Mirzo Ulug'bek", "Mirzo Ulug'bek", 'Chilonzor', 'Olmazor']
If we use OrdinalEncoder:
Chilonzor -> 1
Yakkasaroy -> 6
Mirzo Ulug'bek -> 2
Mirzo Ulug'bek -> 2
Chilonzor -> 1
Olmazor -> 3
PROBLEM: the model now thinks 'Yunusobod (7) > Bektemir (0)'.
That is MEANINGLESS: districts cannot be ordered.
# RIGHT: OneHot for a nominal column oh = OneHotEncoder(sparse_output=False, handle_unknown="ignore") encoded = oh.fit_transform(X[["tuman"]]) names = oh.get_feature_names_out(["tuman"]) print(f"OneHotEncoder: 1 column -> {encoded.shape[1]} columns") print() result = pd.DataFrame(encoded[:6].astype(int), columns=[n.replace("tuman_", "") for n in names]) result.insert(0, "original", districts.tuman.values) show(result)
OneHotEncoder: 1 column -> 8 columns
| original | Bektemir | Chilonzor | Mirzo Ulug'bek | Olmazor | Sergeli | Shayxontohur | Yakkasaroy | Yunusobod |
|---|---|---|---|---|---|---|---|---|
| Chilonzor | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 |
| Yakkasaroy | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 |
| Mirzo Ulug'bek | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 |
| Mirzo Ulug'bek | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 |
| Chilonzor | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 |
| Olmazor | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 |
Every district gets its own column, and the value is 0 or 1. No artificial order at all.
Don't forget handle_unknown="ignore"
You trained the model. A month later a new district appears, say "Uchtepa".
Without handle_unknown="ignore", the model raises an error and your server goes down.
With it, the new district is 0 in every column and the model keeps working.
Every OneHotEncoder that goes to production needs this parameter.
print("Proof: when a NEW district reaches the model:") new = pd.DataFrame({"tuman": ["Uchtepa"]}) print(" with handle_unknown='ignore':", oh.transform(new).astype(int).tolist()) strict = OneHotEncoder(sparse_output=False).fit(X[["tuman"]]) try: strict.transform(new) except Exception as e: print(" without handle_unknown :", type(e).__name__, "->", str(e)[:58])
Proof: when a NEW district reaches the model: with handle_unknown='ignore': [[0, 0, 0, 0, 0, 0, 0, 0]] without handle_unknown : ValueError -> Found unknown categories ['Uchtepa'] in column 0 during tr
For an ordinal column, you state the order yourself:
# A made-up example: put flats into condition categories X_example = X.copy() X_example["condition"] = pd.cut(X_example.qurilgan_yil.fillna(X_example.qurilgan_yil.median()), bins=[0, 1990, 2010, 2100], labels=["old", "mid", "new"]) order = {"old": 0, "mid": 1, "new": 2} X_example["condition_code"] = X_example.condition.map(order) show(X_example[["qurilgan_yil", "condition", "condition_code"]].head(6)) print() print("Here the order MEANS something: new > mid > old. So .map() is correct.")
Here the order MEANS something: new > mid > old. So .map() is correct.
| qurilgan_yil | condition | condition_code |
|---|---|---|
| 2 016 | new | 2 |
| 1 977 | old | 0 |
| 2 010 | mid | 1 |
| 2 007 | mid | 1 |
| 1 989 | old | 0 |
| 1 987 | old | 0 |
Missing values
print(f"qurilgan_yil missing: {int(X.qurilgan_yil.isna().sum())} ({X.qurilgan_yil.isna().mean():.2%})") print() show(pd.DataFrame([ {"Method": "Drop the row", "When": "< 1% missing and plenty of data", "Cost": "Loses data; if the gaps aren't random, it introduces bias"}, {"Method": "Fill with the median", "When": "Numeric column, 1-30% missing", "Cost": "Reduces the spread; robust to outliers"}, {"Method": "Fill with the mean", "When": "Numeric column, symmetric distribution", "Cost": "Outliers drag the mean"}, {"Method": "The most frequent value", "When": "Text/category column", "Cost": "Makes the big category even bigger"}, {"Method": "'MISSING' as its own category", "When": "The gap ITSELF means something", "Cost": "None; often the best choice"}, {"Method": "Fill with a model (KNN)", "When": "Many gaps and related columns", "Cost": "Slow; high leakage risk"}, ]))
qurilgan_yil missing: 8 (0.67%)
| Method | When | Cost |
|---|---|---|
| Drop the row | < 1% missing and plenty of data | Loses data; if the gaps aren't random, it introduces bias |
| Fill with the median | Numeric column, 1-30% missing | Reduces the spread; robust to outliers |
| Fill with the mean | Numeric column, symmetric distribution | Outliers drag the mean |
| The most frequent value | Text/category column | Makes the big category even bigger |
| 'MISSING' as its own category | The gap ITSELF means something | None; often the best choice |
| Fill with a model (KNN) | Many gaps and related columns | Slow; high leakage risk |
The second-to-last method is the one people skip most. Example: the deck column is 77% empty (Chapter 42), because cheap tickets had no cabin number written. In other words, the gap itself means "cheap ticket".
from sklearn.impute import SimpleImputer for strat in ["median", "mean", "most_frequent"]: t = SimpleImputer(strategy=strat).fit_transform(X[["qurilgan_yil"]]) print(f" {strat:14s} -> filled value: {t[X.qurilgan_yil.isna().values][0][0]:.1f}") print() print("An extra column: keeping a 'was missing' flag") flagged = SimpleImputer(strategy="median", add_indicator=True).fit_transform(X[["qurilgan_yil"]]) print(f" add_indicator=True -> {flagged.shape[1]} columns (value + missing flag)")
median -> filled value: 1998.0 mean -> filled value: 1999.0 most_frequent -> filled value: 1995.0 An extra column: keeping a 'was missing' flag add_indicator=True -> 2 columns (value + missing flag)
Filling is learned from train ONLY
SimpleImputer().fit(X) fits on all the data. That is leakage (Chapter 6).
The median has to come from train and then be applied to test. Pipeline does that automatically, which is why we use it.
Scaling: why you need it, and when you don't
scale = X.select_dtypes(include=[np.number]).agg(["min", "max", "mean", "std"]).T.round(2) show(scale.reset_index().rename(columns={"index": "column"}))
| column | min | max | mean | std |
|---|---|---|---|---|
| xonalar | 1 | 5 | 2.51 | 1.07 |
| maydon_m2 | 22 | 114 | 57.19 | 20.14 |
| qavat | 1 | 16 | 5.1 | 3.61 |
| qavatlar_soni | 2 | 16 | 9.03 | 4.31 |
| metro_km | 0.1 | 8.05 | 2.58 | 1.6 |
| mebel | 0 | 1 | 0.61 | 0.49 |
| qurilgan_yil | 1 975 | 2 024 | 1 998.95 | 14.31 |
| agent_komissiyasi | 0 | 610 | 167.4 | 178.24 |
Look at the problem: maydon_m2 runs from 22 to 220, while mebel is 0 or 1.
For distance-based models (KNN, SVM) this is a disaster: a 1-unit difference in maydon_m2 counts the same as a 1-unit difference in mebel, even though the first matters 200 times less.
from sklearn.preprocessing import StandardScaler, MinMaxScaler, RobustScaler num = ["xonalar", "maydon_m2", "metro_km", "mebel"] for name, sc in [("StandardScaler", StandardScaler()), ("MinMaxScaler", MinMaxScaler()), ("RobustScaler", RobustScaler())]: t = sc.fit_transform(X[num]) print(f"{name:16s} mean={t.mean():+.3f} std={t.std():.3f} " f"min={t.min():+.2f} max={t.max():+.2f}")
StandardScaler mean=+0.000 std=1.000 min=-1.75 max=+3.41 MinMaxScaler mean=+0.421 std=0.335 min=+0.00 max=+1.00 RobustScaler mean=+0.061 std=0.833 min=-1.18 max=+3.00
show(pd.DataFrame([ {"Scaler": "StandardScaler", "What it does": "Mean 0, std 1", "When": "The default choice; when the distribution is close to normal"}, {"Scaler": "MinMaxScaler", "What it does": "Squeezes into 0 to 1", "When": "Neural networks, when exact bounds are needed"}, {"Scaler": "RobustScaler", "What it does": "Uses the median and IQR", "When": "When there are MANY outliers"}, ]))
| Scaler | What it does | When |
|---|---|---|
| StandardScaler | Mean 0, std 1 | The default choice; when the distribution is close to normal |
| MinMaxScaler | Squeezes into 0 to 1 | Neural networks, when exact bounds are needed |
| RobustScaler | Uses the median and IQR | When there are MANY outliers |
Now the most important question: which models need scaling?
show(pd.DataFrame([ {"Model": "KNN", "Scaling": "REQUIRED", "Why": "It computes distances"}, {"Model": "SVM", "Scaling": "REQUIRED", "Why": "Distances and the margin"}, {"Model": "Logistic / Linear Regression", "Scaling": "Recommended", "Why": "Gradients converge faster; coefficients become comparable"}, {"Model": "Ridge / Lasso", "Scaling": "REQUIRED", "Why": "The penalty depends on coefficient size"}, {"Model": "Neural network", "Scaling": "REQUIRED", "Why": "Exploding/vanishing gradients"}, {"Model": "Decision Tree", "Scaling": "NOT NEEDED", "Why": "It only asks 'bigger or smaller?'"}, {"Model": "Random Forest", "Scaling": "NOT NEEDED", "Why": "Built from trees"}, {"Model": "XGBoost / LightGBM", "Scaling": "NOT NEEDED", "Why": "Built from trees"}, ]))
| Model | Scaling | Why |
|---|---|---|
| KNN | REQUIRED | It computes distances |
| SVM | REQUIRED | Distances and the margin |
| Logistic / Linear Regression | Recommended | Gradients converge faster; coefficients become comparable |
| Ridge / Lasso | REQUIRED | The penalty depends on coefficient size |
| Neural network | REQUIRED | Exploding/vanishing gradients |
| Decision Tree | NOT NEEDED | It only asks 'bigger or smaller?' |
| Random Forest | NOT NEEDED | Built from trees |
| XGBoost / LightGBM | NOT NEEDED | Built from trees |
Let's confirm that by measuring it:
from sklearn.model_selection import cross_val_score from sklearn.pipeline import Pipeline from sklearn.neighbors import KNeighborsClassifier from sklearn.tree import DecisionTreeClassifier y = df["ijaraga_berildi_30k"] Xs = X[num] print(f"{'Model':<16} {'no scaling':>14} {'scaling':>13} {'gap':>8}") print("-" * 54) for name, m in [("KNN", KNeighborsClassifier()), ("DecisionTree", DecisionTreeClassifier(max_depth=6, random_state=42))]: without = cross_val_score(m, Xs, y, cv=5).mean() with_sc = cross_val_score(Pipeline([("sc", StandardScaler()), ("m", m)]), Xs, y, cv=5).mean() print(f"{name:<16} {without:>14.4f} {with_sc:>13.4f} {with_sc-without:>+8.4f}")
Model no scaling scaling gap ------------------------------------------------------ KNN 0.6692 0.6600 -0.0092 DecisionTree 0.6858 0.6858 +0.0000
Look at the table: for KNN, scaling changed the result; for the tree it barely changed anything. The theory is confirmed by measurement.
Duplicate rows
print(f"Duplicates in our file: {int(X.duplicated().sum())}") print() print("But careful: ALMOST identical rows are a problem too:") fake = pd.DataFrame({ "tuman": ["Chilonzor", "chilonzor", "Chilonzor "], "maydon_m2": [55.0, 55.0, 55.0], }) print(fake.to_string(index=False)) print() print("Duplicates duplicated() finds:", int(fake.duplicated().sum())) fake_n = fake.assign(tuman=fake.tuman.str.strip().str.lower()) print("After normalising :", int(fake_n.duplicated().sum()))
Duplicates in our file: 0
But careful: ALMOST identical rows are a problem too:
tuman maydon_m2
Chilonzor 55.0
chilonzor 55.0
Chilonzor 55.0
Duplicates duplicated() finds: 0
After normalising : 2
So the order is: normalise first, then drop duplicates. The exercise in Chapter 44 has exactly this problem.
All together: ColumnTransformer
Each column type needs different treatment. ColumnTransformer gathers all of it into one object.
from sklearn.compose import ColumnTransformer from sklearn.linear_model import LogisticRegression num_cols = ["xonalar", "maydon_m2", "qavat", "qavatlar_soni", "metro_km", "mebel", "qurilgan_yil"] cat_cols = ["tuman"] prep = ColumnTransformer([ ("num", Pipeline([ ("impute", SimpleImputer(strategy="median")), ("scale", StandardScaler()), ]), num_cols), ("cat", Pipeline([ ("impute", SimpleImputer(strategy="most_frequent")), ("encode", OneHotEncoder(handle_unknown="ignore")), ]), cat_cols), ], remainder="drop") pipe = Pipeline([("prep", prep), ("model", LogisticRegression(max_iter=2000))]) score = cross_val_score(pipe, X, y, cv=5, scoring="f1") print(f"Full pipeline, 5-fold CV F1: {score.mean():.4f} ±{score.std():.4f}") print() prep.fit(X, y) print(f"In : {X.shape[1]} columns") print(f"Out: {prep.transform(X).shape[1]} columns (OneHot expanded the district)")
Full pipeline, 5-fold CV F1: 0.7273 ±0.0118 In : 11 columns Out: 15 columns (OneHot expanded the district)
remainder="drop" matters: columns not in the list get dropped. That's why elon_id, narx_matn and agent_komissiyasi fell out automatically: the three traps from Chapter 1 were blocked by the structure of the pipeline itself.
Memorise this pattern
ColumnTransformer([ ("num", Pipeline([impute, scale]), num_cols), ("cat", Pipeline([impute, onehot]), cat_cols), ], remainder="drop")
This pattern repeats through the whole book. When the model changes, only the last line changes.
The dataset passport
You've finished cleaning. Now write it down: this document will rescue you three months from now.
import hashlib def dataset_passport(frame, name, target): digest = hashlib.md5(pd.util.hash_pandas_object(frame, index=True).values).hexdigest()[:16] lines = [ f"Dataset : {name}", f"Rows : {len(frame):,}".replace(",", " "), f"Columns : {frame.shape[1]}", f"Target : {target}", f"Hash (md5) : {digest}", f"Missing : {int(frame.isna().sum().sum())}", f"Duplicates : {int(frame.duplicated().sum())}", ] if target in frame: n = frame[target].value_counts(normalize=True).sort_index() lines.append("Target ratio: " + ", ".join(f"{k}={v:.1%}" for k, v in n.items())) return "\n".join(lines) print(dataset_passport(df, "uy_ijara.csv", "ijaraga_berildi_30k"))
Dataset : uy_ijara.csv Rows : 1 200 Columns : 13 Target : ijaraga_berildi_30k Hash (md5) : 1b7dd7e5b3894c3f Missing : 8 Duplicates : 0 Target ratio: 0=49.3%, 1=50.7%
The hash is a letter to your future self
Three months later your model gives a different result. The question: did the data change, or the code?
You compare the hash. If it's the same, the problem is in the code. If it's different, the data changed.
Without it you can never answer that question. The MLOps book gives this its own chapter.
What we learned today
show(pd.DataFrame([ {"Problem": "A text column", "Fix": "Nominal -> OneHot, Ordinal -> .map()/OrdinalEncoder"}, {"Problem": "Numbering a nominal column", "Fix": "It invents an order; DON'T"}, {"Problem": "A new category shows up", "Fix": "handle_unknown='ignore'"}, {"Problem": "Missing values", "Fix": "Median/mode/own category, depending on what the gap means"}, {"Problem": "Different scales", "Fix": "StandardScaler; trees don't need it"}, {"Problem": "Almost identical rows", "Fix": "Normalise first, then drop_duplicates"}, {"Problem": "Tying it all together", "Fix": "ColumnTransformer + Pipeline, remainder='drop'"}, {"Problem": "Remembering in three months", "Fix": "Dataset passport + md5 hash"}, ]))
| Problem | Fix |
|---|---|
| A text column | Nominal -> OneHot, Ordinal -> .map()/OrdinalEncoder |
| Numbering a nominal column | It invents an order; DON'T |
| A new category shows up | handle_unknown='ignore' |
| Missing values | Median/mode/own category, depending on what the gap means |
| Different scales | StandardScaler; trees don't need it |
| Almost identical rows | Normalise first, then drop_duplicates |
| Tying it all together | ColumnTransformer + Pipeline, remainder='drop' |
| Remembering in three months | Dataset passport + md5 hash |
That completes Part 1. You can get the data (Chapter 3), look at it (Chapter 4) and prepare it (Chapter 5).
The next part, Part 2, is the most important: how to evaluate a model. It starts in Chapter 6.
Questions about this chapter?
The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.