Skip to content

Feature Scaling (Normalization & Standardization)

What you’ll learn

  • the three scaler formulas, each computed by hand on the same five numbers
  • which models are destroyed by unscaled features, which are indifferent, and why — measured
  • what a single outlier does to each scaler, quantified
  • why fitfit belongs to the training set and transformtransform to everything
  • when to log-transform instead of scale
  • that scaling never changes the shape of a distribution

Intuition

The housing data has median_incomemedian_income spanning 0.5 to 15 and populationpopulation spanning 3 to 35,682. Nothing is wrong with either column. The problem is that some algorithms treat “a difference of 1” as meaning the same thing in both.

Two model families care intensely:

  • Distance-based — KNN, SVM with RBF, k-means. Euclidean distance sums squared differences, so a feature with a range in the thousands drowns one with a range of 10.
  • Gradient-based — linear and logistic regression via SGD, neural networks. Unequal feature scales stretch the cost surface into a ravine, and descent zig-zags instead of descending.

Two families do not:

  • Tree-based — splits depend on the ordering of values, and scaling is monotone.
  • Closed-form solvers — the Normal Equation solves exactly regardless of conditioning, within numerical limits.
diagram Diagram mermaid

The math

xminmax=xmin(x)max(x)min(x)xstd=xμσxrobust=xmedian(x)Q3Q1x_{\text{minmax}} = \frac{x - \min(x)}{\max(x) - \min(x)} \qquad x_{\text{std}} = \frac{x - \mu}{\sigma} \qquad x_{\text{robust}} = \frac{x - \text{median}(x)}{Q_3 - Q_1}
ScalerCentreSpreadOutput rangeOutlier behaviour
MinMaxScalerMinMaxScalerminrangeexactly [0,1][0, 1]Terrible — one point sets the range
StandardScalerStandardScalermeanstdunbounded, mean 0, sd 1Poor — both statistics move
RobustScalerRobustScalermedianIQRunboundedGood — both statistics resist

StandardScalerStandardScaler uses the population standard deviation (÷n\div n, not ÷(n1)\div (n-1)), which is why hand calculations must use σ\sigma rather than pandas’ default ss.

Worked example by hand

Five values: [1,2,3,4,5][1, 2, 3, 4, 5].

Step 1 — the statistics.

min=1,max=5,μ=3,σ=4+1+0+1+45=21.4142,Q1=2,  median=3,  Q3=4\min = 1,\quad \max = 5,\quad \mu = 3,\quad \sigma = \sqrt{\tfrac{4+1+0+1+4}{5}} = \sqrt{2} \approx 1.4142,\quad Q_1 = 2,\; \text{median} = 3,\; Q_3 = 4

Step 2 — apply each formula.

xxmin-max x14\frac{x-1}{4}standard x31.4142\frac{x-3}{1.4142}robust x32\frac{x-3}{2}
10.00−1.4142−1.0
20.25−0.7071−0.5
30.500.00000.0
40.750.70710.5
51.001.41421.0

Step 3 — check the properties. Min-max lands exactly on [0,1][0, 1]. Standardised values have mean 0 and population standard deviation 1. Robust values are centred on the median with the interquartile range as the unit.

Step 4 — note what did not change. All three columns are perfectly evenly spaced, exactly as the original was. Scaling is an affine map: it slides and stretches the axis and can do nothing else.

figureThe same column, four treatmentsmatplotlib
Four histograms of median income: raw spanning 0.5 to 15, min-max spanning 0 to 1, standardised spanning -1.77 to 5.86, and robust spanning -1.39 to 5.26. All four have identical shape.Four histograms of median income: raw spanning 0.5 to 15, min-max spanning 0 to 1, standardised spanning -1.77 to 5.86, and robust spanning -1.39 to 5.26. All four have identical shape.
The right-skew survives every scaler unchanged. If you wanted to fix the skew you needed a log, not a scaler.

What one outlier does

figureValues 1 to 10, plus one reading of 1000matplotlib
Three rows of points showing the values 1 to 10 after min-max, standard and robust scaling of a dataset that also contains one value of 1000. Under min-max and standard scaling the ten normal points are compressed almost to a single spot.Three rows of points showing the values 1 to 10 after min-max, standard and robust scaling of a dataset that also contains one value of 1000. Under min-max and standard scaling the ten normal points are compressed almost to a single spot.
Min-max crushes the ten real values into a span of 0.009 — they are now numerically indistinguishable. RobustScaler keeps them spread over 1.8 and lets the outlier fly off to 198.8.

Reading the plot

ScalerSpan of the ten normal valuesWhere the outlier lands
MinMaxScalerMinMaxScaler0.00901.000
StandardScalerStandardScaler0.03153.162
RobustScalerRobustScaler1.8000198.800
  1. Min-max is catastrophic. The single value of 1000 defines the range, so the ten real observations occupy 0.9% of the output interval. Any distance computed between them is now effectively zero.
  2. Standardisation is bad but less so. The outlier inflates σ\sigma, compressing everything else into a span of 0.03.
  3. RobustScaler keeps the real data readable. Median and IQR ignore the extreme value entirely, so the ten normal points keep a usable spread — and the outlier is left at 198.8, visibly flagged rather than silently absorbed.

RobustScaler is the right default whenever outliers are present and cannot be removed.

See it move

Two features, wildly different ranges. Watch the cloud transform as the scalers are applied — and watch the shape stay exactly where it was.

sketch Scaling moves the axes, not the data p5.js
A cloud of points with one feature spanning thousands and the other spanning tens, cycling through raw, min-max, standardised and robust scaling. The relative arrangement of the points never changes.

Which models actually care

figureFive models, with and without scalingmatplotlib
Grouped bar chart on a log scale comparing cross-validated RMSE with raw and standardised features for linear regression, SGD, KNN, SVR and random forest. SGD's raw bar is astronomically tall.Grouped bar chart on a log scale comparing cross-validated RMSE with raw and standardised features for linear regression, SGD, KNN, SVR and random forest. SGD's raw bar is astronomically tall.
SGD on raw features diverges to an RMSE of about 3.8 quadrillion. KNN improves by 31%. Linear regression, SVR and the random forest are essentially unchanged.
who_cares.py
import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestRegressor
from sklearn.linear_model import LinearRegression, SGDRegressor
from sklearn.model_selection import cross_val_score
from sklearn.neighbors import KNeighborsRegressor
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.svm import SVR
 
housing = pd.read_csv(URL).dropna()
X = housing.select_dtypes("number").drop(columns=["median_house_value"]).values
y = housing["median_house_value"].values
sample = np.random.default_rng(0).choice(len(y), 3000, replace=False)
X, y = X[sample], y[sample]
 
models = {
    "linear": LinearRegression(),
    "sgd": SGDRegressor(max_iter=2000, tol=1e-4, random_state=0),
    "knn": KNeighborsRegressor(10),
    "svr": SVR(),
    "forest": RandomForestRegressor(n_estimators=60, random_state=0, n_jobs=-1),
}
 
for name, model in models.items():
    raw = -cross_val_score(model, X, y, cv=3,
                           scoring="neg_root_mean_squared_error").mean()
    scaled = -cross_val_score(make_pipeline(StandardScaler(), model), X, y, cv=3,
                              scoring="neg_root_mean_squared_error").mean()
    print(f"{name:<7} raw {raw:>22,.0f}   scaled {scaled:>9,.0f}   "
          f"{100 * (scaled - raw) / raw:+7.1f}%")
 
# linear  raw                 69,721   scaled    69,721      +0.0%
# sgd     raw  3,795,293,450,055,030   scaled    70,035    -100.0%
# knn     raw                100,607   scaled    69,128     -31.3%
# svr     raw                117,412   scaled   117,374      -0.0%
# forest  raw                 58,226   scaled    58,263      +0.1%
who_cares.py
import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestRegressor
from sklearn.linear_model import LinearRegression, SGDRegressor
from sklearn.model_selection import cross_val_score
from sklearn.neighbors import KNeighborsRegressor
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.svm import SVR
 
housing = pd.read_csv(URL).dropna()
X = housing.select_dtypes("number").drop(columns=["median_house_value"]).values
y = housing["median_house_value"].values
sample = np.random.default_rng(0).choice(len(y), 3000, replace=False)
X, y = X[sample], y[sample]
 
models = {
    "linear": LinearRegression(),
    "sgd": SGDRegressor(max_iter=2000, tol=1e-4, random_state=0),
    "knn": KNeighborsRegressor(10),
    "svr": SVR(),
    "forest": RandomForestRegressor(n_estimators=60, random_state=0, n_jobs=-1),
}
 
for name, model in models.items():
    raw = -cross_val_score(model, X, y, cv=3,
                           scoring="neg_root_mean_squared_error").mean()
    scaled = -cross_val_score(make_pipeline(StandardScaler(), model), X, y, cv=3,
                              scoring="neg_root_mean_squared_error").mean()
    print(f"{name:<7} raw {raw:>22,.0f}   scaled {scaled:>9,.0f}   "
          f"{100 * (scaled - raw) / raw:+7.1f}%")
 
# linear  raw                 69,721   scaled    69,721      +0.0%
# sgd     raw  3,795,293,450,055,030   scaled    70,035    -100.0%
# knn     raw                100,607   scaled    69,128     -31.3%
# svr     raw                117,412   scaled   117,374      -0.0%
# forest  raw                 58,226   scaled    58,263      +0.1%

Reading the table

  • SGD is the headline. Raw features produce an RMSE of 3.8×10153.8 \times 10^{15} — the optimiser diverged. Scaling turns a broken model into a working one, matching the closed-form solution to within 0.5%. Not an improvement; a repair.
  • KNN gains 31%. Distance was being dominated by populationpopulation, which spans 35,000, while median_incomemedian_income spanning 15 contributed almost nothing.
  • Linear regression is byte-identical. LinearRegressionLinearRegression uses lstsqlstsq, and least squares is invariant to affine feature transformations.
  • The forest is unchanged to within noise, exactly as the theory says.
  • SVR barely moves here only because this sample happens to be dominated by one large-range feature either way; on the full dataset with an RBF kernel, scaling matters a great deal.

Fit on train, transform everything

fit_on_train.py
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
 
X_train, X_test = train_test_split(X, test_size=0.2, random_state=42)
 
scaler = StandardScaler()
scaler.fit(X_train)                    # learn mu and sigma from training data only
 
X_train_scaled = scaler.transform(X_train)
X_test_scaled = scaler.transform(X_test)     # NOT fit_transform
 
print(X_train_scaled.mean(axis=0).round(6))  # ~0 by construction
print(X_test_scaled.mean(axis=0).round(4))   # near 0, but not exactly — correct
fit_on_train.py
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
 
X_train, X_test = train_test_split(X, test_size=0.2, random_state=42)
 
scaler = StandardScaler()
scaler.fit(X_train)                    # learn mu and sigma from training data only
 
X_train_scaled = scaler.transform(X_train)
X_test_scaled = scaler.transform(X_test)     # NOT fit_transform
 
print(X_train_scaled.mean(axis=0).round(6))  # ~0 by construction
print(X_test_scaled.mean(axis=0).round(4))   # near 0, but not exactly — correct

The test set’s scaled mean is not exactly zero, and that is the point. It was scaled by parameters learned elsewhere, exactly as a live request will be. If your test data scales to a perfect mean of zero, you called fit_transformfit_transform on it and leaked.

Scaling is not transforming

Scaling cannot fix skew, because it is affine. Six of the nine housing columns are heavily right-skewed, and no scaler touches that. A logarithm does:

log_transform.py
import numpy as np
from scipy import stats
 
col = housing["population"]
print("raw skew:", round(float(stats.skew(col)), 3))            # 4.935
print("scaled skew:", round(float(stats.skew(
    (col - col.mean()) / col.std())), 3))                       # 4.935 — unchanged
print("log skew:", round(float(stats.skew(np.log1p(col))), 3))  # -1.044
log_transform.py
import numpy as np
from scipy import stats
 
col = housing["population"]
print("raw skew:", round(float(stats.skew(col)), 3))            # 4.935
print("scaled skew:", round(float(stats.skew(
    (col - col.mean()) / col.std())), 3))                       # 4.935 — unchanged
print("log skew:", round(float(stats.skew(np.log1p(col))), 3))  # -1.044

Standardising leaves the skew at 4.94. log1plog1p brings it to −1.04 — overshooting into a mild left skew, which is a reminder that a log is a fixed transform, not a tuned one. Different tools for different problems: scalers equalise ranges, transforms reshape distributions.

SituationReach for
Features on different scales, no outliersStandardScalerStandardScaler
Outliers presentRobustScalerRobustScaler
Bounded input required (image pixels, some neural nets)MinMaxScalerMinMaxScaler
Heavy right skewnp.log1pnp.log1p, then a scaler
Skew of unknown formPowerTransformer(method="yeo-johnson")PowerTransformer(method="yeo-johnson")
Only the rank mattersQuantileTransformerQuantileTransformer

Pitfalls

quizCheck yourself
  1. Why does scaling not affect a decision tree?

    Show answer

    B — A split tests whether a value is above or below a threshold, and any monotone rescaling preserves that ordering — Scaling is monotone, so it maps every threshold to a corresponding threshold. The partition of the data — and therefore the tree — is identical.

  2. MinMaxScaler is applied to values 1 to 10 plus one reading of 1000. What happens to the ten normal values?

    Show answer

    B — They are compressed into a span of about 0.009, because the outlier defines the entire range — Min-max divides by max minus min, which the outlier sets to 999. The real data occupies 0.9% of the output interval and becomes numerically indistinguishable.

  3. Why must you call transform, not fit_transform, on the test set?

    Show answer

    B — Fitting on test data uses statistics that will not exist at prediction time, so the reported score cannot be reproduced in production — A single live request has no mean or standard deviation of its own. The scaler must replay the parameters it learned during training, and the test set has to be treated the same way.

  4. Your feature has a skew of 4.94. You standardise it. What is the new skew?

    Show answer

    C — 4.94 — unchanged — Standardisation is an affine map: subtract a constant, divide by a constant. Skewness is invariant to both. Use a log or a power transform to change the shape.

🧪 Try It Yourself

Exercise 1 – Min-max scale by hand

Exercise 2 – Standardise, and match scikit-learn

Exercise 3 – Watch one outlier crush the rest

Exercise 4 – Fit on train, transform both

Exercise 5 – Scaling cannot fix skew

Recap

  • Min-max maps to [0,1][0,1], standardisation to mean 0 and sd 1, robust to median 0 and IQR 1.
  • Worked by hand on [1,2,3,4,5][1,2,3,4,5]: all three produce evenly spaced output, because scaling is affine.
  • Distance-based and gradient-based models need scaling; trees and closed-form solvers do not. SGD on raw housing features reaches an RMSE of 3.8×10153.8\times10^{15}; scaled, it matches the exact solution.
  • One outlier crushes the ten real values into a span of 0.009 under min-max and 0.032 under standardisation, against 1.8 under RobustScalerRobustScaler.
  • Fit on train, transform on everything. A test set that scales to exactly zero mean is evidence of a leak.
  • Scaling cannot change skewness — 4.935 before and after. Use log1plog1p or PowerTransformerPowerTransformer for that.

Exercise 6 – Which scaler survives one outlier?

Next

Continue to Transformation Pipelines & Custom Transformers — assemble imputing, encoding and scaling into one object that cannot leak.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did