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

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:

```plaintext
[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:

```plaintext
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))
```

```plaintext
[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:

```plaintext
z_score = (data - data.mean()) / data.std()
print(np.round(z_score, 3))
```

```plaintext
[-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:

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

robust = (data - median) / iqr
print(np.round(robust, 3))
```

```plaintext
[-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:
> 
> ```plaintext
> 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.
