Learn to get data ready for a model by running it. Ten copy-and-run examples covering cleaning, stratified splitting, scaling without leakage, encoding, engineered features, TF-IDF, feature selection, PCA, and a single pipeline.
The feature engineering article covers everything between a raw table and a model that can be trained on it: cleaning, splitting, scaling, encoding, deriving new columns, turning text into numbers, cutting features down, and assembling the lot into something reproducible. The order matters more than any individual technique, because several of these steps leak information from the test set into the training set if you do them in the wrong sequence, and a leak does not raise an error, it raises your score. Every example here runs against the same deliberately messy dataset and the examples are cumulative, so what you finish one with is what the next one starts from. The idea that ties it together arrives in the last example.
Paste this once to build the working dataset:
import numpy as npimport pandas as pdrng = np.random.default_rng(19)n = 600city = rng.choice(["Manchester", "Leeds", "Bristol", "Glasgow"], n, p=[.35, .3, .2, .15])plan = rng.choice(["basic", "standard", "premium"], n, p=[.5, .35, .15])tenure = rng.integers(1, 60, n)support = rng.poisson(1.4, n)monthly = np.round([rng.normal({"basic": 22, "standard": 41, "premium": 78}[p], 6) for p in plan], 2)notes = rng.choice([ "billing issue not resolved", "great service quick response", "cancelled after price rise", "happy with the upgrade", "slow support long wait", "no complaints at all",], n)logit = (-0.4 + 0.55 * support - 0.045 * tenure + 0.012 * monthly + (plan == "basic") * 0.7 + rng.normal(0, 0.6, n))churn = (1 / (1 + np.exp(-logit)) > 0.5).astype(int)customers = pd.DataFrame({ "customer_id": range(1, n + 1), "city": city, "plan": plan, "tenure_months": tenure, "support_tickets": support, "monthly_charge": monthly, "total_charge": np.round(monthly * tenure * rng.uniform(0.9, 1.1, n), 2), "signup_date": pd.to_datetime("2021-01-01") + pd.to_timedelta(rng.integers(0, 1500, n), unit="D"), "note": notes, "churned": churn,})# the problems a real extract arrives withcustomers.loc[rng.choice(n, 25, replace=False), "monthly_charge"] = np.nancustomers.loc[rng.choice(n, 9, replace=False), "city"] = np.nancustomers = pd.concat([customers, customers.iloc[:12]], ignore_index=True)customers["total_charge"] = customers["total_charge"].astype(str) + " GBP"
1. Cleaning first
Before anything else, three checks: duplicates, missing values and types that are not what they should be.
print("shape:", customers.shape)print("duplicates:", customers.duplicated(subset="customer_id").sum())customers = customers.drop_duplicates(subset="customer_id").reset_index(drop=True)print(customers.isna().sum()[customers.isna().sum() > 0])print("total_charge dtype before:", customers["total_charge"].dtype)customers["total_charge"] = pd.to_numeric( customers["total_charge"].str.replace(" GBP", "", regex=False), errors="coerce")print("total_charge dtype after :", customers["total_charge"].dtype)print("churn rate:", round(customers["churned"].mean(), 3))
shape: (612, 10)duplicates: 12after dedupe: (600, 10)city 9monthly_charge 25dtype: int64total_charge dtype before: objecttotal_charge dtype after : float64churn rate: 0.453
Twelve duplicate customers, two columns with gaps, and a numeric column that arrived as text because someone appended a currency. All three are ordinary and all three would break something downstream: duplicates give the model the same customer twice and inflate any validation score, missing values stop most estimators outright, and a string column silently gets one-hot encoded into hundreds of useless columns if you do not notice. Deduplicate on the business key rather than on whole rows, since two genuinely different customers can share every attribute. And note the churn rate, 45 percent, which is the number the next example has to preserve.
2. Split first, and stratify
The train/test split comes before every transformation, and for a classification target it should preserve the class balance.
from sklearn.model_selection import train_test_splitX = customers.drop(columns=["churned", "customer_id"])y = customers["churned"]X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25, random_state=7, stratify=y)Xu_tr, Xu_te, yu_tr, yu_te = train_test_split(X, y, test_size=0.25, random_state=7)
rows train 450 test 150stratified churn rate train 0.453 test 0.453unstratified train 0.444 test 0.480full dataset 0.453
stratify=y reproduced the 45.3 percent churn rate exactly in both halves, while the unstratified split drifted to 44.4 and 48.0. On a balanced target that difference is cosmetic; on a rare one it is the difference between a usable test set and a meaningless one, because a two percent positive class split at random can easily give you a test set with almost no positives to score against. Dropping customer_id matters too, since an identifier correlated with row order can leak the target in ways that look like brilliant model performance. From here on, every fit happens on X_tr only.
3. Scaling, and what leakage actually looks like
Scalers learn parameters from data, which is why fitting one on everything is a leak rather than a shortcut.
from sklearn.preprocessing import StandardScalernum_cols = ["tenure_months", "support_tickets", "monthly_charge", "total_charge"]tr_num = X_tr[num_cols].fillna(X_tr[num_cols].median())te_num = X_te[num_cols].fillna(X_tr[num_cols].median()) # train's median, on purposescaler = StandardScaler().fit(tr_num) # fit on traintr_scaled = scaler.transform(tr_num)te_scaled = scaler.transform(te_num) # transform test with train's parametersleaky = StandardScaler().fit(pd.concat([tr_num, te_num]))print("leaky scaler mean on tenure:", round(leaky.mean_[0], 3), "vs honest:", round(scaler.mean_[0], 3))
after scaling (train): mean [0. 0. -0. 0.] std [1. 1. 1. 1.]after scaling (test) : mean [0.091 0.072 0.045 0.004] std [1.027 0.979 0.998 1.006]leaky scaler mean on tenure: 29.107 vs honest: 28.722
The training set comes out with mean zero and standard deviation one by construction, and the test set comes out nearthose values without matching them, which is the correct result: the test set is being measured with the training set’s ruler and any difference is real information about how the two differ. The leaky scaler’s mean differs only in the second decimal place here, which is exactly why this mistake survives review, and on a small test set or a shifting distribution the same mistake is worth several points of imaginary accuracy. The same rule applies to the imputation above, where the test set is filled with the training median.
4. Encoding categories
A model needs numbers, and the encoder has to survive a category it has never seen.
from sklearn.preprocessing import OneHotEncoderenc = OneHotEncoder(handle_unknown="ignore", sparse_output=False)enc.fit(X_tr[["city"]].fillna("unknown"))print("categories learned:", enc.categories_[0].tolist())out = enc.transform(pd.DataFrame({"city": ["Leeds", "Cardiff", "unknown"]}))
categories learned: ['Bristol', 'Glasgow', 'Leeds', 'Manchester', 'unknown'] city_Bristol city_Glasgow city_Leeds city_Manchester city_unknownLeeds 0 0 1 0 0Cardiff 0 0 0 0 0unknown 0 0 0 0 1
Cardiff was not in the training data, and handle_unknown="ignore" turned it into a row of zeros instead of raising an error in production six months from now. That argument is the entire reason to prefer scikit-learn’s encoder over pd.get_dummies, which is more convenient in a notebook and has no memory: run it on the test set separately and you get a different number of columns in a different order, which is a class of bug that produces silently wrong predictions rather than a crash. Note also that missing values were turned into their own unknown category rather than dropped, which is often the right call because “we do not know their city” can itself be predictive.
5. Ordinal against nominal
Some categories have an order and encoding them as unordered throws that away.
from sklearn.preprocessing import OrdinalEncoderoe = OrdinalEncoder(categories=[["basic", "standard", "premium"]]).fit(X_tr[["plan"]])demo = pd.DataFrame({"plan": ["basic", "standard", "premium"]})demo["ordinal"] = oe.transform(demo[["plan"]]).astype(int)
plan ordinal
basic 0
standard 1
premium 2
one-hot would give three unordered columns instead:
plan_basic plan_premium plan_standard
1 0 0
0 0 1
0 1 0
Passing categories explicitly is the important part, because the default sorts alphabetically and would have encoded basic as 0, premium as 1 and standard as 2, which asserts that standard is further from basic than premium is. The decision is about the variable, not the algorithm: use ordinal encoding when the order is real and the spacing is roughly meaningful, such as size bands or satisfaction ratings, and one-hot when the categories are simply different, such as city. Getting this wrong in either direction costs you, since one-hot on an ordered variable discards information a tree could have used, and ordinal on an unordered one invents a ranking a linear model will believe.
6. Engineering features
The columns that predict best are often ones you compute rather than ones you were given.
feat = X_tr.copy()feat["charge_per_month_of_tenure"] = (feat["total_charge"] / feat["tenure_months"]).round(2)feat["tickets_per_year"] = (feat["support_tickets"] / (feat["tenure_months"] / 12)).round(2)feat["signup_year"] = feat["signup_date"].dt.yearfeat["signup_quarter"] = feat["signup_date"].dt.quarterfeat["tenure_band"] = pd.cut(feat["tenure_months"], bins=[0, 12, 24, 48, 60], labels=["<1y", "1-2y", "2-4y", "4y+"])
tenure_months total_charge charge_per_month_of_tenure tickets_per_year signup_year tenure_band
42 1769.21 42.12 0.86 2021 2-4y
2 56.72 28.36 6.00 2021 <1y
16 1338.41 83.65 0.00 2024 1-2y
tenure_band
<1y 105
1-2y 82
2-4y 187
4y+ 76
Three kinds of derived feature, each fixing a different blind spot. Ratios normalise for size, so tickets_per_year says something support_tickets cannot, because six tickets from a two-month customer and six from a five-year customer are opposite signals and the raw count treats them as identical. Date parts turn a timestamp, which a model cannot use, into seasonality and cohort features it can. And binning turns a continuous variable into groups, which is worth doing when the relationship is not monotonic or when the bands match how the business already thinks. Two cautions: a ratio with a small denominator explodes, and any feature computed from the target is leakage.
7. Text as numbers with TF-IDF
Free text becomes a numeric matrix by counting words and down-weighting the common ones.
from sklearn.feature_extraction.text import TfidfVectorizertfidf = TfidfVectorizer(max_features=12, min_df=5, stop_words="english")tfidf_tr = tfidf.fit_transform(X_tr["note"])print("vocabulary:", tfidf.get_feature_names_out().tolist())print("matrix shape:", tfidf_tr.shape)
vocabulary: ['billing', 'cancelled', 'happy', 'issue', 'long', 'price', 'resolved', 'rise', 'slow', 'support', 'upgrade', 'wait']matrix shape: (450, 12) | sparsity: 0.826a note: cancelled after price risecancelled 0.577price 0.577rise 0.577
Each note became a row of twelve numbers, one per vocabulary word, mostly zeros. TF-IDF is two ideas multiplied: term frequency, how often the word appears in this document, and inverse document frequency, which shrinks words that appear everywhere, so a word in every note carries almost no weight while a word in a handful carries a lot. The three parameters are the ones you will always set. max_features caps the vocabulary and keeps the matrix manageable, min_dfdrops words appearing in fewer than n documents, which removes typos and one-offs, and stop_words removes the grammatical filler. As always, fit on the training set only, or the vocabulary itself is leaked.
8. Cutting features down
More features are not better, and three cheap methods disagree about which ones to keep.
from sklearn.feature_selection import VarianceThreshold, SelectKBest, f_classifvt = VarianceThreshold(threshold=0.05).fit(num_tr)sel = SelectKBest(f_classif, k=3).fit(num_tr, y_tr)scores = pd.Series(sel.scores_, index=num_tr.columns).sort_values(ascending=False)corr = num_tr.corr().abs()
ANOVA F scores:tenure_months 177.56total_charge 84.11support_tickets 83.73tickets_per_year 37.25monthly_charge 1.01charge_per_month_of_tenure 0.85kept: ['tenure_months', 'support_tickets', 'total_charge']most correlated pairs: monthly_charge / charge_per_month_of_tenure: 0.883 tenure_months / total_charge: 0.674
Three different questions. A variance threshold removes columns that barely change, which is a data-quality filter rather than a modelling one and catches constants and near-constants. SelectKBest with f_classif ranks each feature by how strongly it separates the classes on its own, which is fast and univariate, so it cannot see a feature that only matters in combination with another. And the correlation check finds redundancy rather than uselessness, with monthly_charge and my engineered charge_per_month_of_tenure at 0.88, which means keeping both mostly adds noise and instability to a linear model’s coefficients. Note that tenure_months scores highest by a wide margin, which matches how the data was generated.
9. PCA
When features are correlated, principal components repack the same information into fewer, uncorrelated columns.
from sklearn.decomposition import PCApca_input = StandardScaler().fit_transform(num_tr)pca = PCA().fit(pca_input)print("explained variance ratio:", np.round(pca.explained_variance_ratio_, 3))print("cumulative :", np.round(np.cumsum(pca.explained_variance_ratio_), 3))
explained variance ratio: [0.433 0.247 0.197 0.09 0.022 0.011]cumulative : [0.433 0.68 0.877 0.967 0.989 1. ]components needed for 95%: 4 of 6shape after PCA: (450, 4)
Six correlated columns compressed into four that carry 96.7 percent of the variance. The scaling on the line above is not optional, because PCA maximises variance and an unscaled total_charge measured in thousands would dominate support_tickets measured in single digits regardless of which is informative. Read the cumulative row to choose the number of components rather than picking one in advance. The cost is interpretability, since a component is a weighted blend of your original columns and nobody can explain “component two” to a stakeholder, which is why PCA belongs in pipelines where prediction is the goal and not in ones where the coefficients are the deliverable.
10. One pipeline, and the model
Everything above belongs in a single object that applies the same transformations, in the same order, fitted on the same rows.
from sklearn.compose import ColumnTransformerfrom sklearn.impute import SimpleImputerfrom sklearn.linear_model import LogisticRegressionfrom sklearn.pipeline import Pipelinefrom sklearn.metrics import roc_auc_scorepre = ColumnTransformer([ ("num", Pipeline([("impute", SimpleImputer(strategy="median")), ("scale", StandardScaler())]), ["tenure_months", "support_tickets", "monthly_charge", "total_charge"]), ("cat", Pipeline([("impute", SimpleImputer(strategy="most_frequent")), ("onehot", OneHotEncoder(handle_unknown="ignore"))]), ["city", "plan"]), ("txt", TfidfVectorizer(max_features=12, stop_words="english"), "note"),])model = Pipeline([("prep", pre), ("clf", LogisticRegression(max_iter=1000))])model.fit(X_tr, y_tr)print("features after preprocessing:", model.named_steps["prep"].transform(X_tr).shape[1])print("train AUC:", round(roc_auc_score(y_tr, model.predict_proba(X_tr)[:, 1]), 3))print("test AUC:", round(roc_auc_score(y_te, model.predict_proba(X_te)[:, 1]), 3))
features after preprocessing: 23train AUC: 0.924test AUC: 0.934['num__tenure_months', 'num__support_tickets', 'num__monthly_charge', 'num__total_charge', 'cat__city_Bristol', 'cat__city_Glasgow', ...]
Nine raw columns became 23 features and one fit call did all of it. The ColumnTransformer is what makes this possible, routing each group of columns to its own sub-pipeline, and the outer Pipeline then hands the result to the model. The reason this is worth the extra syntax is not tidiness: a pipeline makes leakage structurally difficult, because fit only ever sees the training rows and predict re-applies exactly the fitted transformations, so you cannot accidentally scale the test set with its own parameters. It is also the only sane way to cross-validate, since the whole pipeline is refitted inside every fold.
The model that ties the ten together is that every preprocessing step is a function with parameters, and those parameters are learned from data, which is why the order is not a matter of taste. A scaler learns a mean and a standard deviation. An imputer learns a median. An encoder learns a category list. A vectoriser learns a vocabulary. A PCA learns a rotation. Each of those is a small model fitted to whatever you show it, and the entire discipline of preprocessing reduces to one rule: fit on the training data, transform everything. That single sentence explains why the split comes first in Example 2, why Example 3’s leaky scaler is wrong, why the test set is imputed with the training median, why the encoder must handle unknown categories, and why Example 10’s pipeline is the safest way to express all of it. The steps that are not fitted, dropping duplicates, parsing a string to a number, computing a ratio from two columns in the same row, are the ones you can safely do before the split.
Work through these and you have the article in practice: duplicates, gaps and a numeric column stored as text; a stratified split that holds the class balance to three decimal places; a scaler fitted on train and the leaky version that differs just enough to hide; one-hot encoding with unknown categories handled; ordinal encoding with an explicitly stated order; ratios, date parts and bands; TF-IDF with the three parameters worth setting; variance, univariate scores and correlation as three different reasons to drop a column; PCA on standardised inputs; and a ColumnTransformer pipeline that does all of it in one fit. The habit that follows is a single question asked before every transformation: does this step learn anything from the data, and if it does, has it only been allowed to learn from the training set.
Thanks for reading, Andrei.
[…] Feature Engineering and Preprocessing: 10 Code-Along Examples […]