
Это третья статья из цикла. В прошлой части мы разбирали типы метрик, и уже там начали встречаться перцентили. Это полезная штука, но для неподготовленного читателя она выглядит просто как математическая дичь. Эта статья поможет разобраться в перцентилях по-инженерному, не заканчивая профильный факультет. Кстати, в следующей части будет подобное объяснение производных.
Лучше, конечно, объяснять эту тему вживую и с доской, но у нас будет простой python – потому что это то же самое, что писать математику вручную, только короче.
Оглавление цикла:
Перцентили для чайников
Зачем?
Какую задачу решают перцентили, и зачем эти сложности? На самом деле, они нужны для того, чтобы посмотреть на массив данных и описать их одним числом. Например «большинство HTTP запросов обработались в пределах 5 секунд». Это нужно, чтобы было удобно смотреть на графике, как ведет себя приложение «в целом». ��ногда запросы залипают или завершаются раньше времени, но нам это не важно, если большинство запросов ведут себя предсказуемо. В общем, это способ сжать историю наблюдений до одного числа, при этом по пути потерять неудобные для нас данные, чтобы не мешали смотреть на общую картину.
Данные
Мы рассматриваем перцентили в контексте метрик. Напомню, метрика — это временной ряд, то есть набор значений во времени. Пока забудем про timestamp-ы. Тогда останутся данные вида [1,2,0,10,50,42,38]. Вот с такими данными мы и будем работать в этой статье: просто каким-то массивом чисел.
Подготовка
Для начала разберемся с тем, что часто встречается и проще всего для понимания: что такое «среднее арифметическое» и «медиана». Работать будем вот с этими массивами:
a = [1, 2, 0, 10, 50, 42, 38]
b = [1, 2, 0, 10, 50, 42, 38, 5]Среднее арифметическое
Это сумма всех элементов, деленная на их количество:
sum(a)/len(a)
# 20.428571428571427 - среднее арифметическое массива a
sum(b)/len(b)
# 18.5 - среднее арифметическое массива bМедиана

Медиана – это «средний элемент», то есть буквально в середине массива (если его упорядочить). Чтобы ее найти:
Упорядочим массив
Если длина массива нечетная, берем элемент посередине
Если длина массива четная, берем два элемента посередине и считаем их среднее арифметическое
Случай с четной длиной — это просто такой математический костыль.
sort_a = sorted(a)
# [0, 1, 2, 10, 38, 42, 50]
len(sort_a)
# 7 - длина массива нечетная, элемент посередине - с индексом 3
sort_a[3]
# 10 - медиана массива aСлучай с массивом четной длины:
sort_b = sorted(b)
# [0, 1, 2, 5, 10, 38, 42, 50]
len(sort_b)
# 8 - длина массива четная, элементы посередине - с индексом 3 и 4
sort_b[3]
# 5
sort_b[4]
# 10
(sort_b[3] + sort_b[4])/2
# 7.5 - медиана массива bМожно сказать, что это свойства нашего массива. Если мы поменяем в нем значения, то его свойства изменятся: будет другое среднее арифметическое и другая медиана. А какие еще есть полезные свойства?
max(a)
# 50
max(b)
# 50
min(a)
# 0
min(b)
# 0Это нам пригодится чуть ниже.
Перцентили
Вообще, есть разные слова, которые обозначают похожие вещи: квантиль (quantile), перцентиль или процентиль (percentile), квартиль (quartile). Пока давайте забьем на все кроме перцентиля, остальное разберем в конце
Можно прочитать определение в википедии, но оно очень математически точное и из-за этого абстрактное, поэтому непонятно, зачем оно вообще надо. Давайте дойдем до этой штуки постепенно, от понятных вещей.
Опять медиана
У медианы есть такая особенность: половина элементов массива больше либо равна ей, а другая половина элементов — меньше либо равна. Мы же достали ее из середины ��ортированного массива, логично? Остановитесь и подумайте над этим:
# a
[0, 1, 2, 10, 38, 42, 50]
'''
^^
10
[ <= ]
[ >= ]
'''
# b
[0, 1, 2, 5, 10, 38, 42, 50]
'''
^
7.5
[ <= ]
[ >= ]
'''Вот возьмем ту часть, которая меньше либо равна. Можно поиграться терминами и перефразировать так:
50% элементов массива <= какого-то числа
Так исторически сложилось, что это «какое-то число» часто используется и имеет свое название, медиана. Еще часто используется вот такое значение:
Все элементы массива <= какого-то числа
или другими словами
100% элементов массива <= какого-то числа
Это, внезапно, максимальный элемент, то есть max(): все элементы массива меньше либо равны ему. Ну и наконец, часто встречается вот это:
Ни один элемент массива не < какого-то числа
или другими словами
0% элементов массива < какого-то числа
Тут опять втыкается математический костыль, потому что в данном случае оказалось удобно заменить <= на <, чтобы в качестве «какого-то числа» взять минимальный элемент, то есть min(). Не заморачивайтесь, почему и зачем, мы тут не за этим...
Поздравляю, теперь вы знаете три перцентиля: 0-й, 50-й и 100-й.
Обобщаем

А что если пошатать определение медианы и взять какой-то произвольный процент? Ну или как-то обобщить min, max и медиану?
N-й перцентиль - это такое число, что N% элементов массива меньше или равны этому числу
или чуть короче
X - это N-й перцентиль, если N% элементов массива <= X
Под это определение попадают медиана (N=50), max (N=100) и min (N=0), с небольшой поправкой на равенство в случае с min. Они описывают понятные вещи: средний элемент, максимальный, минимальный. А что описывает, например, 95-й перцентиль? Подставим его в определение:
95-й прецентиль — это такое число, что 95% элементов массива меньше или равны этому числу.
И что с этим делать? Оказывается, очень удобно использовать эту штуку, чтобы описать большинство элементов массива, при чем степень точности регулируется: 80%, 95%, 99%, 99.9%, ... Для чего бывает полезно описывать «большинство из массива»? Чтобы выбросить пики! Например, у вас есть HTTP-сервер, и вы считаете время обработки запросов. Иногда запросы зависают надолго, потому что сеть моргнула, нода перезапускалась, БД приуныла, пришел GC, клиент сам отвалился, ... В принципе это нормально, мы же не регистрируем сбой из-за каждого подвисшего запроса. Вы хотите знать, что в целом у вас запросы отрабатывают за какое-то разумное время. Вот в этой фразе «в целом» на математическом языке заменяется на «какой-то перцентиль», а «разумное время» — это его значение. Например:
95 перцентиль от времени обработки запросов = 5 секунд
То есть, большинство (95%) запросов мы обработали за 5 секунд или меньше. А остальные 5% обрабатывались дольше. Можно еще посчитать 99-й перцентиль и сравнить с 95-м, тогда станет понятно, что большинство запросов укладываются в 5 секунд, а подавляющее большинство, скажем, в 6 секунд, что вообще-то тоже неплохо. При этом максимум может оказаться каким-нибудь гигантским, скажем, 60 секунд и встречаться крайне редко (оставшийся 1%), поэтому нам он не интересен. А еще до кучи посчитаем медиану (50-й перцентиль), она может оказаться сильно меньше 95-го перцентиля (например 1 секунда). Это поможет понять, что в среднем запросы отрабатывают быстро и есть запас по производительности.
Перцентили позволяют нарезать слоями ваш массив и понять, на какие группы делятся элементы: в среднем они такие-то, в основном такие-то, в подавляющем большинстве такие-то и т.д
Мы не будем здесь разбирать, как именно считать произвольный перцентиль на произвольном массиве. Важно только заметить одну деталь: медиану мы иногда считаем как среднее арифметическое двух элементов, то есть мы не берем ее как элемент из массива, а высчитываем где-то посередине между элементами. По аналогии, перцентили тоже не обязательно берутся из элементов массива, а могут высчитываться где-то между ними, поэтому значение перцентиля может оказаться дробным даже для набора из целых чисел.
Для интересующихся, функция на чистом питоне, которая считает перцентиль из сортированного массива
# взято отсюда: https://stackoverflow.com/a/2753343
import math
def percentile(arr, n):
k = (len(arr)-1) * n
f = math.floor(k)
c = math.ceil(k)
if f==c:
return arr[int(k)]
d0 = arr[int(f)] * (c-k)
d1 = arr[int(c)] * (k-f)
return d0+d1
# пример:
# percentile(sort_a, 0.95)
# percentile(sort_b, 0.5)Если все понятно с определением — отправляемся назад, в будущее!
Добавляем время

Вспомним, что перцентили, как и медиана — это свойства конкретного массива. Если в массив добавить элемент, свойства массива изменятся, и надо будет все пересчитывать. Если массив меняется со временем, то со временем меняются и его свойства. Поэтому можно их выводить на график и косвенно наблюдать, что происходит с данными.
Большие массивы чисел сами по себе «в лоб» не понятно как вообще можно визуализировать, поэтому в метриках и используются перцентили
В самом начале мы считали медиану, min и max на небольших массивах. Если мы начнем записывать время обработки HTTP запросов, и не будем перезапускать приложение месяцами, у нас могут накопиться миллионы значений, которые еще нужно сортировать и обсчитывать, когда мы захотим узнать перцентили. Ну или как-то хитро хранить в сортированном виде и находить место для очередного значения... Короче, это все потенциально разрастается в бесконечность, поэтому так никто не делает.
Семплирование
Обычно для метрик мы семплируем данные, то есть из всего множества выбираем только некоторые элементы, чтобы из них уже считать перцентили и другие свойства. Как из бесконечного ряда даных выбрать конечное количество элементов? Придется чем-то пожертвовать, для этого есть разные алгоритмы. Мы храним, например, до 100 элементов, а когда надо записать новый, мы решаем, какой элемент нужно выкинуть:
Sliding Window: просто выбрасываем самый старый элемент. Очень просто в реализации и понимании
Forward Decay: выбрасываем случайный элемент, но чем он старее, тем больше вероятность, что он будет выброшен. Вероятность может расти как угодно, например, экспоненциально. Идея в том, что старые значения все еще чуть-чуть влияют на свойства массива, поэтому такая выборка немного точнее описывает наш бесконечный ряд данных
Сжатие
Можно считерить и вообще хранить не сами элементы, а «корзины» или «бакеты». Это просто счетчики того, сколько раз элемент попал в такой-то диапазон. На примере будет понятнее: берем время обработки HTTP запросов. Определимся, какое время считать хорошим, какое плохим (пример из прошлой части):
<= 0.1сек - хороший запрос<= 1- сойдет<= 5- подозрительнобольше 5- вообще плохо. Для однообразия можно сказать, что это<= infinity
Пришел запрос, померяли время X, и добавили +1 к тем бакетам, у которых подходят условия: X <= 0.1, X <= 1 и т.д.
А дальше в дело вступает маги��, которую не обязательно знать, и поэтому мы ее разбирать не будем, но из бакетов тоже можно посчитать перцентили. Конечно, если бакетов мало, мы теряем точность, зато экономим ресурсы! Алгоритм можно посмотреть в этой статье — там нет ничего сложного, тоже инженерно-алгоритмические фокусы, только более громоздкие.
Prometheus
С перцентилями можно столкнуться при использовании Histogram и Summary. Подробнее о том, как они выглядят, было в прошлой части. Гистограмма — это бакеты, которые вы считаете в своем приложении, а prometheus можно потом попросить посчитать из них перцентили. Причем если бакеты одинаковые, а теги разные (например, гистограмма с разных реплик или слоев), то можно без проблем их посчитать вместе. Summary — это перцентили, которые вы уже посчитали в приложении, а prometheus просто сохраняет их как числа. Обычно считается с помощью семплирования, поэтому точнее, чем бакеты. Ее можно просто в лоб вывести на график, но нельзя агрегировать! Причина, упрощенно, в том, что у вас разные выборки, поэтому результат агрегации будет отражать непонятно что. Вот показательный пример:
Не агрегируйте Summary
Пример, который демонстрирует, что с виду одинаковые Summary с двух реплик нельзя как-то сложить вместе: предположим у вас две реплики app1 и app2, они получают HTTP-запросы и записывают время обработки.
app1 = [9]*5 # пять запросов по 9 секунд
app2 = [1]*100 # сто запросов по секунде
percentile(app1, 0.95)
# 9 - 95й перцентиль app1
percentile(app2, 0.95)
# 1 - 95й перцентиль app2Каждая пишет в prometheus метрику типа Summary с 95-м перцентилем и с тегом, чтобы различать с какой реплики сняты данные.
http_time{quantile="0.95", instance="app1"} 9
http_time{quantile="0.95", instance="app2"} 1Вы думаете, что неплохо бы нарисовать один общий график «95й перцентиль от времени обработки HTTP» и делаете такой запрос к prometheus:
avg(http_time{quantile="0.95"}) without (instance)avg — потому что нужна же какая-то функция, чтобы сложить вместе два значения, почему бы не взять среднее арфиметическое между ними? Не сумму же. В результате получается (9+1)/2 = 5, вроде бы все нормально, да? А теперь давайте по-честному посчитаем 95 перцентиль на общих данных от app1 и app2:
all = sorted(app1+app2)
percentile(all, 0.95)
# 1Честный 95-й перцентиль равен 1, а avg от двух метрик получился 5, то есть вы видите что-то на графике, но это совершенно бессмысленное значение. Если вам нужно агрегировать summary, подумайте, для чего вы на них смотрите. Например, в случае со временем обработки HTTP, скорее всего вас интересует, не превышает ли 95-й перцентиль какой-то порог. Поэтому можно взять максимум из метрик вместо avg:
max(http_time{quantile="0.95"}) without (instance)Вы будете видеть один график, и если какая-то из реплик работает в основном медленно, то это будет видно. Конечно, решение не идеальное. Например, может привести к false-positive алертам: тормозит только одна реплика, а не все приложение, но вам уже звонят посреди ночи.
Нужно понимать, какие данные вы хотите видеть и для каких целей, и выбирать Histogram, Summary, бакеты, перцентили, способы семплирования и агрегации аккуратно, понимая их ограничения.
Рецепты
Можно запомнить несколько простых рецептов, чтобы сделать метрику, отражающую большинство данных, и не думать (ведь так хочется не думать, а просто сделать уже, чтобы работало!):
Для начала попробуйте сделать гистограмму.
Сделайте бакетов пять, вроде «отлично/хорошо/сойдет/плохо/недопустимо».
Если вообще не представляете, какие нужны бакеты, придется взять Summary.
Вам скорее всего нужен 95-й перцентиль (вне зависимости от типа метрики).
Сравните визуально 95-й перцентиль и медиану. Медиана покажет что-то максимально усредненное, 95-й перцентиль покажет изменения с большей точностью, но спрячет выбросы.
Если у вас Summary, не агрегируйте ее или делайте очень осторожно, никакого
avg()!У Summary неплохо бы понимать параметры семплирования, читайте доку к вашей библиотеке для метрик.
Вариации на тему
Короткий словарик похожих терминов, как и обещано:
Перцентиль мы уже знаем. Это число N от 0 до 100, такое, что N% элементов массива меньше него.
Процентиль это другой способ перевести percentile на русский язык. То есть синоним слова перцентиль.
Квартиль это четверти: 25%, 50%, 75%, 100%. То есть бывает первый, второй третий и четвертый квартиль. И еще иногда используют нулевой.
Квантиль - это, условно, перцентиль без процентов. Используется в статистике, где бывает удобно указывать абсолютную вероятность, а не в процентах
Еще можно встретить дециль — это 10%, 20% и т.д.
Есть еще разночтения самого определения, которое, по сути, гнется и шатается под вашу задачу, например:
Можно определить перцентили через вероятности, как это сделано в матстатистике. Этот способ точнее описывает суть, но гораздо сложнее для понимания, поэтому сойдет и так.
Можно в определении использовать
< > >=вместо<=и это все можно тоже называть перцентилями, например «95% элементов больше X».Можно считать перцентиль так, чтобы он всегда был элементом массива, это нужно в некоторых задачах (как и медиану не считать средним арифметическим, а брать ближайший больший/меньший элемент).
В целом все это создает путаницу, особенно для тех, кто еще не знаком с самим понятием и не переваривает сложные математические определения. Поэтому здесь разобран один вариант, наиболее близкий к тому, как работает prometheus, чтобы можно было как-то пользоваться Histogram, Summary и понимать, что вообще происходит.
Бонус
Для тех, кто осилил, несколько очень приятных картинок, которые помогут запомнить простые выводы. Случайно найдены в твиттере:
В следующий (последней) части, вооружившись всеми знаниями и научившись писать метрики, будем разбираться, как писать запросы к Prometheus, чтобы уже увидеть хоть что-то на графиках.
