Как разбить на бины в SQL
Карьерник — квиз-тренажёр в Telegram с 1500+ вопросами для собесов аналитика. SQL, Python, A/B, метрики. Бесплатно.
Зачем это нужно
Разбиение на bins — базовая задача аналитика. Возраст → группы (teen, young, middle, senior). Revenue → сегменты (low/mid/high value). Session length → categories для когортного анализа. Вместо работы с непрерывной переменной — удобные категории для GROUP BY, дашборда, ML feature engineering.
На собеседовании спрашивают: «как разбить users на 10 сегментов по LTV». Два ответа: вручную через CASE (ручные границы) или NTILE (равное количество в каждой группе). Правильный выбор зависит от задачи.
В статье:
- CASE WHEN для ручных границ
- NTILE для равных сегментов
- WIDTH_BUCKET в Postgres
- Binning по квантилям
- Dynamic binning
1. CASE WHEN — ручные границы
Самый гибкий способ:
SELECT
age,
CASE
WHEN age < 18 THEN 'teen'
WHEN age < 35 THEN 'young'
WHEN age < 55 THEN 'middle'
ELSE 'senior'
END AS age_group
FROM users;Плюсы: полный контроль, понятные labels. Минусы: нужно знать хорошие границы заранее.
2. NTILE — равные сегменты
Разбивает на N одинаковых (по количеству) групп:
SELECT
user_id,
total_spent,
NTILE(10) OVER (ORDER BY total_spent DESC) AS decile
FROM user_stats;Decile 1 — топ 10% (самые платящие). Decile 10 — нижние 10%.
Квартили
NTILE(4) OVER (ORDER BY ... ) -- Q1, Q2, Q3, Q4Деcиль (NTILE(10))
Для RFM.
Плюсы: автомат, равные группы. Минусы: границы не понятные (нужно вычислить отдельно).
3. WIDTH_BUCKET (Postgres)
Равные по диапазону:
SELECT
age,
WIDTH_BUCKET(age, 0, 100, 10) AS age_bucket
FROM users;Разбивает диапазон [0, 100] на 10 равных bins. Возраст 35 → bucket 4 (из интервала 30-40).
Плюсы: равный диапазон. Минусы: неравное количество пользователей в каждой.
4. Binning по квантилям
Сочетание: границы по квантилям, labels ручные.
WITH quartiles AS (
SELECT
PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY total_spent) AS q1,
PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY total_spent) AS q2,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY total_spent) AS q3
FROM users
)
SELECT
u.user_id,
u.total_spent,
CASE
WHEN u.total_spent <= q.q1 THEN 'low'
WHEN u.total_spent <= q.q2 THEN 'mid_low'
WHEN u.total_spent <= q.q3 THEN 'mid_high'
ELSE 'high'
END AS tier
FROM users u, quartiles q;5. Logarithmic bins для long-tail
Для данных с тяжёлым хвостом:
SELECT
amount,
CASE
WHEN amount < 100 THEN '< 100'
WHEN amount < 1000 THEN '100-1k'
WHEN amount < 10000 THEN '1k-10k'
WHEN amount < 100000 THEN '10k-100k'
ELSE '> 100k'
END AS amount_bucket
FROM orders;6. Bins по времени
-- по часу дня
SELECT
EXTRACT(HOUR FROM created_at) AS hour_of_day,
COUNT(*) AS events
FROM events
GROUP BY 1;
-- по периоду дня
SELECT
CASE
WHEN EXTRACT(HOUR FROM created_at) < 6 THEN 'night'
WHEN EXTRACT(HOUR FROM created_at) < 12 THEN 'morning'
WHEN EXTRACT(HOUR FROM created_at) < 18 THEN 'afternoon'
ELSE 'evening'
END AS period,
COUNT(*)
FROM events
GROUP BY 1;7. Dynamic bins через PERCENTILE + generate_series
Для ровно N равных квантильных bins:
WITH quantiles AS (
SELECT
unnest(ARRAY[0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]) AS pct
),
thresholds AS (
SELECT
pct,
PERCENTILE_CONT(pct) WITHIN GROUP (ORDER BY amount) OVER () AS threshold
FROM quantiles, orders
GROUP BY pct
)
SELECT * FROM thresholds;Сложнее, но даёт «label + value».
8. В pandas
# равные диапазоны
df['age_bucket'] = pd.cut(df['age'], bins=4)
# с labels
df['age_group'] = pd.cut(df['age'],
bins=[0, 18, 35, 55, 100],
labels=['teen', 'young', 'middle', 'senior'])
# равное количество (квантили)
df['decile'] = pd.qcut(df['total_spent'], q=10, labels=False) + 19. Binning в контексте RFM
WITH user_rfm AS (
SELECT
user_id,
CURRENT_DATE - MAX(created_at)::DATE AS days_since_last,
COUNT(*) AS frequency,
SUM(total) AS monetary
FROM orders
GROUP BY user_id
),
rfm_scored AS (
SELECT
user_id,
NTILE(5) OVER (ORDER BY days_since_last DESC) AS r_score,
NTILE(5) OVER (ORDER BY frequency) AS f_score,
NTILE(5) OVER (ORDER BY monetary) AS m_score
FROM user_rfm
)
SELECT
user_id,
r_score,
f_score,
m_score,
r_score * 100 + f_score * 10 + m_score AS rfm_score
FROM rfm_scored;Частые ошибки
1. NTILE без ORDER BY
Результат случайный.
2. Границы CASE с gap'ами
-- ошибка: 18 попадает в обе
WHEN age < 18 THEN 'teen'
WHEN age > 18 THEN 'adult'Правильно: WHEN age < 18 THEN 'teen' WHEN age >= 18 THEN 'adult' или используйте ELSE.
3. Очень мало bins
3 bins — не даёт разделения. 3-10 обычно оптимально.
4. Очень много
100 bins — большинство пустые. 5-10 достаточно.
Связанные темы
FAQ
NTILE или CASE?
CASE — для бизнес-логики (понятные labels). NTILE — для равных сегментов (статистика).
WIDTH_BUCKET работает в MySQL?
Нет, только в Postgres / Oracle.
Как best-fit bins?
Jenks natural breaks / decision tree binning — для ML. Ручные границы — для отчётов.
Bins для ML features?
Часто улучшает. Но оставляйте и raw — дать модели решить.
Тренируйте SQL — откройте тренажёр с 1500+ вопросами для собесов.