Человеческим языком про метрики 3: перцентили для чайников
Это третья статья из цикла. В прошлой части мы разбирали типы метрик, и уже там начали встречаться перцентили. Это полезная штука, но для неподготовленного читателя она выглядит просто как математическая дичь. Эта статья поможет разобраться в перцентилях по-инженерному, не заканчивая профильный факультет. Кстати, в следующей части будет подобное объяснение производных.
Лучше, конечно, объяснять эту тему вживую и с доской, но у нас будет простой 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, чтобы уже увидеть хоть что-то на графиках.