EthicsComputerCourses
Join training +91 90532 10052

Percentiles & z-Scores

Statistics · Lesson 9 of 28 Beginner 7 min read Updated

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:

Text
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.

PythonRun it yourself
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))
Output
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:

Text
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.

PythonRun it yourself
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")
Output
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).

PythonRun it yourself
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())
Output
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.

PythonRun it yourself
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))
Output
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.14

Saanvi 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 ddof in stats.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 (check kind).
  • 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() uses ddof=0 by 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
Python
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))
Output
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?

2. A test has mean 50 and SD 8. What is the z-score of a mark of 38?

3. What ddof does scipy.stats.zscore() use by default?

4. When is it safe to turn a z-score into a percentile with stats.norm.cdf()?

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