0Pricing
Pandas & NumPy Academy · Lesson

Rank and Percentile within Groups

Compute within-group ranks using groupby().rank() and create percentile buckets with pd.qcut for relative comparisons.

Rank and Percentile within Groups is a free Pandas & NumPy Academy lesson on CoddyKit — lesson 4 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Pandas & NumPy Academy learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

Why Rank Within Groups?

Raw values are often less meaningful than relative rankings. A sales rep with $50,000 in monthly revenue is a top performer if the average is $30,000, but a poor performer if the average is $80,000. By computing ranks within groups (e.g. rank within each region or rank within each quarter), you get a normalised comparison that accounts for different baselines across groups. Pandas makes within-group ranking easy with groupby().rank().

import pandas as pd
import numpy as np

np.random.seed(42)
df = pd.DataFrame({
    'rep': ['Alice', 'Bob', 'Carol', 'Dave', 'Eve',
            'Frank', 'Grace', 'Hank', 'Iris', 'Jake'],
    'region': ['East']*5 + ['West']*5,
    'sales': np.random.randint(30000, 100000, 10)
})
print(df.sort_values('region'))

Series.rank() — Basic Ranking

Series.rank() assigns a rank to each value: rank 1 is the smallest, rank n is the largest. The ascending=False parameter reverses the direction so rank 1 is the largest. The result is a float Series (not integer) because ties are resolved by averaging the tied ranks by default. For a column with 5 values, ranks range from 1.0 to 5.0 — or with ties, some values may share a rank like 2.5.

import pandas as pd

s = pd.Series([80, 45, 90, 45, 70])

print('Values:', s.values)
print('Rank (ascending=True, default):', s.rank().values)
print('Rank (ascending=False — best=1):', s.rank(ascending=False).values)
# Note: two 45s share ranks 1 and 2 → both get 1.5

Tie-Breaking Methods in rank()

The method parameter controls what happens to tied values. Options: 'average' (default — tied values share the mean of their ranks), 'min' (all tied values get the lowest rank), 'max' (all get the highest rank), 'first' (ranks in order of appearance — no ties), and 'dense' (no gaps in ranks — 1, 2, 3, 3, 4 becomes 1, 2, 3, 3, 4 rather than 1, 2, 3, 3, 5). The 'dense' method is most useful for percentile-style rankings.

import pandas as pd

s = pd.Series([80, 45, 90, 45, 70])

result = pd.DataFrame({
    'value': s,
    'average': s.rank(method='average'),
    'min': s.rank(method='min'),
    'max': s.rank(method='max'),
    'first': s.rank(method='first'),
    'dense': s.rank(method='dense')
})
print(result)

Ranking Within Groups with groupby().rank()

groupby('group_col')['value_col'].rank() computes ranks within each group independently. The rank resets at the start of each group — so the best performer in the East region gets rank 1 in East, and the best in the West region also gets rank 1 in West. This is what makes groupby rank so useful for fair cross-group comparisons.

import pandas as pd
import numpy as np

np.random.seed(42)
df = pd.DataFrame({
    'rep': list('ABCDEABCDE'),
    'region': ['East']*5 + ['West']*5,
    'sales': np.random.randint(30000, 100000, 10)
})

# Rank within each region (best = rank 1)
df['rank_in_region'] = df.groupby('region')['sales'].rank(ascending=False, method='min')
df_sorted = df.sort_values(['region', 'rank_in_region'])
print(df_sorted.to_string(index=False))

Percentile Ranking with rank(pct=True)

Setting pct=True in rank() returns the percentile rank: a value between 0 and 1 representing what fraction of values in the group are at or below the current value. A percentile rank of 0.8 means the value is in the 80th percentile — higher than 80% of all values. This normalisation makes values from different-sized groups directly comparable without knowing the actual group sizes.

import pandas as pd
import numpy as np

np.random.seed(0)
df = pd.DataFrame({
    'name': list('ABCDEFGHIJ'),
    'region': ['East']*5 + ['West']*5,
    'score': np.random.randint(50, 100, 10)
})

# Percentile rank within region
df['pct_rank'] = df.groupby('region')['score'].rank(pct=True)
df['percentile'] = (df['pct_rank'] * 100).round(0).astype(int)
print(df.sort_values(['region', 'percentile'], ascending=[True, False]).to_string(index=False))

pd.qcut: Quantile-Based Binning

pd.qcut(series, q) divides values into q equal-frequency bins so that each bin contains approximately the same number of observations. Unlike pd.cut which uses equal-width intervals, pd.qcut adapts interval boundaries to the data's distribution. This is perfect for creating quartiles (q=4), quintiles (q=5), or deciles (q=10) for performance tiers.

import pandas as pd
import numpy as np

np.random.seed(42)
sales = pd.Series(np.random.exponential(scale=50000, size=100))

# Divide into quartiles
quartiles = pd.qcut(sales, q=4, labels=['Q1', 'Q2', 'Q3', 'Q4'])

result = pd.DataFrame({'sales': sales, 'quartile': quartiles})
print('Quartile counts (should be ~25 each):')
print(result['quartile'].value_counts().sort_index())
print('\nMean sales per quartile:')
print(result.groupby('quartile')['sales'].mean().round(0))

pd.cut vs pd.qcut

pd.cut(series, bins=n) creates equal-width intervals (same range, different counts). pd.qcut(series, q=n) creates equal-frequency intervals (same count, different ranges). For skewed data, pd.cut results in nearly empty bins at the tail while pd.qcut distributes observations evenly. Choose pd.cut when the interval boundaries have a natural business meaning (price ranges, age groups); choose pd.qcut for performance tiers where you want equal group sizes.

import pandas as pd
import numpy as np

np.random.seed(0)
# Right-skewed data
data = pd.Series(np.random.exponential(1, 100))

cut_result = pd.cut(data, bins=5).value_counts().sort_index()
qcut_result = pd.qcut(data, q=5).value_counts().sort_index()

print('Equal-width bins (pd.cut) — uneven counts:')
print(cut_result.to_string())
print('\nEqual-frequency bins (pd.qcut) — even counts:')
print(qcut_result.to_string())

Creating Decile Labels with pd.qcut

pd.qcut(series, q=10, labels=range(1,11)) assigns each observation to its decile (1 to 10). This is a standard technique in marketing analytics (customer value deciles), credit scoring (risk deciles), and AB testing (traffic deciles for cohort analysis). The labels parameter can be any list of the same length as q — use strings like ['Bottom 10%', ..., 'Top 10%'] for more readable output.

import pandas as pd
import numpy as np

np.random.seed(7)
customer_value = pd.Series(np.random.exponential(500, 1000))

# Assign to deciles 1-10
decile_labels = [f'D{i}' for i in range(1, 11)]
customer_decile = pd.qcut(
    customer_value,
    q=10,
    labels=decile_labels
)

result = pd.DataFrame({'value': customer_value, 'decile': customer_decile})
print('Mean customer value per decile:')
print(result.groupby('decile')['value'].mean().round(0))

Percentile Bins Within Groups

Combine groupby with pd.qcut using transform to create percentile bins within each group. This is useful when you want to rank customers within each region (rather than globally) — a customer in the bottom global decile might be top of their regional decile. Use groupby('group')['value'].transform(lambda x: pd.qcut(x, q=4, labels=['Q1','Q2','Q3','Q4'])).

import pandas as pd
import numpy as np

np.random.seed(42)
df = pd.DataFrame({
    'customer': range(20),
    'region': ['North']*10 + ['South']*10,
    'spend': np.random.randint(100, 1000, 20)
})

# Quartile within each region
def quartile_label(x):
    return pd.qcut(x, q=4, labels=['Q1','Q2','Q3','Q4'])

df['region_quartile'] = df.groupby('region')['spend'].transform(quartile_label)
print(df.sort_values(['region', 'region_quartile']).to_string(index=False))

Top-N Within Groups

A common business question is 'who are the top 3 performers in each region?' Use groupby().rank() and then filter by rank: df[df['rank_col'] <= 3]. This works cleanly with any group size and handles ties gracefully by keeping more than 3 rows if there are tied ranks at the cutoff. Alternatively, use groupby().nlargest(n) which returns the n largest values per group directly without adding a rank column.

import pandas as pd
import numpy as np

np.random.seed(0)
df = pd.DataFrame({
    'rep': [f'Rep_{i}' for i in range(15)],
    'region': ['East']*5 + ['West']*5 + ['North']*5,
    'revenue': np.random.randint(40000, 120000, 15)
})

df['region_rank'] = df.groupby('region')['revenue'].rank(ascending=False, method='min')

print('Top 2 per region:')
top2 = df[df['region_rank'] <= 2].sort_values(['region', 'region_rank'])
print(top2[['rep', 'region', 'revenue', 'region_rank']].to_string(index=False))

Combining Rank and Transform for Normalisation

You can use groupby().rank(pct=True) combined with transform to add a percentile rank column to the original DataFrame. This normalised column is often more useful as a model feature than the raw value — it is scale-free and group-comparable. For machine learning, percentile ranks are a simple alternative to standardisation (z-scoring) that is more robust to outliers.

import pandas as pd
import numpy as np

np.random.seed(1)
df = pd.DataFrame({
    'product': np.random.choice(['A', 'B', 'C'], 30),
    'score': np.random.randint(50, 100, 30)
})

# Percentile rank within each product group
df['global_pct'] = df['score'].rank(pct=True).round(3)
df['group_pct'] = df.groupby('product')['score'].rank(pct=True).round(3)

print(df.sort_values(['product', 'score']).head(12).to_string(index=False))

Quick Check

Test your understanding of rank and percentile operations from this lesson.

Lesson Recap

In this lesson you learned: Series.rank() computes numeric ranks with configurable tie-breaking, groupby().rank() resets ranks within each group for fair cross-group comparison, pct=True converts ranks to percentiles (0 to 1), and pd.qcut creates equal-frequency bins for quartile, decile, and percentile tier assignments. Next up we explore Pandas performance tips — profiling, avoiding slow loops, and efficient data types.

Frequently asked questions

Is the “Rank and Percentile within Groups” lesson free?

Yes — the full text of “Rank and Percentile within Groups” is free to read here on the web, and the Pandas & NumPy Academy course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Pandas & NumPy Academy course, upgrade to CoddyKit PRO.

What will I learn in “Rank and Percentile within Groups”?

Compute within-group ranks using groupby().rank() and create percentile buckets with pd.qcut for relative comparisons. You practise Pandas & NumPy Academy with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start Pandas & NumPy Academy?

No prior experience is required. Pandas & NumPy Academy on CoddyKit is structured for beginners through advanced learners; this is — lesson 4 of 4, so you can start here or from the beginning and move at your own pace.

How long does the “Rank and Percentile within Groups” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this Pandas & NumPy Academy lesson?

Yes. Every Pandas & NumPy Academy lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. Rolling Windows
  2. Expanding Windows
  3. Exponentially Weighted Moving Average
  4. Rank and Percentile within Groups
← Back to Pandas & NumPy Academy