EDA and Feature Engineeringpandas 3.0 · scikit-learn 1.9 · seaborn 0.13 · Python 3.12+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
72 small wins to finish your pathNext lesson →

Finding outliers with the z score

The z score of a value is how many standard deviations it sits from the mean of its column, and the usual rule calls a value an outlier when its z score is past 3 in either direction.

Last updated: 12 Oct, 2026 · pandas 3.0 · scikit-learn 1.9

What an outlier is picked the three far readings out by eye with a cut-off of 50. This lesson replaces the eye with a rule that reads the column and decides for itself.

Syntax: the z score is a subtraction and a division, so it needs no library beyond the one computing the mean and the standard deviation.

x is the value, the Greek letters are the mean and the standard deviation of the column it came from

Scoring the 34 readings

The mean and the standard deviation

values.mean() and values.std() on a NumPy array divide by n. Series.std() in pandas divides by n minus 1 and gives 26.7689 for the same column. Both answers are correct; they are different conventions, and the one you pick shifts every z score slightly.

python
mean, std = values.mean(), values.std()   # NumPy divides by n

The score of every value, and the threshold

Subtracting the mean and dividing by the standard deviation turns the column into scores centred on zero. abs catches both tails at once, so a value far below the mean is flagged on the same rule as a value far above.

python
z = (values - mean) / std
outliers = [int(v) for v, score in zip(values, z) if abs(score) > 3]

Running the 3 rule on the readings

ExampleThe z score of 34 readings, with a threshold of 3
import numpy as np

readings = [11, 10, 12, 14, 12, 15, 14, 13, 15, 102, 12, 14, 17, 19, 107,
            10, 13, 12, 14, 12, 108, 12, 11, 14, 13, 15, 10, 15, 12, 10, 14, 13, 15, 10]
values = np.array(readings)
mean, std = values.mean(), values.std()   # NumPy divides by n

z = (values - mean) / std
outliers = [int(v) for v, score in zip(values, z) if abs(score) > 3]

print("mean:", round(mean, 4), " std:", round(std, 4))
print("largest |z|:", round(np.abs(z).max(), 4))
print("outliers:", outliers)

Reading the three scores

  • The mean is 21.1765 and the standard deviation 26.3723. Both numbers were computed from the column that contains the three far values, so the rule is measuring the readings against a centre and a spread those same readings pushed out of place.
  • The largest score is 3.2922. That belongs to 108, the largest reading. It clears 3, so the rule fires.
  • Three values are flagged: 102, 107 and 108. The same three the dot plot showed.
  • The 3 is a choice. For a normal column, about 99.7% of values fall inside 3 standard deviations, so a threshold of 3 flags roughly 3 values in 1000. Lower it to 2 and the rule flags about 5 in 100.

The same test with scipy.stats.zscore

SciPy writes the same arithmetic in one call. Its ddof argument is the same n or n minus 1 choice, and it defaults to 0, which is the NumPy convention used above.

Examplescipy.stats.zscore on the same readings
import numpy as np
from scipy import stats

readings = [11, 10, 12, 14, 12, 15, 14, 13, 15, 102, 12, 14, 17, 19, 107,
            10, 13, 12, 14, 12, 108, 12, 11, 14, 13, 15, 10, 15, 12, 10, 14, 13, 15, 10]
values = np.array(readings)

z = stats.zscore(values)                 # ddof=0, the same as NumPy's std
print("same scores:", np.allclose(z, (values - values.mean()) / values.std()))
print("threshold 3:", [int(v) for v in values[np.abs(z) > 3]])
print("threshold 2:", [int(v) for v in values[np.abs(z) > 2]])

Lowering the threshold to 2 returns the same three values here. The gap between 19 and 102 is wide enough that no reasonable threshold lands inside it.

Drawing the boundaries on a real column

Working with the two boundaries is often easier than working with the scores: a z score past 3 is the same as a value outside mean plus or minus 3 standard deviations. On the Titanic Age column those boundaries are a number you can read off a histogram.

ExampleTitanic Age with the two 3 sigma boundaries drawn
import matplotlib
matplotlib.use("svg")
import matplotlib.pyplot as plt
import pandas as pd

URL = ("https://raw.githubusercontent.com/krishnaik06/Feature-Engineering-Live-sessions/"
       "master/titanic.csv")
age = pd.read_csv(URL)["Age"].dropna()

lower = age.mean() - 3 * age.std()
upper = age.mean() + 3 * age.std()
print("mean:", round(age.mean(), 6), " std:", round(age.std(), 6))
print("lower:", lower)
print("upper:", upper)
print("ages outside:", sorted(age[(age < lower) | (age > upper)].tolist()))

fig, ax = plt.subplots(figsize=(7, 3.1))
ax.hist(age, bins=40, color="#9370DB", edgecolor="white")
ax.axvline(lower, color="#d64541", linestyle="--", label="mean - 3 x std")
ax.axvline(upper, color="#d64541", linestyle="--", label="mean + 3 x std")
ax.set_xlabel("Age")
ax.set_ylabel("passengers")
ax.legend()
fig.savefig("chart.svg")
A histogram of the 714 known Titanic ages, peaking between 20 and 35 and tailing off towards 80. A red dashed line sits at about -13.9, to the left of every bar and below zero, and a second red dashed line sits at about 73.3, with only two bars beyond it.

The lower boundary is minus 13.88, an age that cannot exist, so the rule flags nothing on the left. On the right it flags two passengers, aged 74 and 80. Both are plausible ages and neither is an error, which is the point made earlier: the rule finds candidates, not verdicts.

The assumption the rule makes

Mean plus or minus 3 standard deviations only means 99.7% of the column when the column is close to normal. On a column with a long right tail, the mean and the standard deviation are both pulled towards the tail, the boundary runs away with them, and the rule stops flagging anything.

ExampleThe same rule on a near-normal column and on a skewed one
import pandas as pd

URL = ("https://raw.githubusercontent.com/krishnaik06/Feature-Engineering-Live-sessions/"
       "master/titanic.csv")
df = pd.read_csv(URL)

for col in ["Age", "Fare"]:
    s = df[col].dropna()
    upper = s.mean() + 3 * s.std()
    print(f"{col:5s} skew {s.skew():6.3f}   mean+3*std {upper:8.3f}"
          f"   max {s.max():8.3f}   flagged {(s > upper).sum():4d}")

Why Fare defeats the rule

  • Age has a skew of 0.389. Close enough to symmetric for the rule to behave: the boundary lands at 73.3, inside the range of the column, and flags 2 of 714 rows.
  • Fare has a skew of 4.787. A long right tail. The standard deviation is 49.69, larger than the mean of 32.20, so the boundary lands at 181.28.
  • 181.28 is above the 99.9th percentile of the column. The rule flags 20 fares out of 891, and the fence in the next lesson flags 116.
  • The tail moved the rule, not the other way round. The same values the rule should catch are the ones that pushed the boundary past themselves.

Z score vs the IQR fence

Z scoreIQR fence
Built fromthe mean and the standard deviationthe 25th and 75th percentiles
Moved by the values it is testingyes, both inputs areno, percentiles are positions
Use it whenthe column is roughly symmetricthe column is skewed, or you do not know
Usual threshold|z| > 3, sometimes 2outside Q1 - 1.5 x IQR to Q3 + 1.5 x IQR
On Titanic Fare20 rows flagged, boundary at 181.28116 rows flagged, fence at 65.63

Where the z score is the right rule

  • Sensor and measurement data. Readings of one physical quantity usually scatter symmetrically around a true value, which is the shape the rule assumes.
  • Residuals from a fitted model. The errors of a well-fitted regression are meant to be symmetric around zero, so a residual with a large z score points at a row the model cannot explain.
  • Any column after a successful Gaussian transformation. Once a skewed column has been transformed towards normal, the rule applies to the transformed values.
Watch out. The mean and the standard deviation are computed from the column including its outliers, so a few extreme values hide themselves by stretching the boundary. Check the skew before trusting the rule, and reach for the IQR fence when the skew is large.
Try it yourself
  • Change the threshold from 3 to 1.5 in the readings example and print how many values are flagged.
  • Pass ddof=1 to stats.zscore and compare the largest score with the 3.2922 printed above.
  • Run the boundary example on Fare in place of Age and look at where the two dashed lines land on the histogram.

Slow is fine. Stopping is the only problem.