Аналитика данных Распределения: почему хвосты важнее центра
0%

Распределения: почему хвосты важнее центра

Распределения: почему хвосты важнее центра

Описательная статистика закончилась на неприятной ноте: среднее — это сжатие с потерями, и потерять оно может ровно то, ради чего вы считали. Эта глава про то, что именно теряется. Ответ короткий: форма. А в прикладных данных форма почти никогда не такая, какую подразумевают привычные формулы.

Начнём с примера, который повторяется в каждой второй компании. Аналитик приносит: «средняя выручка на пользователя за июнь — 480 рублей, в мае было 455, растём на 5,5 процента». На цифру опирается прогноз, потом план найма. Через месяц выясняется, что в июне один корпоративный клиент оплатил годовой тариф на 4 миллиона рублей. Пользователей 200 тысяч, значит один клиент дал 20 рублей к среднему — почти весь «рост». Никто не соврал: сумма верна, деление верно. Просто среднее по такой форме данных — это не характеристика пользователя, а характеристика того, попал ли в окно один конкретный платёж.

Главная мысль главы: у распределения есть центр и есть хвост, и почти всегда решение зависит от хвоста, а сводная цифра описывает центр. Плюс второй слой, сквозной для всего трека: хвост, который вы видите в таблице, — это не хвост реальности, а хвост, прошедший через ваш способ сбора данных. Таймауты, семплирование, фильтры «выбросов» и предагрегация срезают именно те наблюдения, ради которых всё затевалось.

1. Распределение — это ответ, а статистика — его сжатие

Полный ответ на вопрос «сколько времени отвечает наш API» — это не число, а функция: для каждого значения t доля запросов быстрее t. Всё остальное — среднее, медиана, p99, дисперсия — это сжатие этой функции в один скаляр, и как у любого сжатия с потерями, у него есть спецификация: что оно сохраняет, а что выбрасывает.

Статистика Что сохраняет Что выбрасывает
Среднее сумму, то есть общий объём ресурса всё про форму: одно огромное значение неотличимо от тысячи средних
Медиана «типичный» случай, устойчива к выбросам полностью игнорирует хвост, каким бы он ни был
Дисперсия разброс — если он вообще конечен бессмысленна при асимметрии, у тяжёлых хвостов может не существовать
p99 границу, за которой живёт 1 процент худших случаев сколько именно там: 1,1 секунды или 40 секунд — одинаковый p99
Максимум худший наблюдавшийся случай всё остальное, и сам растёт с размером выборки

Отсюда практическое правило, к которому мы вернёмся в конце: сначала решение, потом статистика. Если решение — «сколько серверов купить», нужна сумма, значит среднее. Если решение — «переживёт ли пользователь наш чекаут», нужна доля пострадавших, значит перцентиль или доля выше порога. Если решение — «стоит ли верить прогнозу выручки», нужно знать, конечна ли вообще дисперсия. Это продолжение принципа из главы «Сначала вопрос».

2. Зоопарк форм, которые реально встречаются

Формы распределений не случайны — каждая порождается своим механизмом. Знать механизм полезнее, чем знать формулу: он подсказывает, чего ждать от данных, которые вы ещё не видели.

Нормальное. Возникает как сумма многих независимых сопоставимых слагаемых — центральная предельная теорема, разобранная в теории вероятностей. В продуктовых данных встречается редко: рост человека, ошибка измерения, а также — и это важно — средние по большим группам. Сама длительность запроса не нормальна никогда, а вот среднее по 10 тысячам запросов уже близко к нормальному, и на этом стоит вся классическая статистика.

Биномиальное. Каждый пользователь либо конвертировался, либо нет. Единственное место, где нормальное приближение работает почти без оговорок: при n·p ≥ 10 и n·(1-p) ≥ 10. Именно поэтому A/B-тесты на конверсию считаются легко, а A/B-тесты на выручку — тяжело: у выручки хвост.

Экспоненциальное и пуассоновское. Время до следующего события при постоянной интенсивности и число событий за интервал. У экспоненциального нет памяти: сколько ни ждал, ожидаемое оставшееся время то же; хвост лёгкий, убывает как $e^{-\lambda x}$. Пуассоновское часто оказывается слишком узким для реальных счётчиков — если дисперсия заметно больше среднего, это перерассеяние, и нужна отрицательная биномиальная модель.

Логнормальное. Если величина складывается не сложением, а умножением многих факторов, логарифм становится суммой, и по ЦПТ логарифм нормален. Это самая частая форма в продуктовых данных: длительность сессии, время отклика, размер заказа, зарплаты. Проверка занимает минуту: возьмите ln(x) и постройте гистограмму — если получился колокол, у вас логнормальное.

Степенное (Парето). Порождается положительной обратной связью: у кого больше подписчиков, тот получает ещё больше — «предпочтительное присоединение». Отсюда распределение размеров городов, числа подписчиков, размеров файлов, выручки на клиента в B2B. Про механику усиливающих петель — нелинейность в системном мышлении.

Смеси и избыток нулей. Данные двух разных режимов, слитые в одну колонку: попадание в кэш и промах, мобильные и десктоп, боты и люди. Гистограмма двугорбая, и любая сводная цифра описывает несуществующее среднее между двумя реальностями; лечение — не статистика, а разделение по колонке, которая различает режимы. Частный случай — выручка на пользователя: 97 процентов нулей и 3 процента платежей. Её раскладывают на две интерпретируемые части, ARPU = конверсия × ARPPU, потому что по одному среднему невозможно понять, какая из них сдвинулась.

3. Что значит «тяжёлый хвост» и почему это меняет всё

Интуиция: у распределения тяжёлый хвост, если редкие большие значения дают заметную долю суммы. Формально — хвост убывает медленнее любой экспоненты; в практически важном степенном случае

$$P(X > x) \approx C \cdot x^{-\alpha}, \qquad x \to \infty$$

где $\alpha$ — показатель хвоста. Он определяет, какие статистики вообще существуют:

Диапазон $\alpha$ Математический факт Практическое следствие
$\alpha > 2$ среднее и дисперсия конечны обычная статистика работает, но сходимость медленная
$1 < \alpha \le 2$ среднее конечно, дисперсия бесконечна доверительные интервалы по ЦПТ врут, среднее скачет от выборки к выборке
$\alpha \le 1$ среднее не существует выборочное среднее растёт с объёмом выборки; «средний клиент» — фикция

«Дисперсия бесконечна» звучит абстрактно, пока не увидишь это на своих данных: вы считаете среднюю выручку за месяц, и она прыгает на десятки процентов между соседними месяцами без всякой причины, кроме того, попал ли в окно один крупный клиент.

Три распределения в осях лог-лог: обрыв, изгиб и прямая

Картинка выше — рабочий инструмент, а не иллюстрация. По горизонтали значение в логарифмической шкале, по вертикали доля наблюдений больше этого значения (CCDF), тоже в логарифмической. В этих осях степенное распределение — прямая линия, наклон которой равен минус $\alpha$; логнормальное — вогнутая кривая; нормальное — обрыв. Построив такой график по своим данным, вы за десять секунд отвечаете на вопрос «насколько всё плохо».

Логнормальное: полезные точные формулы

Если $\ln X$ нормален со средним $\mu$ и стандартным отклонением $\sigma$, то:

$$\text{медиана} = e^{\mu}, \qquad \mathbb{E}\lbrack X \rbrack = e^{\mu + \sigma^2/2}, \qquad \frac{\text{среднее}}{\text{медиана}} = e^{\sigma^2/2}$$

Пусть $\mu = 5$, $\sigma = 1{,}2$ (типичные значения для времени отклика в миллисекундах). Тогда медиана $e^5 \approx 148$ мс, среднее $e^{5{,}72} \approx 305$ мс, а p99 равен $e^{\mu + 2{,}326\sigma} = e^{7{,}79} \approx 2417$ мс. Половина пользователей видит 148 миллисекунд, «средний пользователь» не существует вовсе, а один из ста ждёт две с половиной секунды.

Обратный ход полезнее прямого. Из двух чисел, которые у вас уже есть на дашборде, восстанавливается вся форма:

$$\sigma \approx \frac{\ln \left( p_{99} / p_{50} \right)}{2{,}326}$$

Для 148 и 2417: $\ln(16{,}3)/2{,}326 = 2{,}79/2{,}326 = 1{,}2$. Сошлось. Отношение $p_{99}/p_{50}$ — самый дешёвый индикатор тяжести хвоста: около 2–3 значит почти симметрию, 5–20 — логнормальность, больше 50 — вы в зоне, где сумма и среднее опасны.

Как вытащить $\alpha$ из доли, которую вы уже считаете

Для распределения Парето доля суммы, приходящаяся на верхнюю долю $p$ наблюдений, равна $p^{(\alpha-1)/\alpha}$. Формулу можно обратить: если верхние $p$ клиентов дают долю $s$ выручки, то

$$\alpha = \frac{1}{1 - \ln s / \ln p}$$

Проверим на классическом правиле «20 процентов клиентов дают 80 процентов выручки»: $\ln 0{,}8 / \ln 0{,}2 = 0{,}1386$, отсюда $\alpha = 1{,}16$ — дисперсия бесконечна, а среднее держится на волоске. Если топ-20 процентов дают 95 процентов, то $\alpha = 1{,}03$: прогнозировать среднюю выручку бессмысленно, надо прогнозировать крупных клиентов поимённо. Оговорка, без которой формула превращается во вредительство: реальные данные редко степенные на всём диапазоне, а подгонка прямой на лог-логе систематически смещена. Это диагностика «насколько всё плохо», а не оценка параметра для публикации; как делать правильно — Clauset, Shalizi, Newman.

4. Диагностика на своих данных: SQL и немного Python

Логарифмические корзины — правильный способ смотреть на скошенные данные: они дают одинаковую детализацию и в области 10 миллисекунд, и в области 10 секунд.

-- Одна строка = корзина шириной в одну степень двойки.
-- duration_ms > 0 обязательно: ln(0) не определён.
WITH bucketed AS (
    SELECT floor(ln(duration_ms::double precision) / ln(2.0))::int AS bucket
    FROM api_requests
    WHERE received_at >= TIMESTAMPTZ '2026-06-01 00:00+03'
      AND received_at <  TIMESTAMPTZ '2026-07-01 00:00+03'
      AND duration_ms > 0
)
SELECT bucket,
       power(2, bucket)::bigint     AS lo_ms,   -- верхняя граница = 2 * lo_ms
       count(*)                     AS n,
       round(100.0 * count(*) / sum(count(*)) OVER (), 3) AS pct
FROM bucketed
GROUP BY bucket
ORDER BY bucket;

Тот же CCDF, что на картинке, — запросом. На миллионах строк считать cume_dist() построчно дорого, поэтому сначала схлопываем до уникальных значений:

WITH counted AS (
    SELECT duration_ms, count(*) AS n
    FROM api_requests
    WHERE received_at >= TIMESTAMPTZ '2026-06-01 00:00+03'
      AND received_at <  TIMESTAMPTZ '2026-07-01 00:00+03'
    GROUP BY duration_ms
)
SELECT duration_ms,
       -- P(X > x): доля наблюдений строго больше текущего значения
       1.0 - sum(n) OVER (ORDER BY duration_ms) / sum(n::numeric) OVER () AS ccdf
FROM counted
ORDER BY duration_ms;

Последняя строка получит ccdf = 0 — на логарифмической оси её просто не будет; это нормально и не повод «чинить» запрос.

Проверка на Парето — доли, которые держит верхушка:

WITH per_user AS (
    SELECT user_id, sum(total_amount) AS revenue
    FROM orders
    WHERE status = 'paid'
      AND created_at >= TIMESTAMPTZ '2026-01-01 00:00+03'
      AND created_at <  TIMESTAMPTZ '2026-07-01 00:00+03'
    GROUP BY user_id
),
ranked AS (
    SELECT revenue,
           row_number() OVER (ORDER BY revenue DESC) AS rn,
           count(*)     OVER ()                      AS n,
           sum(revenue) OVER ()                      AS total
    FROM per_user
)
SELECT max(n) AS users,
       round(100.0 * sum(revenue) FILTER (WHERE rn <= ceil(n * 0.01)) / max(total), 1) AS top_1pct_share,
       round(100.0 * sum(revenue) FILTER (WHERE rn <= ceil(n * 0.20)) / max(total), 1) AS top_20pct_share
FROM ranked;

И разложение смеси с нулями на интерпретируемые части — вместо одного ARPU, который меняется по двум разным причинам сразу:

SELECT
    round(100.0 * count(*) FILTER (WHERE revenue > 0) / count(*), 2)  AS conversion_pct,
    round(avg(revenue), 2)                                            AS arpu,
    round(avg(revenue) FILTER (WHERE revenue > 0), 2)                 AS arppu,
    -- percentile_disc возвращает реально существовавшее значение,
    -- а не интерполяцию: для денег это почти всегда правильный выбор
    percentile_disc(0.5)  WITHIN GROUP (ORDER BY revenue)
        FILTER (WHERE revenue > 0)                                    AS median_payer,
    percentile_disc(0.99) WITHIN GROUP (ORDER BY revenue)
        FILTER (WHERE revenue > 0)                                    AS p99_payer
FROM user_revenue_monthly
WHERE month = DATE '2026-06-01';

Разбор оконных функций и FILTER — в главе «SQL для анализа».

Оценка показателя хвоста и демонстрация того, как ведёт себя среднее при $\alpha < 2$:

import numpy as np

rng = np.random.default_rng(42)


def hill_alpha(sample: np.ndarray, k: int) -> float:
    """Оценка Хилла показателя alpha в P(X > x) ~ C * x^(-alpha).

    k — сколько верхних наблюдений считаем «хвостом»; разумный старт —
    5 процентов выборки. Результат обязательно смотреть как функцию k:
    если он «плывёт», степенной модели здесь нет.
    Время O(n log n) из-за сортировки, память O(n).
    """
    s = np.sort(sample)[::-1]        # по убыванию
    top, threshold = s[:k], s[k]     # (k+1)-я по величине — порог
    return 1.0 / np.mean(np.log(top / threshold))

# Классическое Парето: numpy даёт Ломакса, поэтому +1 и умножение на масштаб
alpha, x_m = 1.2, 100.0
sample = (rng.pareto(alpha, size=1_000_000) + 1.0) * x_m

print(round(hill_alpha(sample, k=50_000), 3))   # около 1.20 — оценка работает

# Теоретическое среднее существует: alpha * x_m / (alpha - 1) = 600
for n in (10**3, 10**4, 10**5, 10**6):
    means = [((rng.pareto(alpha, n) + 1.0) * x_m).mean() for _ in range(20)]
    print(n, round(min(means)), round(max(means)))
# n=1e3:  ~370 .. ~2600
# n=1e6:  ~520 .. ~1400   — разброс не схлопывается даже на миллионе

Последние строки — вся суть тяжёлых хвостов в одном эксперименте. Для нормальных данных разброс выборочного среднего падает как $1/\sqrt{n}$, и на миллионе наблюдений среднее известно с точностью в доли процента. Здесь на миллионе наблюдений оно всё ещё гуляет в разы. Никакой размер выборки это не лечит, потому что не хватает не данных, а конечной дисперсии.

5. Перцентили: определение и арифметика, которой не существует

Перцентиль уровня q — значение, ниже которого лежит доля q наблюдений. Самое понятное определение — по ближайшему рангу: отсортировать n значений и взять элемент с номером $\lceil q \cdot n \rceil$. Postgres предлагает два варианта: percentile_cont интерполирует между соседями, percentile_disc возвращает реально существовавшее наблюдение.

Среднее перцентилей не является перцентилем. Ни простое, ни взвешенное по количеству запросов. Никогда. Это правило нарушают чаще любого другого в аналитике.

Контрпример, который стоит держать в голове целиком. Два шарда за один час:

Источник Запросов Длительности p95
Шард A 1000 все по 50 мс 50 мс
Шард B 10 все по 2000 мс 2000 мс
Вместе 1010 1000 по 50 мс и 10 по 2000 мс 50 мс

Среднее двух p95 — 1025 мс. Взвешенное по числу запросов — 69 мс. Истинный p95 объединённой выборки: $\lceil 0{,}95 \cdot 1010 \rceil = 960$, а девятисотшестидесятое значение по возрастанию — это 50 мс. Ошибка простого усреднения — двадцатикратная, и она в сторону паники: дашборд покажет секунду там, где пользователи видят 50 миллисекунд.

Можно ли ошибиться в другую сторону? Да. Точное утверждение такое: квантиль смеси лежит между минимальным и максимальным квантилями компонент, но где именно — не определяется ими. Доказательство на одну строку: функция распределения смеси есть взвешенное среднее функций распределения компонент, поэтому она зажата между наименьшей и наибольшей из них, и то же верно для обратной функции. Пример недооценки: шард A — 96 запросов по 10 мс и 4 по 500 мс, шард B — 96 по 20 мс и 4 по 600 мс. Их p95 равны 10 и 20 мс, среднее 15 мс, а объединённый p95 равен 20 мс.

Отсюда прямое следствие для витрин: таблица с дневными перцентилями не позволяет получить недельный перцентиль. Никакой запрос поверх неё не даст верного ответа.

-- НЕВЕРНО: агрегат поверх агрегата, число получится, смысла у него нет
SELECT avg(p95_ms) AS "p95 за месяц"
FROM latency_daily
WHERE day >= DATE '2026-06-01' AND day < DATE '2026-07-01';

-- ВЕРНО, вариант 1: пересчитать по сырым данным за весь период
SELECT percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms) AS p95_month_ms,
       count(*)                                                   AS n
FROM api_requests
WHERE received_at >= TIMESTAMPTZ '2026-06-01 00:00+03'
  AND received_at <  TIMESTAMPTZ '2026-07-01 00:00+03';

Если сырые данные хранить дорого, храните гистограмму, а не перцентили: счётчики по корзинам складываются, поэтому перцентиль восстанавливается из склеенных корзин.

-- ВЕРНО, вариант 2: складываем корзины, а не перцентили
WITH merged AS (
    SELECT bucket_lo_ms, sum(n) AS n
    FROM latency_histogram_daily
    WHERE day >= DATE '2026-06-01' AND day < DATE '2026-07-01'
    GROUP BY bucket_lo_ms
),
cum AS (
    SELECT bucket_lo_ms,
           sum(n) OVER (ORDER BY bucket_lo_ms) AS running,
           sum(n) OVER ()                      AS total
    FROM merged
)
SELECT min(bucket_lo_ms) AS p95_lower_bound_ms   -- нижняя граница корзины, где накопилось 95%
FROM cum
WHERE running >= 0.95 * total;

Результат — не точное значение, а нижняя граница корзины; точность задаётся шириной корзин, и это честный компромисс, в отличие от усреднения перцентилей, где ошибка неограниченна.

Чем считать перцентили в проде

АЛГОРИТМ: перцентиль потока значений
  ТОЧНО:      накопить всё → отсортировать → взять элемент ceil(q*n)
  ГИСТОГРАММА: инкремент счётчика корзины → скан накопленной суммы до q*total
  T-DIGEST:   сжатое представление, плотное у хвостов и редкое в центре;
              слияние двух представлений = слияние центроидов
Способ Время на значение Память Слияние частей Точность
Точная сортировка $O(\log n)$ амортизированно, итог $O(n \log n)$ $O(n)$ только через сырые данные точно
Гистограмма с фиксированными корзинами $O(1)$ $O(1)$, задана заранее складыванием счётчиков до ширины корзины
t-digest $O(\log(1/\delta))$ $O(1/\delta)$ слиянием состояний относительная, лучше у хвостов
Среднее перцентилей $O(1)$ $O(1)$ «складыванием» неверно

Практика: в ClickHouse — quantileTDigestState в AggregatingMergeTree и quantileTDigestMerge при чтении, тогда перцентиль за любой период считается корректно (см. колоночные хранилища). В Prometheus — histogram_quantile поверх корзин, а не summary, именно потому, что корзины складываются между инстансами, а квантили нет; про это прямо написано в документации Prometheus по гистограммам. В Postgres — либо пересчёт по сырым, либо расширение вроде timescaledb-toolkit с накопительными состояниями. Идея сжатых сводок по потоку — та же, что в вероятностных структурах данных и потоковых алгоритмах.

6. Сколько данных нужно, чтобы увидеть хвост

Перцентиль в хвосте оценивается по горстке наблюдений, даже если выборка кажется большой. Число наблюдений выше $p_{99}$ распределено биномиально с параметрами $n$ и $0{,}01$, поэтому его стандартное отклонение равно $\sqrt{n \cdot 0{,}01 \cdot 0{,}99}$.

$n$ Наблюдений выше p99 Разброс этого числа Что это значит
100 1 ±1 p99 = максимум, оценки нет
1 000 10 ±3,1 ошибка в десятки процентов
10 000 100 ±9,9 около 10 процентов — рабочий минимум
1 000 000 10 000 ±99,5 1 процент — можно сравнивать периоды

Асимптотическая дисперсия выборочного квантиля даёт то же самое строже:

$$\operatorname{Var}(\hat{x}_q) \approx \frac{q(1-q)}{n \cdot f(x_q)^2}$$

Здесь $f(x_q)$ — плотность в точке квантиля. У тяжёлых хвостов плотность в хвосте мала, знаменатель мал, дисперсия велика — вот математическая причина, по которой p99 «шумит» сильнее медианы при том же объёме данных.

Рабочее правило: чтобы квантиль уровня $q$ имел относительную ошибку порядка 10 процентов, нужно примерно $n \ge 100/(1-q)$ наблюдений. Для p99 — 10 тысяч, для p99,9 — 100 тысяч, для p99,99 — миллион. Если на графике p99 по пятиминутным интервалам с сотней запросов в интервале — вы смотрите на шум и принимаете решения по нему. Как считать доверительный интервал для квантиля (обычно бутстрэпом, поскольку плотность неизвестна) — в следующей главе, «Неопределённость».

7. Почему хвост важнее центра: усиление при разветвлении

Есть системная причина, по которой редкий случай перестаёт быть редким. Пользовательский запрос к странице обычно порождает много внутренних вызовов и ждёт их все. Если каждый вызов независимо оказывается медленнее порога с вероятностью 1 процент, то вероятность, что медленным окажется хотя бы один из $k$:

$$P = 1 - 0{,}99^{k}$$

При $k = 10$ это 9,6 процента, при $k = 100$ — 63,4 процента. Иначе говоря, при сотне вызовов больше половины пользовательских запросов упираются в то, что для отдельного сервиса является «одним процентом худших случаев».

Тот же расчёт с другой стороны, ещё нагляднее. Если ответ страницы — максимум из $k$ независимых вызовов, то функция распределения максимума равна $F(t)^k$, и медиана максимума достигается при $F(t) = 0{,}5^{1/k}$:

Разветвление $k$ Медиана страницы = перцентиль одного вызова p95 страницы = перцентиль одного вызова
1 p50 p95
10 p93,3 p99,49
100 p99,31 p99,95

Читается так: при разветвлении в сто вызовов типичный пользователь получает то, что для бэкенда является 99,3-м перцентилем. Оптимизация медианы отдельного сервиса не улучшит опыт вообще; улучшит только работа с хвостом. Это ядро классической статьи Дина и Барросо «The Tail at Scale» и причина, по которой в проде живут хеджированные запросы и деградация вместо ожидания. Оговорка честности: расчёт предполагает независимость вызовов, а она нарушается в обе стороны — общая пауза сборщика мусора делает задержки коррелированными и картина хуже, кеширование и локальность — лучше. Смысл таблицы в порядке эффекта, а не в точных числах; подробнее — производительность: измерения и наблюдаемость в распределённых системах.

8. Способ сбора данных решает, какой хвост вы увидите

Это сквозная тема трека, и в распределениях она проявляется резче всего: механизмы сбора устроены так, что вырезают именно хвост.

Таймаут срезает хвост и превращает его в пик на границе

Цензурирование таймаутом

Запрос, шедший 90 секунд, при таймауте в 30 запишется как 30 секунд — или не запишется вовсе. Хвост схлопывается в пик на границе, а метрика упирается в потолок и перестаёт различать «слегка плохо» и «катастрофа».

-- 1. Потолок виден как масса у границы и мода ровно на ней
SELECT count(*) FILTER (WHERE duration_ms >= 29900)                              AS at_the_wall,
       round(100.0 * count(*) FILTER (WHERE duration_ms >= 29900) / count(*), 3) AS pct_at_wall,
       mode() WITHIN GROUP (ORDER BY duration_ms)                                AS most_common_ms
FROM api_requests
WHERE received_at >= TIMESTAMPTZ '2026-06-01 00:00+03';

-- 2. Хвост, которого в данных нет вообще: строки без записи о завершении
SELECT date_trunc('hour', started_at)              AS hour,
       count(*)                                    AS started,
       count(*) FILTER (WHERE finished_at IS NULL) AS never_finished
FROM request_spans
WHERE started_at >= TIMESTAMPTZ '2026-06-01 00:00+03'
GROUP BY 1 ORDER BY 1;

Если pct_at_wall заметно больше нуля, любой перцентиль выше этой отметки — артефакт конфигурации, а не свойство системы. И главное: не всё цензурирование видно как пик — часто прерванный запрос просто не порождает записи, и искать надо не значения, а отсутствующие строки (второй запрос).

Согласованное упущение

Самый коварный случай, описанный Гилом Тене как coordinated omission. Нагрузочный генератор отправляет запрос, ждёт ответа и только потом шлёт следующий. Когда сервис зависает на 10 секунд, генератор в это время не создаёт нагрузки — и все запросы, которые должны были попасть в окно затыка, просто не существуют. В отчёте: небольшое число медленных ответов вместо тысяч. Ровно та же логика в клиентской аналитике: пользователь, у которого приложение зависло, не отправит событие о зависании.

Лекарство известно и простое: измерять время от запланированного момента отправки, а не от фактического, и записывать факт ухода пользователя. Разбор — в докладе «How NOT to Measure Latency» и в HdrHistogram, где коррекция встроена.

Остальные пять способов потерять хвост

Семплирование трейсов. Головное семплирование в 1 процент сохраняет медиану отлично, а хвост — плохо: редкие медленные трейсы попадают в выборку в среднем в 1 проценте случаев, и на пятиминутном окне их там просто нет. Решение — tail-based sampling: решать о сохранении трейса после завершения, оставляя все медленные и ошибочные.

Предагрегация на стороне сборщика. Агент, отправляющий среднее за минуту вместо сырых значений, уничтожает хвост необратимо: из средних перцентиль не восстанавливается никаким запросом. То же с витринами, где сохранили только avg — см. форматы хранения.

Фильтрация «выбросов». Правило «отбрасываем значения выше трёх сигм» на логнормальных данных отбрасывает вполне нормальные наблюдения, а на выручке — настоящих крупных клиентов. Симметрично: фильтр ботов по «слишком высокой активности» вырезает самых активных живых пользователей. Любой такой фильтр должен быть записан в определение метрики и отражён на графике долей отфильтрованного — иначе он становится невидимым допущением; про это же — качество данных.

Потолки в самих данных. Поле с ограничением «999+», шкала опроса от 1 до 5, лимит корзины. Распределение обрезано конструкцией формы, и никакая статистика этого не восстановит; максимум, что можно, — честно сказать «доля упёршихся в потолок равна X процентам».

Смещение по длине. Если выбирать сессии через события («возьмём сессии, в которых было событие X»), длинные сессии попадут в выборку чаще пропорционально своей длине. Наблюдаемое среднее тогда равно не $\mathbb{E}\lbrack L \rbrack$, а

$$\frac{\mathbb{E}\lbrack L^2 \rbrack}{\mathbb{E}\lbrack L \rbrack} = \mathbb{E}\lbrack L \rbrack \cdot (1 + \mathrm{CV}^2)$$

где $\mathrm{CV}$ — коэффициент вариации. При тяжёлом хвосте $\mathrm{CV}$ большой, и «средняя сессия» в отчёте оказывается в разы длиннее настоящей. Это тот же парадокс, из-за которого автобус, на который вы попали, почти всегда идёт с бо́льшим интервалом, чем средний. Правильная выборка — по единицам, о которых вы делаете вывод: по пользователям или по сессиям, а не по событиям. Родственные ловушки разобраны в главе «Откуда берутся данные».

Три вопроса перед тем, как назвать перцентиль вслух

9. Как показывать распределение, чтобы оно не соврало

Полный разбор — в главе «Визуализация», здесь только то, что специфично для формы данных.

  • Ширина корзины гистограммы — это параметр, который меняет вывод. Широкие корзины сливают два горба в один, узкие превращают данные в шум. Дефолт по умолчанию не нейтрален. Разумная отправная точка — правило Фридмана–Дьякониса: ширина $= 2 \cdot \mathrm{IQR} \cdot n^{-1/3}$. Правило Стёрджеса на скошенных данных даёт слишком мало корзин.
  • Логарифмическая ось X для скошенных величин превращает нечитаемую «палку у нуля с пустотой справа» в осмысленную картинку. Обязательное условие — подписать, что шкала логарифмическая: на такой оси одинаковые расстояния означают одинаковые отношения, и глаз без подписи читает график неверно.
  • ECDF — самый честный дефолт. У эмпирической функции распределения нет параметров, которые можно подкрутить: нет корзин, нет сглаживания. Она отвечает сразу на все вопросы вида «а какая доля быстрее 300 миллисекунд».
  • Боксплот скрывает бимодальность. Смесь двух режимов и одно широкое унимодальное распределение дают почти одинаковые ящики. Подозреваете смесь — violin, страйп-плот или две ECDF рядом.
  • «Среднее ± стандартное отклонение» на скошенных данных бессмысленно. Если нижняя граница отрицательна, а величина неотрицательна по природе, это прямое доказательство, что модель не подходит данным. И не усредняйте по группам разного размера, не показав сами размеры: агрегация, скрывающая распределение, — самый частый способ соврать честными числами.

10. Какую цифру докладывать под какое решение

Решение Правильная статистика Почему не среднее
Сколько закупить мощности сумма и среднее, плюс пик закупка про суммарный объём, тут среднее уместно
Соблюдаем ли обязательство по скорости доля запросов быстрее порога доли складываются между шардами и днями, перцентили нет
Прогноз выручки сумма плюс отдельный разбор крупных клиентов среднее нестабильно при $\alpha < 2$
Сравнение двух версий продукта эффект на медиану и на долю выше порога одно значение в хвосте перевешивает тысячи обычных
Ёмкость очереди и буферов p99,9 и максимум буфер, рассчитанный на среднее, переполняется гарантированно

Отдельно про формулировку обязательств. Требование «p99 меньше 300 мс» хуже, чем эквивалентное на вид «не менее 99 процентов запросов быстрее 300 мс», по чисто технической причине: доли аддитивны, а перцентили нет. Долю — count(*) FILTER (WHERE duration_ms <= 300) / count(*) — можно посчитать за неделю, сложить по шардам, разложить по регионам и агрегировать в любом порядке; перцентиль нельзя ничего из этого. Поэтому в SLI фиксируют порог и считают долю; подробности — SLI и SLO и мониторинг. Ту же оптику стоит применять к продуктовым метрикам: метрики продукта и метрики интерфейса чаще всего строят на средних, хотя решения там принимаются про хвост — про тех, кому неудобно.

11. Типичные ошибки

  1. Считать среднее, не посмотрев на форму. Гистограмма занимает одну минуту и один запрос. Пропуск этого шага — источник большинства ошибок этой главы.
  2. Усреднять перцентили. Дневные p95 из витрины, сложенные в недельный, — почти обязательный баг любого дашборда, который делали быстро.
  3. Считать p99 на маленьких окнах. Пять минут и сто запросов — это шум, по которому кто-то поднимает алерт.
  4. Отбрасывать выбросы по правилу трёх сигм. На скошенных данных правило не имеет смысла, потому что выведено для нормального распределения.
  5. Путать отсутствие хвоста с его невидимостью. Пик на границе таймаута — это не «у нас всё в пределах 30 секунд».
  6. Строить доверительный интервал по ЦПТ на данных с $\alpha \le 2$. Формула отработает, число будет узким и неправдой; подробности — в главе «Неопределённость».
  7. Сравнивать средние в A/B-тесте на выручке без учёта хвоста. Один кит в одной из групп двигает результат сильнее, чем любой продуктовый эффект; способы борьбы — винзоризация, метрика на логарифме, ограничение сверху с явной фиксацией правила — в главе «A/B-тесты».
  8. Смешивать режимы и выбирать выборку не по той единице. Одно распределение вместо «мобильные и десктоп» или «боты и люди»; выборка по событиям вместо выборки по пользователям, из-за чего смещение по длине завышает всё, что связано с длительностью.

Мини-итог

  • Полный ответ на вопрос о величине — это распределение; любая сводная цифра есть сжатие с потерями, и надо знать, что именно оно теряет.
  • Формы порождаются механизмами: суммы дают нормальное, произведения — логнормальное, положительная обратная связь — степенное, разные режимы — смесь. Тяжесть хвоста измеряется показателем $\alpha$: при $\alpha \le 2$ дисперсия бесконечна и доверительные интервалы по ЦПТ лгут, при $\alpha \le 1$ среднее не существует. Дешёвые индикаторы — отношение $p_{99}/p_{50}$, доля топ-1 процента и прямая на графике лог-лог.
  • Перцентили не складываются и не усредняются: складываются гистограммы, доли и мержимые состояния вроде t-digest. Чтобы квантиль уровня $q$ имел ошибку около 10 процентов, нужно порядка $100/(1-q)$ наблюдений.
  • При разветвлении в сто вызовов типичный пользователь получает то, что для отдельного сервиса является 99,3-м перцентилем: хвост становится нормой.
  • И главное: таймауты, семплирование, предагрегация, фильтры выбросов и выбор единицы наблюдения систематически срезают хвост. Прежде чем объяснять форму данных свойствами мира, проверьте, не объясняется ли она способом сбора.

Источники

Что дальше

Мы научились видеть форму и поняли, что оценки в хвосте держатся на горстке наблюдений. Естественный следующий вопрос — насколько вообще можно доверять любому числу, полученному из выборки, и как честно записать эту неуверенность в отчёт: Неопределённость: доверительные интервалы и размер выборки.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов