Условие
DataFrame orders(user_id, order_date, amount). Постройте RFM-сегментацию: 5 квинтилей по recency / frequency / monetary, итоговый сегмент R-F-M.
Решение
import pandas as pd
def rfm(orders: pd.DataFrame, today=None) -> pd.DataFrame:
today = pd.Timestamp(today) if today else pd.Timestamp.today().normalize()
df = orders.copy()
df["order_date"] = pd.to_datetime(df["order_date"])
rfm = (
df.groupby("user_id", as_index=False)
.agg(
recency_days=("order_date", lambda s: (today - s.max()).days),
frequency=("user_id", "size"),
monetary=("amount", "sum"),
)
)
# qcut: 1 = низ, 5 = верх. Инвертируем recency
rfm["R"] = pd.qcut(rfm["recency_days"], 5, labels=[5, 4, 3, 2, 1]).astype(int)
rfm["F"] = pd.qcut(rfm["frequency"].rank(method="first"), 5,
labels=[1, 2, 3, 4, 5]).astype(int)
rfm["M"] = pd.qcut(rfm["monetary"], 5, labels=[1, 2, 3, 4, 5]).astype(int)
rfm["segment"] = rfm["R"].astype(str) + "-" + rfm["F"].astype(str) + "-" + rfm["M"].astype(str)
return rfmЗачем rank(method='first') для frequency
pd.qcut падает с ValueError: Bin edges must be unique, если в данных слишком много одинаковых значений (например, 60% юзеров имеют ровно 1 заказ). rank(method='first') ломает ties монотонно — qcut получает уникальные значения.
Альтернатива — pd.qcut(..., duplicates='drop'), но тогда квинтилей будет меньше 5.
Инверсия для recency
Recency: меньше = лучше. Поэтому labels=[5, 4, 3, 2, 1] — низ recency_days получает метку 5.
Подводные камни
qcutvscut.qcutрежет по квантилям (равное число точек в группе),cut— по равным интервалам значений. Для RFM — почти всегдаqcut.labelsтип. Без.astype(int)метки — categorical, не int. Конкатенация работает, но арифметика — нет.todayпараметр. Жёсткоеpd.Timestamp.today()ломает тесты. Лучше параметр.- NaN после groupby + agg. Если у user_id нет заказов — он не в
rfm. Для inactive users нужно LEFT JOIN с user dimension. - Time zone. Если
order_dateсо tz, аtodayбез —(today - s.max()).daysпадает с ошибкой. Привести к одному tz.
Эталонный ответ
groupby.agg(recency, frequency, monetary), потом pd.qcut(.., 5, labels=...) с инверсией для recency. Для frequency — обязательно .rank(method='first') ради duplicates.