Как разбить на бины в 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) + 1

9. 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+ вопросами для собесов.