Skip to main content

Command Palette

Search for a command to run...

How to Normalize a NumPy Array (Min-Max vs Z-Score)

Updated
7 min readView as Markdown

Normalizing rescales your data onto a common scale — which matters a lot for machine learning models that are sensitive to the raw magnitude of features, and matters even for something as simple as comparing two columns that were never on the same units to begin with. Min-max and z-score are the two everyone reaches for first. Neither one is safe to use blindly — real datasets almost always have at least one point that breaks the assumption both of them are quietly making.

Problem Statement

Given a NumPy array, rescale its values onto a common range or distribution — and do it in a way that still works if one value in the data is unusually large or small.

Example

Input:

A small salary dataset — five ordinary salaries, and one executive salary that's wildly larger than the rest:

[42000, 45000, 47000, 44000, 46000, 250000]

That last value isn't a typo. It's the realistic case — the point where naive normalization quietly stops working.

Using Min-Max Normalization

Min-max rescales everything into a fixed [0, 1] range, based on the minimum and maximum values in the data:

import numpy as np

data = np.array([42000, 45000, 47000, 44000, 46000, 250000])

min_max = (data - data.min()) / (data.max() - data.min())
print(np.round(min_max, 3))
[0.000 0.014 0.024 0.01  0.019 1.000]

Look at what happened to the five ordinary salaries: they're squeezed into a range from 0.000 to 0.024 — barely distinguishable from each other — while the one outlier claims the entire rest of the scale by itself. Min-max uses only two data points, the minimum and the maximum, to define the whole scale. One extreme value, and the other 83% of your data (five out of six points) gets compressed into a sliver near zero.

Using Z-Score Standardization

Z-score rescales based on the mean and standard deviation instead — how many standard deviations each value sits from the average:

z_score = (data - data.mean()) / data.std()
print(np.round(z_score, 3))
[-0.484 -0.445 -0.418 -0.458 -0.431  2.236]

This looks like it fixed the problem — the ordinary salaries aren't all clustered at exactly the same spot anymore. But look closer: they still only span a range of about 0.066 (from -0.484 to -0.418), while the outlier sits all the way out at 2.236. Here's the mechanism, made concrete: the mean of all six points is 79,000 — but the mean of just the five ordinary salaries, without the outlier, is 44,800. One data point pulled the average up by more than 76%. Standard deviation is calculated from how far every point sits from that inflated mean, so it gets dragged upward too. Z-score is less visually dramatic about it than min-max, but it's not actually immune to the same problem.

Note: data.std() defaults to population standard deviation (ddof=0) in NumPy, while scikit-learn's StandardScaler uses sample standard deviation (ddof=1) by default. The two will give you slightly different numbers on the same data — that's not a bug in either one, just a different default assumption about whether your array is the whole population or a sample of it.

Using Robust Scaling (Median and IQR)

Robust scaling swaps out both the mean and the standard deviation for statistics that don't move much when there's an outlier in the data — the median, and the interquartile range (the spread of the middle 50% of the data). The result is a scale where the median lands at 0 and the IQR itself becomes the unit of measurement — a value of 1 means "one IQR above the median," regardless of how extreme the raw numbers were:

median = np.median(data)
q1, q3 = np.percentile(data, [25, 75])
iqr = q3 - q1

robust = (data - median) / iqr
print(np.round(robust, 3))
[-1.4 -0.2  0.6 -0.6  0.2  81.8]

This is the difference that actually matters. The five ordinary salaries now span a real, usable range — -1.4 to 0.6 — instead of being crushed into a sliver near zero or near each other. The median (45,500) and IQR (2,500) are calculated from the middle of the data, so one extreme value barely moves them — compare that 2,500 IQR to the mean's jump from 44,800 to 79,000 in the z-score section above. And the outlier itself, instead of quietly dominating the scale, gets a score of 81.8 — a number that immediately flags it as extreme rather than hiding it among values that look almost the same.

Small footnote: np.percentile's default interpolation method for computing Q1/Q3 can differ slightly from how scikit-learn's RobustScaler computes them internally. On this dataset the numbers line up cleanly, but on messier or larger datasets, don't be surprised by small discrepancies between the two if you're comparing hand-rolled NumPy against the scikit-learn equivalent.

Watch Out For

  • Constant or near-constant arrays. Min-max divides by (max - min), which is 0 if every value is identical — that's a 0/0 division, producing NaN plus a RuntimeWarning. Z-score hits the same wall if std() is 0. Check that your denominator isn't zero before scaling, or add a small epsilon.

  • Single-element arrays. Same problem — a one-element array has a range of 0 and a standard deviation of 0, so both min-max and z-score produce NaN on it.

  • Heavily duplicated or zero-inflated data. If the middle 50% of your data is all identical values, IQR can also hit 0, and robust scaling divides by zero the same way the other two do.

  • Empty arrays. All three raw NumPy formulas above return an empty array cleanly with no error. Scikit-learn's scalers don't — they raise on empty input. Worth knowing if you're moving between hand-rolled code and scikit-learn in the same pipeline.

  • In all of these zero-denominator cases, scikit-learn's scalers handle it gracefully and output 0 instead of crashing — raw NumPy will not do that for you automatically.

Which One Should You Use?

  • Data has no serious outliers, and you need values in a fixed range (like [0, 1] for a neural network input)? Use min-max.

  • Data roughly follows a normal distribution, no major outliers, and the algorithm you're feeding it assumes standardized features? Use z-score.

  • Data has outliers you can't or shouldn't remove, and you still need the typical values to be usefully spread out? Use robust scaling.

  • Downstream system requires non-negative or strictly bounded input (certain GLM link functions, image pixel values)? Only min-max guarantees a bounded [0, 1] range — z-score and robust scaling are both unbounded and can go negative.

Before you ship this to production:

scikit-learn ships all three as MinMaxScaler, StandardScaler, and RobustScaler — reach for those directly in a real project instead of hand-rolling the formulas, since they also handle multi-column data and reuse fitted parameters consistently.

If you're scaling training and test data separately, fit the scaler on the training set only, then apply those same fitted parameters to the test set:

scaler.fit_transform(X_train)   # learn parameters from training data only
scaler.transform(X_test)        # apply those same parameters to test data

Fitting a fresh scaler on the test set — or on the combined data — leaks information about the test set into your training process, which quietly inflates how good your model looks during evaluation.

Conclusion

All three scalers agree on the ordinary salaries when there's no outlier in the mix — the differences here only show up because one point in the data is extreme. Min-max and z-score both let that one point dictate the scale for everything else, just through different mechanisms (range vs. mean/std). Robust scaling is the one built specifically to resist that — not because it's always the "better" choice, but because it's the only one of the three that keeps working the way you'd expect once your data stops being clean.