PYTHON / MACHINE LEARNING WITH PYTHON
Feature scaling and preprocessing
Fit scalers on the training split only, transform everything else with them, and pick StandardScaler, MinMaxScaler or RobustScaler on purpose.
What you will learn
- Fit a scaler on the training split only, then transform train, test and new rows
- Wrap scaler and estimator in a Pipeline so no fold ever sees test statistics
- Choose StandardScaler, MinMaxScaler or RobustScaler from a column's spread and outliers
- Skip scaling for trees; require it for kNN, SVM, PCA and penalised linear models
Understanding Feature scaling and preprocessing
An estimator sees the feature matrix as plain numbers with no memory of units. If one column is income in dollars (tens of thousands) and another is age in years (tens), then any model that measures distance between rows, takes a dot product with a weight vector, or penalises the size of coefficients will treat income as about a thousand times more important, purely because of the unit someone happened to record it in. Scaling removes that accident: after standardising, one unit means one standard deviation in every column, so the model has to earn its weighting from the data instead of inheriting it from the measuring stick.
A scaler is itself a fitted model. StandardScaler.fit computes a mean and a standard deviation per column and stores them in .mean_ and .scale_; transform then applies (x - mean) / scale using those frozen numbers. That is why you fit on the training split and only call transform on validation, test and production rows: they have to be pushed through the same arithmetic, otherwise your test features live in a different coordinate system than the one the model learnt in. It also means scaled test values are not guaranteed to be centred or bounded, so a test row far from the training mean coming out at 2.68 is the correct answer, not a bug.
Which scaler you use depends on the column, not on habit. StandardScaler suits roughly symmetric data; MinMaxScaler maps the training range onto [0, 1] and is extremely sensitive to a single extreme value, which can squash every ordinary row into a tiny sliver; RobustScaler centres on the median and divides by the interquartile range, so extremes barely move it. Decision trees and forests are indifferent, because a split is just x <= t and any monotone rescaling moves the threshold along with the data. None of these transformers change the shape of a distribution either: standardising a skewed feature gives you a skewed feature with mean 0.
import numpy as np
from sklearn.preprocessing import StandardScaler
# column 0 = age in years, column 1 = income in dollars
X_train = np.array([[20., 30000.],
[30., 50000.],
[40., 70000.],
[50., 90000.]])
X_test = np.array([[35., 60000.],
[60., 120000.]])
scaler = StandardScaler().fit(X_train) # statistics come from train only
print(f"train mean: age={scaler.mean_[0]:.1f} income={scaler.mean_[1]:.1f}")
print(f"train scale: age={scaler.scale_[0]:.3f} income={scaler.scale_[1]:.3f}")
Z_train = scaler.transform(X_train)
Z_test = scaler.transform(X_test) # reuses the same two numbers per column
print("scaled train:", np.round(Z_train, 3).tolist())
print("scaled test: ", np.round(Z_test, 3).tolist())
print("train centred:", np.allclose(Z_train.mean(axis=0), 0))
print("train unit std:", np.allclose(Z_train.std(axis=0), 1))A scaler is fitted like any other model: it learns statistics from the training set and applies exactly those numbers to every row it sees afterwards.
Worked examples
Scaling flips a k-NN prediction
Shows that an unscaled feature measured in thousands hijacks the Euclidean distance that k-NN relies on.
import numpy as np
from sklearn.neighbors import KNeighborsClassifier
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
# column 0 carries the signal (near 0 -> class 0, near 1 -> class 1)
# column 1 is noise recorded in a huge unit
X = np.array([[0.0, 1000.],
[0.1, 9000.],
[0.9, 1050.],
[1.0, 8950.]])
y = np.array([0, 0, 1, 1])
new = np.array([[0.2, 5000.]])
raw = KNeighborsClassifier(n_neighbors=1).fit(X, y)
scaled = make_pipeline(StandardScaler(),
KNeighborsClassifier(n_neighbors=1)).fit(X, y)
print("raw features ->", raw.predict(new))
print("scaled features ->", scaled.predict(new))Example explained
Line 1Unscaled, the distance from new to every training row is about 3950, all of it coming from column 1; column 1 contributes at most 0.8 and cannot influence the winner.
Line 2The nearest raw neighbour is [0.9, 1050] only because 5000 is 50 units from 1050 and 4000 units from 1000, so the noise column decides the label.
Line 3make_pipeline standardises inside fit and again inside predict, so both columns end up with std 1 and the 0.3 gap in column 0 becomes 0.66 standard deviations.
Line 4With comparable spreads the nearest neighbour becomes [0.1, 9000], class 0, which matches the signal column.
One outlier ruins MinMaxScaler
Compares MinMaxScaler and RobustScaler on a column with a single extreme value, and shows a test value landing outside [0, 1].
import numpy as np
from sklearn.preprocessing import MinMaxScaler, RobustScaler
X_train = np.array([[10.], [20.], [30.], [40.], [1000.]]) # last row is an outlier
X_test = np.array([[5.], [25.], [2000.]])
mm = MinMaxScaler().fit(X_train)
rb = RobustScaler().fit(X_train)
print("min-max train:", mm.transform(X_train).ravel().round(3).tolist())
print("min-max test: ", mm.transform(X_test).ravel().round(3).tolist())
print("robust train: ", rb.transform(X_train).ravel().round(3).tolist())
print("median, IQR: ", rb.center_[0], rb.scale_[0])Example explained
Line 1MinMaxScaler stores data_min_=10 and a range of 990, so the four ordinary rows are compressed into 0.0-0.03 and become nearly indistinguishable to the model.
Line 2Test values 5 and 2000 map to -0.005 and 2.01: transform reuses the training range and never re-fits, so leaving [0, 1] is expected behaviour.
Line 3RobustScaler subtracts the median (30) and divides by the interquartile range (20), which keeps the ordinary rows one half-unit apart.
Line 4The outlier still shows up as 48.5 under RobustScaler, but it no longer dictates the scale of everything else.
Scaling numbers, encoding strings
Uses ColumnTransformer to standardise a numeric column while one-hot encoding a text column in the same step.
import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
df = pd.DataFrame({"age": [20., 40., 60.],
"city": ["oslo", "rome", "oslo"]})
pre = ColumnTransformer([
("num", StandardScaler(), ["age"]),
("cat", OneHotEncoder(), ["city"]),
])
out = pre.fit_transform(df)
print(np.round(out, 3).tolist())
print(pre.get_feature_names_out().tolist())Example explained
Line 1ColumnTransformer sends ["age"] to StandardScaler and ["city"] to OneHotEncoder, then stacks the blocks left to right in the order listed.
Line 2A StandardScaler would crash on the string column, which is exactly why per-column routing exists instead of one scaler for the whole frame.
Line 3The dummy columns stay 0/1 and are deliberately not standardised: they are already on the same scale as each other.
Line 4get_feature_names_out prefixes each output column with its transformer name, so you can still map a model coefficient back to a source column.
Important notes
StandardScaler divides by the population standard deviation (ddof=0), so scaler.scale_ will not exactly match np.std(col, ddof=1) on small samples.
MinMaxScaler(clip=True) forces transformed values into [0, 1]; the default clip=False lets them escape, which is usually preferable because out-of-range values are a useful signal that new data has drifted.
Common mistakes
Calling fit_transform on the test set: it gets its own mean and std, so train and test end up in different coordinate systems and the reported score describes nothing real.
Scaling the full X before train_test_split: the training statistics already contain the test set's mean, min and max, so cross-validation scores come out optimistically high.
Scaling the target y and then reporting RMSE without inverse_transform, which quotes the error in standard deviations and makes the model look far better than it is.
Try it yourself
Change, predict, then run
Make a 8x2 array where column 1 is in the thousands, fit a StandardScaler on the first 6 rows, and print scale_ plus the transformed last 2 rows. Then refit with MinMaxScaler and report which of the last 2 rows falls outside [0, 1].
Open the Python workspaceCheck your understanding
You fit a MinMaxScaler on the training set. A test row contains a value larger than any value seen during fitting. What does transform do with it?
- Raises a ValueError because the value is outside the fitted range
- Maps it to a number greater than 1.0, which is the expected result
- Updates the stored minimum and maximum so the new value fits inside [0, 1]
- Silently clips it to exactly 1.0
Show answer
transform only applies the stored data_min_ and range as fixed arithmetic, so a larger input simply produces an output above 1.0. Clipping to 1.0 happens only if you explicitly pass clip=True, and updating the stored statistics would mean re-fitting on test data, which is exactly the leakage the fit/transform split is designed to prevent.