Skip to content

Preparing the data: encoding, missing values, scaling

Chapter 5 20 code blocks 3700 words

The code in this chapter uses these filesrun it on your own machine

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:

python1 lines
model.fit(df[["tuman", "maydon_m2"]], y)

And you get an error:

yaml1 lines
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

python18 lines
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}")
Output
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.

python6 lines
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"},
]))
KindExampleIs there an orderMethod
Ordinal (ordered)small < medium < large; 1st year < 2nd yearYESOrdinalEncoder or .map()
Nominal (unordered)district, colour, countryNOOneHotEncoder

Why does the difference matter? Let me show you.

python15 lines
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.")
Output
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.
python9 lines
# 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)
Output
OneHotEncoder: 1 column -> 8 columns
originalBektemirChilonzorMirzo Ulug'bekOlmazorSergeliShayxontohurYakkasaroyYunusobod
Chilonzor01000000
Yakkasaroy00000010
Mirzo Ulug'bek00100000
Mirzo Ulug'bek00100000
Chilonzor01000000
Olmazor00010000

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.

python8 lines
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])
Output
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:

python10 lines
# 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.")
Output
Here the order MEANS something: new > mid > old. So .map() is correct.
qurilgan_yilconditioncondition_code
2 016new2
1 977old0
2 010mid1
2 007mid1
1 989old0
1 987old0

Missing values

python16 lines
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"},
]))
Output
qurilgan_yil missing: 8 (0.67%)
MethodWhenCost
Drop the row< 1% missing and plenty of dataLoses data; if the gaps aren't random, it introduces bias
Fill with the medianNumeric column, 1-30% missingReduces the spread; robust to outliers
Fill with the meanNumeric column, symmetric distributionOutliers drag the mean
The most frequent valueText/category columnMakes the big category even bigger
'MISSING' as its own categoryThe gap ITSELF means somethingNone; often the best choice
Fill with a model (KNN)Many gaps and related columnsSlow; 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".

python9 lines
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)")
Output
  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

python2 lines
scale = X.select_dtypes(include=[np.number]).agg(["min", "max", "mean", "std"]).T.round(2)
show(scale.reset_index().rename(columns={"index": "column"}))
columnminmaxmeanstd
xonalar152.511.07
maydon_m22211457.1920.14
qavat1165.13.61
qavatlar_soni2169.034.31
metro_km0.18.052.581.6
mebel010.610.49
qurilgan_yil1 9752 0241 998.9514.31
agent_komissiyasi0610167.4178.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.

python9 lines
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}")
Output
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
python8 lines
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"},
]))
ScalerWhat it doesWhen
StandardScalerMean 0, std 1The default choice; when the distribution is close to normal
MinMaxScalerSqueezes into 0 to 1Neural networks, when exact bounds are needed
RobustScalerUses the median and IQRWhen there are MANY outliers

Now the most important question: which models need scaling?

python10 lines
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"},
]))
ModelScalingWhy
KNNREQUIREDIt computes distances
SVMREQUIREDDistances and the margin
Logistic / Linear RegressionRecommendedGradients converge faster; coefficients become comparable
Ridge / LassoREQUIREDThe penalty depends on coefficient size
Neural networkREQUIREDExploding/vanishing gradients
Decision TreeNOT NEEDEDIt only asks 'bigger or smaller?'
Random ForestNOT NEEDEDBuilt from trees
XGBoost / LightGBMNOT NEEDEDBuilt from trees

Let's confirm that by measuring it:

python14 lines
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}")
Output
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

python12 lines
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()))
Output
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.

python24 lines
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)")
Output
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

python4 lines
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.

python19 lines
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"))
Output
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

python10 lines
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"},
]))
ProblemFix
A text columnNominal -> OneHot, Ordinal -> .map()/OrdinalEncoder
Numbering a nominal columnIt invents an order; DON'T
A new category shows uphandle_unknown='ignore'
Missing valuesMedian/mode/own category, depending on what the gap means
Different scalesStandardScaler; trees don't need it
Almost identical rowsNormalise first, then drop_duplicates
Tying it all togetherColumnTransformer + Pipeline, remainder='drop'
Remembering in three monthsDataset 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.

Write on Telegram Contents