EduFlow

Section 4 · Analysis: Group, Join, Reshape

Ranking and Binning

16 min3 min readPython exercise

Lesson at a glance

Rank customers by spend, measure how concentrated revenue is, and turn a continuous number into segments with cut (fixed boundaries) and qcut (equal-sized groups).

  1. 1One row per customer
  2. 2Ranking
  3. 3cut: bins with boundaries you choose
  4. 4qcut: equal-sized groups

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

“Who are our best customers?” is a question every sales or marketing team asks, and the honest answer has two parts: a ranked list, and how much the top of that list matters. At Cartwheel the answer is striking, and it shapes how you should read every revenue number, including the Q4 jump.

One row per customer

Start with a customer-level table: one row per customer, with orders and spend, built with the groupby from Lesson 4.1.

Python
import pandas as pd

items = pd.read_csv("order_items.csv")
orders = pd.read_csv("orders.csv", parse_dates=["order_date"])
items["revenue"] = items["quantity"] * items["unit_price"] * (1 - items["discount"])
orders = orders.merge(items.groupby("order_id", as_index=False)["revenue"].sum(), on="order_id", validate="one_to_one")
orders = orders[orders["status"] != "cancelled"]

cust = orders.groupby("customer_id").agg(orders=("order_id", "count"), spend=("revenue", "sum"))
print(len(cust), "customers with a non-cancelled order")
print(cust["spend"].describe().round(0))

503 customers. The median spent 551 dollars, the mean 888, the top one 5,370. When the mean sits far above the median, a few large values are pulling it up; Lesson 5.1 returns to what that means for reporting averages.

Ranking

Python
import pandas as pd

items = pd.read_csv("order_items.csv")
orders = pd.read_csv("orders.csv", parse_dates=["order_date"])
items["revenue"] = items["quantity"] * items["unit_price"] * (1 - items["discount"])
orders = orders.merge(items.groupby("order_id", as_index=False)["revenue"].sum(), on="order_id", validate="one_to_one")
orders = orders[orders["status"] != "cancelled"]
cust = orders.groupby("customer_id").agg(orders=("order_id", "count"), spend=("revenue", "sum"))

cust["rank"] = cust["spend"].rank(ascending=False, method="min")
cust["percentile"] = cust["spend"].rank(pct=True)
print(cust.sort_values("rank").head(5).round(2))

top20 = cust.nlargest(int(len(cust) * 0.2), "spend")
print(len(top20), "customers =", round(top20["spend"].sum() / cust["spend"].sum(), 3), "of revenue")

rank gives every row its position, with 1 for the highest spend because of ascending=False. Ties are where the methods differ: "min" gives tied customers the same rank and skips the next numbers, like a sports table, "dense" never skips, "first" breaks ties by row order, and the default "average" gives them the mean rank. pct=True turns ranks into percentiles between 0 and 1, which compare across groups of different sizes.

The last line is the concentration figure: the top 100 customers, a fifth of the base, generate 55.5% of revenue. That is less extreme than the textbook 80/20 rule, but still means losing a handful of big customers would hurt more than losing a hundred small ones.

Ranking within groups

Country managers want their own top customers, not a global list where the largest markets crowd everyone else out. rank works on a grouped column too, restarting at 1 inside each group:

Python
import pandas as pd

items = pd.read_csv("order_items.csv")
orders = pd.read_csv("orders.csv", parse_dates=["order_date"])
customers = pd.read_csv("customers.csv")
items["revenue"] = items["quantity"] * items["unit_price"] * (1 - items["discount"])
orders = orders.merge(items.groupby("order_id", as_index=False)["revenue"].sum(), on="order_id", validate="one_to_one")
orders = orders[orders["status"] != "cancelled"]
cust = orders.groupby("customer_id", as_index=False).agg(spend=("revenue", "sum"))
cust = cust.merge(customers[["customer_id", "country"]], on="customer_id", validate="one_to_one")

cust["rank_in_country"] = cust.groupby("country")["spend"].rank(ascending=False, method="first")
print(cust[cust["rank_in_country"] == 1].sort_values("spend", ascending=False).round(0))

Filtering on rank_in_country == 1 keeps each country’s top spender, eight rows in all; keeping ranks of 3 or less would give a top-three list per country. method="first" guarantees exactly one row per rank even when two customers tie, which is what you want for a fixed-length list. This “rank within group, then filter” pattern answers a whole family of questions: the best-selling product per category, the first order of each customer, the latest ticket per order.

cut: bins with boundaries you choose

Marketing wants spend bands they can name in a campaign. You choose the edges:

Python
import numpy as np
import pandas as pd

items = pd.read_csv("order_items.csv")
orders = pd.read_csv("orders.csv", parse_dates=["order_date"])
items["revenue"] = items["quantity"] * items["unit_price"] * (1 - items["discount"])
orders = orders.merge(items.groupby("order_id", as_index=False)["revenue"].sum(), on="order_id", validate="one_to_one")
orders = orders[orders["status"] != "cancelled"]
cust = orders.groupby("customer_id").agg(orders=("order_id", "count"), spend=("revenue", "sum"))

cust["band"] = pd.cut(cust["spend"], bins=[0, 250, 500, 1000, 2000, np.inf],
                      labels=["<250", "250-500", "500-1k", "1k-2k", "2k+"])
print(cust.groupby("band", observed=True)["spend"].agg(["count", "sum"]).round(0))

Each edge pair defines a bin, closed on the right by default: (250, 500] includes 500 but not 250. The 57 customers in the 2k+ band, 11% of the base, brought in 173,407 dollars, more than any other band. cut returns a categorical column whose categories keep your order, so the table lists the bands from smallest to largest rather than alphabetically.

qcut: equal-sized groups

When you want groups of equal size rather than fixed boundaries, qcut cuts at quantiles:

Top row: cut splits the spend axis at fixed values 250, 500, 1000 and 2000; the bins hold different numbers of customers. Bottom row: qcut splits at the quartiles 210, 551 and 1236 so each of the four bins holds about 126 customers. cut 143 92 118 93 57 customers qcut 126 126 125 126 210 551 1236 quartile edges
cut uses fixed boundaries; qcut uses equal counts
Python
import pandas as pd

items = pd.read_csv("order_items.csv")
orders = pd.read_csv("orders.csv", parse_dates=["order_date"])
items["revenue"] = items["quantity"] * items["unit_price"] * (1 - items["discount"])
orders = orders.merge(items.groupby("order_id", as_index=False)["revenue"].sum(), on="order_id", validate="one_to_one")
orders = orders[orders["status"] != "cancelled"]
cust = orders.groupby("customer_id").agg(orders=("order_id", "count"), spend=("revenue", "sum"))

cust["quartile"] = pd.qcut(cust["spend"], q=4, labels=["Q1 low", "Q2", "Q3", "Q4 high"])
share = cust.groupby("quartile", observed=True)["spend"].sum() / cust["spend"].sum()
print(share.round(3))
print(pd.qcut(cust["orders"], q=4, duplicates="drop").value_counts().sort_index())

The top quartile of customers brings 63.2% of revenue, the bottom quartile 2.8%. The last line shows qcut’s limit: a third of customers placed exactly one order, so the two lowest quartile edges are both 1, and plain qcut raises ValueError: Bin edges must be unique. duplicates="drop" merges the repeated edges, leaving fewer, unequal groups. For counts with many ties, cut with hand-picked edges is usually clearer.

In the exercise you segment customers yourself. That completes the analysis toolkit; Section 5 turns it on real questions, starting with what an average can and cannot tell you.

Key takeaways

  • rank(ascending=False, method="min") numbers rows by value; the method decides how ties are numbered, and pct=True gives percentile ranks.
  • pd.cut assigns values to bins with boundaries you choose; use np.inf as the last edge so nothing falls outside.
  • pd.qcut makes bins with (nearly) equal numbers of rows, such as quartiles; when many values tie, pass duplicates="drop".
  • Both return a categorical column, so group by it with observed=True and sort by the category order, not alphabetically.
  • A concentration figure such as “the top 20% of customers bring 55% of revenue” is often the most useful single number you can give a business.

Further reading

Check your understanding

3 questions

Question 1 of 3

pd.cut(spend, bins=[0, 250, 500, 1000]) leaves some customers as NaN. Which ones?

Question 2 of 3

You want four customer groups with the same number of customers each. Which tool fits?

Question 3 of 3

Two customers tie for second place by spend. With rank(ascending=False, method="min"), what ranks do they and the next customer get?

Practice

Python exercise

Segment Cartwheel's customers

Build cust, one row per customer_id, with orders (number of orders) and spend (total revenue). Add rank (1 for the highest spender, ties sharing the lowest rank), and band, made with pd.cut using the edges 0, 250, 500, 1000, 2000 and infinity and the labels "<250", "250-500", "500-1k", "1k-2k", "2k+". Finally set top10_share to the share of all revenue that comes from the 50 highest-spending customers (about the top 10%), rounded to 3 decimals.

Data files: order_items.csv, orders.csv

import numpy as np
import pandas as pd

items = pd.read_csv("order_items.csv")
orders = pd.read_csv("orders.csv", parse_dates=["order_date"])
items["revenue"] = items["quantity"] * items["unit_price"] * (1 - items["discount"])
orders = orders.merge(items.groupby("order_id", as_index=False)["revenue"].sum(), on="order_id", validate="one_to_one")
orders = orders[orders["status"] != "cancelled"]

# 1. cust: one row per customer_id with columns orders (count) and spend (sum of revenue)
cust = None

# 2. Column "rank": 1 for the highest spender, ties share the lowest rank ("min")

# 3. Column "band": spend bands with edges 0, 250, 500, 1000, 2000, infinity
#    and labels "<250", "250-500", "500-1k", "1k-2k", "2k+"

# 4. Share of all revenue from the top 10% of customers by spend (top 50), rounded to 3 decimals
top10_share = None
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

(no output)

Checks

  • cust has 503 customers with orders and spend
  • rank starts at 1, and customer 331 ranks 2nd
  • band has no gaps and the expected counts per band
  • top10_share is the top 50 customers’ share of revenue