Percentiles & z-Scores
Priya scored 72 in maths and 80 in English. Which is her better performance? The raw marks cannot tell you, because the two papers may have had different difficulty and different spreads of marks. To compare fairly you need to know where each mark stands relative to the other students. Percentiles and z-scores are the two standard ways to do that.
In this lesson you will find percentiles and percentile ranks by hand and with NumPy and SciPy, compute z-scores, use them to compare across tests, and see how z-scores connect to the normal distribution.
Percentiles and percentile ranks
The p-th percentile is the value below which about p% of the data falls. The 90th percentile of marks is the mark that about 90% of students scored below. The quartiles are special percentiles: Q1 is the 25th, the median is the 50th and Q3 is the 75th.
The percentile rank goes the other way: given a value, what percent of the data is below it? There are a few conventions for how to count values that are equal to it:
strict: percent of values < x
weak: percent of values <= x
mean: (count below + 0.5 x count equal) / n x 100 (the average of the two)Many competitive exams in India report a percentile score rather than raw marks, so you will meet this idea often. Exact definitions vary between exams, which is a good reason to understand the conventions.
import numpy as np
from scipy import stats
# Marks out of 50 of 20 students in a Pune class test (invented)
marks = np.array([12, 18, 21, 23, 25, 27, 28, 30, 31, 33,
34, 35, 36, 38, 39, 41, 42, 44, 46, 49])
score = 38
below = (marks < score).sum()
equal = (marks == score).sum()
print("Below:", below, " equal:", equal)
print("Percentile rank by hand:", (below + 0.5 * equal) / marks.size * 100)
print("kind='mean': ", stats.percentileofscore(marks, score, kind="mean"))
print("kind='weak': ", stats.percentileofscore(marks, score, kind="weak"))
print("kind='strict':", stats.percentileofscore(marks, score, kind="strict"))
print("90th percentile:", np.percentile(marks, 90))Below: 13 equal: 1 Percentile rank by hand: 67.5 kind='mean': 67.5 kind='weak': 70.0 kind='strict': 65.0 90th percentile: 44.2
13 of the 20 students scored below 38, so a mark of 38 has a percentile rank between 65 (strict) and 70 (weak). stats.percentileofscore() uses kind="rank" by default, which for a value that appears once gives the same answer as "weak". In the other direction, np.percentile(marks, 90) returns 44.2: it sits at position 0.9 x 19 = 17.1 in the sorted list, just past 44 on the way to 46.
z-scores: how many SDs from the mean?
A z-score (standard score) measures how far a value is from the mean, in units of standard deviation:
z = (x - mean) / SD- z = 0: exactly at the mean.
- z = 1: one SD above the mean; z = -2: two SDs below.
- The sign says above or below; the size says how unusual.
Back to Priya. In her class, maths had a mean of 60 and an SD of 12; English had a mean of 75 and an SD of 4.
from scipy import stats
# Priya's marks, with her class's mean and SD in each subject (invented)
subjects = {
"Maths": {"mark": 72, "mean": 60, "sd": 12},
"English": {"mark": 80, "mean": 75, "sd": 4},
}
for name, s in subjects.items():
z = (s["mark"] - s["mean"]) / s["sd"]
share = stats.norm.cdf(z) * 100
print(f"{name:<8} z = ({s['mark']} - {s['mean']}) / {s['sd']} = {z:.2f} -> above about {share:.0f}% if marks are normal")Maths z = (72 - 60) / 12 = 1.00 -> above about 84% if marks are normal English z = (80 - 75) / 4 = 1.25 -> above about 89% if marks are normal
Her maths mark is 12 above the mean, but maths marks are widely spread, so that is exactly one SD. Her English mark is only 5 above the mean, but English marks are tightly bunched, so that is 1.25 SDs. Relative to her class, English is the stronger result. If the marks follow a bell-shaped normal distribution, stats.norm.cdf(z) converts a z-score to the share of students below it. You will learn the normal distribution properly in its own lesson; without that assumption, use actual percentiles instead.
z-scores in NumPy, SciPy and pandas
scipy.stats.zscore() standardises a whole array at once. Watch the default: it uses the population SD (ddof=0), while pandas' std() uses the sample SD (ddof=1).
import numpy as np
import pandas as pd
from scipy import stats
marks = np.array([45, 52, 60, 61, 67, 70, 75, 90])
print("Mean:", marks.mean(), " SD (ddof=0):", round(marks.std(), 3), " SD (ddof=1):", round(marks.std(ddof=1), 3))
print("By hand: ", np.round((marks - marks.mean()) / marks.std(), 2))
print("stats.zscore: ", np.round(stats.zscore(marks), 2))
print("zscore(ddof=1): ", np.round(stats.zscore(marks, ddof=1), 2))
s = pd.Series(marks)
print("pandas formula: ", ((s - s.mean()) / s.std()).round(2).tolist())Mean: 65.0 SD (ddof=0): 13.058 SD (ddof=1): 13.959 By hand: [-1.53 -1. -0.38 -0.31 0.15 0.38 0.77 1.91] stats.zscore: [-1.53 -1. -0.38 -0.31 0.15 0.38 0.77 1.91] zscore(ddof=1): [-1.43 -0.93 -0.36 -0.29 0.14 0.36 0.72 1.79] pandas formula: [-1.43, -0.93, -0.36, -0.29, 0.14, 0.36, 0.72, 1.79]
The two sets of z-scores differ a little because the SDs differ (13.06 vs 13.96). With eight values the gap is visible; with hundreds it almost disappears. Whichever you choose, the z-scores of a whole column always have a mean of 0, and an SD of 1 when measured with the same ddof. Rescaling a column this way is called standardising, and it is common before comparing or combining variables measured on different scales.
Combining subjects fairly
When subjects have different spreads, adding raw marks quietly gives more weight to the subject with the biggest spread. Averaging z-scores gives each subject equal weight. Try it on students.csv.
import pandas as pd
students = pd.read_csv("students.csv", na_values=["AB"]).drop_duplicates()
students = students.dropna(subset=["maths", "english"])
for subject in ["maths", "english"]:
col = students[subject]
print(f"{subject}: mean {col.mean():.1f}, SD {col.std():.1f}")
students[subject + "_z"] = (col - col.mean()) / col.std()
students["total"] = students["maths"] + students["english"]
students["avg_z"] = students[["maths_z", "english_z"]].mean(axis=1)
cols = ["name", "maths", "english", "total", "maths_z", "english_z", "avg_z"]
print(students.sort_values("avg_z", ascending=False)[cols].head().round(2).to_string(index=False))maths: mean 58.1, SD 17.9
english: mean 62.1, SD 14.2
name maths english total maths_z english_z avg_z
Kabir Sharma 100.0 100 200.0 2.34 2.66 2.50
Gauri Ansari 100.0 97 197.0 2.34 2.45 2.39
Sahil Ansari 94.0 100 194.0 2.01 2.66 2.33
Saanvi Agarwal 90.0 99 189.0 1.78 2.59 2.19
Dev Ansari 97.0 92 189.0 2.17 2.10 2.14Saanvi and Dev both have a total of 189, but Saanvi's average z-score is higher. English marks are less spread out (SD 14.2 against 17.9), so her 99 in English is more exceptional than Dev's 97 in maths. A z-score above 2 in both subjects means these students are far above the typical student in each.
Common mistakes
- Mixing up percent and percentile. 80% means 80 marks out of 100; the 80th percentile means you did better than about 80% of people.
- Forgetting
ddofinstats.zscore(). Its default is the population SD; pandas uses the sample SD. - Converting z to a percentile for skewed data.
norm.cdf()is only accurate for roughly normal data. - Comparing z-scores from different groups as if they were the same. A z of 1 in a strong class and in a weak class mean different absolute levels.
Key points
- The p-th percentile is the value with about p% of the data below it; a percentile rank is the reverse.
np.percentile()finds percentiles;stats.percentileofscore()finds percentile ranks (checkkind).- z = (x - mean) / SD tells you how many standard deviations a value is from the mean.
- z-scores let you compare and combine values measured on different scales.
stats.zscore()usesddof=0by default;norm.cdf(z)gives a percentile only for normal data.
Exercise
Using weather.csv, take Jaipur's daily maximum temperatures (drop missing values and the 99.9 errors by keeping values below 60). Print the 10th, 50th and 90th percentiles. Then compute the z-score (with ddof=1) of a day at 43.0 °C and the percentile rank of 43.0 with kind="weak".
Show solution
import numpy as np
import pandas as pd
from scipy import stats
weather = pd.read_csv("weather.csv")
t = weather.loc[(weather["city"] == "Jaipur") & (weather["temp_max"] < 60), "temp_max"]
print("10th, 50th, 90th percentiles:", np.percentile(t, [10, 50, 90]).round(1))
z = (43.0 - t.mean()) / t.std()
print(f"z for 43.0 °C: {z:.2f}")
print("Percentile rank:", round(stats.percentileofscore(t, 43.0, kind="weak"), 1))10th, 50th, 90th percentiles: [23.6 32.7 39.5] z for 43.0 °C: 1.88 Percentile rank: 100.0
A 43 °C day is almost two SDs above Jaipur's mean and is the hottest day in the file, so its weak percentile rank is 100. The comparison t < 60 also removes missing values, because comparisons with NaN are always false.
Quick Quiz
1. Kabir is at the 75th percentile of his class. What does this mean?
A percentile is about position relative to others, not the mark itself.
2. A test has mean 50 and SD 8. What is the z-score of a mark of 38?
z = (38 - 50) / 8 = -1.5, one and a half SDs below the mean.
3. What ddof does scipy.stats.zscore() use by default?
stats.zscore() divides by the population SD unless you pass ddof=1.
4. When is it safe to turn a z-score into a percentile with stats.norm.cdf()?
The conversion uses the normal curve, so it is only accurate when the data is close to normal.
Learn Statistics with a mentor
Free lessons get you started. Our Data Science & Machine Learning training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
