Я довольно долгое время работала аналитиком в Яндекс.Метрике - системе web аналитики. Такие системы помогают сайтам собирать и анализировать поведение пользователей на сайтах.
Естественно, в таких продуктах как аналитические системы, данные - это главная ценность. Поэтому одна из моих задач как аналитика была мониторинг того, что с данными всё ок.
Есть 2 аспекта качества данных, за которыми стоит следить:
В данных нет пропусков и дубликатов -> число событий и сессий находится в пределах ожидаемых значений.
Данные корректны -> для каждого параметра распределение значений постоянно и очередной релиз не сломал все, например, перестав трекать покупки на сайтах совсем или начав указывать Safari вместо всех других браузеров.
В этой статье я хочу поделиться своим опытом по решению этой комплексной задачи и бонусом покажу примеры использования array functions в ClickHouse.
Что такое web аналитика?
Системы web аналитики логируют довольно много информации о событиях на сайтах, например, какой браузер и операционную систему пользователь использовал, какие URLs он посетил, сколько времени он провел на сайте и даже какие товары добавил в корзину и затем купил. Все эти данные затем могут быть использованы для отчетности (узнать, сколько пользователей посетило сайт) или аналитики (понять, в чем могут быть проблемы текущего customer journey). Можно подробнее почитать про системы web аналитики на Wikipedia.
В этой статье мы будем использовать данные web аналитики из примеров ClickHouse. Вы можете найти инструкцию по тому, как все загрузить в документации.
Для начала давайте посмотрим на данные.VisitID
- это уникальный идентификатор сессии, другие колонки содержат информацию о характеристиках каждой сессии. В колонках UserAgent
и OS
содержатся цифры, но на самом деле, это категориальные переменные - числами закодированы названия браузеров и операционных систем. Хранить числа вместо строк и декодировать на уровне application гораздо эффективнее. Такие оптимизации действительно важны и могут помочь вам сэкономить терабайты места на диске и довольно много CPU, когда вы работаете с big data.
SELECT
VisitID,
StartDate,
UTCStartTime,
Duration,
PageViews,
StartURLDomain,
IsMobile,
UserAgent,
OS
FROM datasets.visits_v1
FINAL
LIMIT 10
┌─────────────VisitID─┬──StartDate─┬────────UTCStartTime─┬─Duration─┬─PageViews─┬─StartURLDomain─────────┬─IsMobile─┬─UserAgent─┬──OS─┐
│ 6949594573706600954 │ 2014-03-17 │ 2014-03-17 11:38:42 │ 0 │ 1 │ gruzomoy.sumtel.com.ua │ 0 │ 7 │ 2 │
│ 7763399689682887827 │ 2014-03-17 │ 2014-03-17 18:22:20 │ 24 │ 3 │ gruzomoy.sumtel.com.ua │ 0 │ 2 │ 2 │
│ 9153706821504089082 │ 2014-03-17 │ 2014-03-17 09:41:09 │ 415 │ 9 │ gruzomoy.sumtel.com.ua │ 0 │ 7 │ 35 │
│ 5747643029332244007 │ 2014-03-17 │ 2014-03-17 04:46:08 │ 19 │ 1 │ gruzomoy.sumtel.com.ua │ 0 │ 2 │ 238 │
│ 5868920473837897470 │ 2014-03-17 │ 2014-03-17 10:10:31 │ 11 │ 1 │ gruzomoy.sumtel.com.ua │ 0 │ 3 │ 35 │
│ 6587050697748196290 │ 2014-03-17 │ 2014-03-17 09:06:47 │ 18 │ 2 │ gruzomoy.sumtel.com.ua │ 0 │ 120 │ 35 │
│ 8872348705743297525 │ 2014-03-17 │ 2014-03-17 06:40:43 │ 190 │ 6 │ gruzomoy.sumtel.com.ua │ 0 │ 5 │ 238 │
│ 8890846394730359529 │ 2014-03-17 │ 2014-03-17 02:27:19 │ 0 │ 1 │ gruzomoy.sumtel.com.ua │ 0 │ 57 │ 35 │
│ 7429587367586011403 │ 2014-03-17 │ 2014-03-17 01:13:14 │ 0 │ 1 │ gruzomoy.sumtel.com.ua │ 1 │ 1 │ 12 │
│ 5195928066127503662 │ 2014-03-17 │ 2014-03-17 01:43:02 │ 1926 │ 3 │ gruzomoy.sumtel.com.ua │ 0 │ 2 │ 35 │
└─────────────────────┴────────────┴─────────────────────┴──────────┴───────────┴────────────────────────┴──────────┴───────────┴─────┘ю
Как вы могли заметить, я использовалаfinal
modifier после названия таблицы в запросе выше и это не случайно. Я сделала это, чтобы быть уверенной, что все данные были помержены и в результате я получу только одну строку для каждой сессии.
В ClickHouse довольно часто используется движокCollapsingMergeTree
, поскольку он позволяет использовать inserts вместо updates (которых в CH вообще долгое время не было). Больше подробностей про это как обычно в документации.
В таком подходе для некоторых сессий после обновления может быть несколько строк, но потом eventually система все помержит в background и удалит/коллапсирует лишние. Используя final
modifier, мы "насильно" применяем мержи при выполнении запроса.
Чтобы увидеть разницу, посмотрим на 2 запроса:
SELECT
uniqExact(VisitID) AS unique_sessions,
sum(Sign) AS number_sessions,
-- number of sessions after collapsing
count() AS rows
FROM datasets.visits_v1
┌─unique_sessions─┬─number_sessions─┬────rows─┐
│ 1676685 │ 1676581 │ 1680609 │
└─────────────────┴─────────────────┴─────────┘
SELECT
uniqExact(VisitID) AS unique_sessions,
sum(Sign) AS number_sessions,
count() AS rows
FROM datasets.visits_v1
FINAL
┌─unique_sessions─┬─number_sessions─┬────rows─┐
│ 1676685 │ 1676721 │ 1676721 │
└─────────────────┴─────────────────┴─────────┘
Disclaimer: использование final
modifier может негативно сказаться на performance запроса (подробности). Как всегда точность имеет свою цену.
Как быть уверенным в качестве данных?
Задача мониторинга того, что в данных не появилось пропусков или дубликатов, довольно понятна. Есть множество методов time-series anomaly detection начиная от наивного подхода (например, число событий отличается от прошлого периода не больше чем на 20%) до Machine Learning подходов с использованием таких библиотек как Prophet или PyCaret.
Задача мониторинга data consistency уже более интересная. Как мы обсуждали ранее, системы web аналитики собирают сотни разных параметров о поведении пользователей на сайте и мы хотим быть уверены, что все эти параметры собираются корректно (или хотя бы также как и раньше).
Параметры могут быть числовыми (например, длительность сессии или число посещенных страниц) или категориальными (например, браузер или операционная система). С числовыми метрикам все просто: мы можем использовать статистические критерии для проверки, что распределение не изменилось, например, the Kolmogorov-Smirnov test.
Так что после гугления best practices, у меня остался только один вопрос: "а как мониторить консистентность категориальных признаков?". Давайте перейдем к нему.
Категориальные переменные
Давайте для примера будем смотреть на браузеры. Всего в наших данных 62 уникальных значения для этого поля и 12 браузеров, доля которых больше 1%.
SELECT uniqExact(UserAgent) AS unique_browsers
FROM datasets.visits_v1
┌─unique_browsers─┐
│ 62 │
└─────────────────┘
SELECT
UserAgent,
count() AS sessions,
round((100. * sessions) / (
SELECT count()
FROM datasets.visits_v1
FINAL
), 2) AS sessions_share
FROM datasets.visits_v1
FINAL
GROUP BY 1
HAVING sessions_share >= 1
ORDER BY sessions_share DESC
┌─UserAgent─┬─sessions─┬─sessions_share─┐
│ 7 │ 493225 │ 29.42 │
│ 2 │ 236929 │ 14.13 │
│ 3 │ 235439 │ 14.04 │
│ 4 │ 196628 │ 11.73 │
│ 120 │ 154012 │ 9.19 │
│ 50 │ 86381 │ 5.15 │
│ 79 │ 63082 │ 3.76 │
│ 121 │ 50245 │ 3 │
│ 1 │ 48688 │ 2.9 │
│ 42 │ 21040 │ 1.25 │
│ 5 │ 20399 │ 1.22 │
│ 71 │ 19893 │ 1.19 │
└───────────┴──────────┴────────────────┘
Мы, конечно, можем мониторить долю сессий с каждым конкретным браузером отдельно. Тогда это все будут числовые переменные и задача сводится к предыдущей. Но есть нюанс: нам придется мониторить по крайней мере 12 различных метрик (по числу крупных браузеров) только для одного поля UserAgent
.
Каждый человек, который хоть раз настраивал alerts, знает, что чем меньше величин ты мониторишь - тем лучше. Потому что неизбежно есть ложные срабатывания (false positives) и если вы мониторите очень много временных рядов, то они могут начать доставлять неудобства.
Так что, я начала думать о том, как свести задачу к одной метрике, которая бы показывала разницу между распределениями сейчас (момент времени T2
) и до этого (момент времени T1
).
Выбор подходящего периода времени в прошлом будет зависеть от гранулярности:
для минутных данных — можно просто смотреть на предыдущую точку,
для дневных данных — стоит смотреть на неделю назад, чтобы учесть недельную сезонность (например, в выходные люди меньше проводят времени на desktop и доля мобильных браузеров будет выше чем по будням - это может привести к постоянным ненужным срабатываниям),
для данных по неделям и месяцам — стоит проверить, есть ли в ваших данных годовая сезонность, и в зависимости от этого брать либо предыдущую точку либо данные на год назад.
В качестве первой идеи метрика у меня была вариация на L1 норму в machine learning (больше деталей).
Давайте посчитаем ее для такого примера.
Значение L1 метрики будет равно 10%. На самом деле, за этим стоит физический смысл: она показывает минимальную долю событий, для которых изменилось значение по сравнению с прошлым периодом.
После этого, я обсудила эту задачу с коллегой c большим опытом в data science. И он посоветовал мне посмотреть на Kullback-Leibler и Jensen-Shannon divergences, поскольку это более академически правильный подход для измерения расстояний между вероятностными распределениями.
Если вы не помните или даже никогда не слышали об этих метриках, не волнуйтесь - я тоже была на вашем месте. Так что, я просто загуглила формулы (эта статья довольно подробно объясняет теорию) и посчитала их значения для нашего примера.
import numpy as np
prev = np.array([0.7, 0.2, 0.1])
curr = np.array([0.6, 0.27, 0.13])
def get_kl_divergence(prev, curr):
kl = prev * np.log(prev / curr)
return np.sum(kl)
def get_js_divergence(prev, curr):
mean = (prev + curr)/2
return 0.5*(get_kl_divergence(prev, mean) + get_kl_divergence(curr, mean))
kl = get_kl_divergence(prev, curr)
js = get_js_divergence(prev, curr)
print('KL divergence = %.4f, JS divergence = %.4f' % (kl, js))
# KL divergence = 0.0216, JS divergence = 0.0055
Как вы можете видеть, посчитанные расстояния довольно сильно отличаются. То есть, мы пришли к (как минимум) трем различным способам расчета расстояния между распределениями браузеров.
Теперь нам нужно понять, какой из способов лучше всего подойдет нам для мониторинга консистентности данных.
And the winner is…
Лучший способ сравнить различные методы - посмотреть как они будут вести себя в ситуациях близких к реальности. Для этого мы можем смоделировать различные аномалии в данных и сравнить эффекты в зависимости от их магнитуды.
Будем смотреть на два класса аномалий, которые часто случаются в данных:
Потеря данных: мы начинаем терять данные из одного из браузеров и это приводит к перераспределению долей остальных браузеров.
Перераспределение: это ситуация, когда траффик из одного браузера вдруг начинает определяться как другой, например, 10% событий из Safari мы стали определять как undefined.
Возьмем реальные данные о долях браузеров и симулируем аномалии. Для простоты, я объединила все браузеры с долей меньше 5% в одну группу browser = 0
.
WITH browsers AS
(
SELECT
UserAgent,
count() AS raw_sessions,
(100. * count()) / (
SELECT count()
FROM datasets.visits_v1
FINAL
) AS raw_sessions_share
FROM datasets.visits_v1
FINAL
GROUP BY 1
)
SELECT
if(raw_sessions_share >= 5, UserAgent, 0) AS browser,
sum(raw_sessions) AS sessions,
round(sum(raw_sessions_share), 2) AS sessions_share
FROM browsers
GROUP BY browser
ORDER BY sessions DESC
┌─browser─┬─sessions─┬─sessions_share─┐
│ 7 │ 493225 │ 29.42 │
│ 0 │ 274107 │ 16.35 │
│ 2 │ 236929 │ 14.13 │
│ 3 │ 235439 │ 14.04 │
│ 4 │ 196628 │ 11.73 │
│ 120 │ 154012 │ 9.19 │
│ 50 │ 86381 │ 5.15 │
└─────────┴──────────┴────────────────┘
Пришла время смоделировать эти 2 сценария. Вы можете найти весь код симуляции на GitHub. Самый важный для нас параметр - это настоящий эффект (доля событий, которые поменяли метки или были утеряны). В идеале хотелось бы, чтобы наша метрика расстояния была близка к этому эффекту.
В результате симуляций мы получили 2 графика, показывающих корреляции между настоящим эффектом и метриками расстояния. Каждая точка на графике - это результат одной симуляции.
По графикам довольно очевидно, что L1 норма - это лучший выбор для нашей задачи, поскольку она наиболее близка к линииdistance = share of affected events
. Более того, значения Kullback-Leibler и Jensen-Shannon divergences сильно различаются в зависимости от use case, например, трафик какого браузера перераспределяется.
Очевидно, что divergences не подойдут для мониторинга, потому что нам будет сложно выбрать threshold. Значения метрик сильно зависят от исходного распределения долей. Кроме того, эти метрики сложно интерпретировать, в то время как L1 норма имеет понятный физический смысл и показывает силу аномалии.
Расчет L1 нормы
Мы выбрали метрику, которая будет показывать нам консистентность данных (L1 норма) и теперь осталось только имплементировать ее расчёт в ClickHouse (нашей базе данных).
Мы можем посчитать L1 норму с помощью распространенных и популярных window functions.
with browsers as (
select
UserAgent as param,
multiIf(
toStartOfHour(UTCStartTime) = '2014-03-18 12:00:00', 'previous',
toStartOfHour(UTCStartTime) = '2014-03-18 13:00:00', 'current',
'other'
) as event_time,
sum(Sign) as events
from datasets.visits_v1
where (StartDate = '2014-03-18')
-- filter by partition key is a good practice
and (event_time != 'other')
group by param, event_time)
select
sum(abs_diff)/2 as l1_norm
from
(select
param,
sumIf(share, event_time = 'current') as curr_share,
sumIf(share, event_time = 'previous') as prev_share,
abs(curr_share - prev_share) as abs_diff
from
(select
param,
event_time,
events,
sum(events) over (partition by event_time) as total_events,
events/total_events as share
from browsers)
group by param)
┌─────────────l1_norm─┐
│ 0.01515028932687386 │
└─────────────────────┘
Однако, в ClickHouse есть array functions, которые могут помочь нам посчитать эту метрику "in a ClickHouse way".
В CH долгое время не было поддержки window functions и мы обходились массивами.
with browsers as (
select
UserAgent as param,
multiIf(
toStartOfHour(UTCStartTime) = '2014-03-18 12:00:00', 'previous',
toStartOfHour(UTCStartTime) = '2014-03-18 13:00:00', 'current',
'other'
) as event_time,
sum(Sign) as events
from datasets.visits_v1
where StartDate = '2014-03-18' -- filter by partition key is a good practice
and event_time != 'other'
group by param, event_time
order by event_time, param)
select l1_norm
from
(select
-- aggregating all param values into arrays
groupArrayIf(param, event_time = 'current') as curr_params,
groupArrayIf(param, event_time = 'previous') as prev_params,
-- calculating params that are present in both time periods or only in one of them
arrayIntersect(curr_params, prev_params) as both_params,
arrayFilter(x -> not has(prev_params, x), curr_params) as only_curr_params,
arrayFilter(x -> not has(curr_params, x), prev_params) as only_prev_params,
-- aggregating all events into arrays
groupArrayIf(events, event_time = 'current') as curr_events,
groupArrayIf(events, event_time = 'previous') as prev_events,
-- calculating events shares
arrayMap(x -> x / arraySum(curr_events), curr_events) as curr_events_shares,
arrayMap(x -> x / arraySum(prev_events), prev_events) as prev_events_shares,
-- filtering shares for browsers that are present in both periods
arrayFilter(x, y -> has(both_params, y), curr_events_shares, curr_params) as both_curr_events_shares,
arrayFilter(x, y -> has(both_params, y), prev_events_shares, prev_params) as both_prev_events_shares,
-- filtering shares for browsers that are present only in one of periods
arrayFilter(x, y -> has(only_curr_params, y), curr_events_shares, curr_params) as only_curr_events_shares,
arrayFilter(x, y -> has(only_prev_params, y), prev_events_shares, prev_params) as only_prev_events_shares,
-- calculating the abs differences and l1 norm
arraySum(arrayMap(x, y -> abs(x - y), both_curr_events_shares, both_prev_events_shares)) as both_abs_diff,
1/2*(both_abs_diff + arraySum(only_curr_events_shares) + arraySum(only_prev_events_shares)) as l1_norm
from browsers)
┌─────────────l1_norm─┐
│ 0.01515028932687386 │
└─────────────────────┘
Такой подход может быть более удобен и понятен для людей с pythonic mindset. В целом, я уверена, что с помощью небольшой доли упорства и изобретательности, можно запрограммировать на array functions логику любой сложности.
Alerting & Monitoring
Теперь у нас есть пара запросов, которые показывают флуктуации распределений в наших данных и мы уже почти у цели - это отлично.
На практике прежде чем запустить alerting вам нужно будет еще договориться с командой о thresholds срабатывания. Я обычно смотрю на исторические данные и прошлые известные аномалии и подбираю threshold так, чтобы с одной стороны мы бы узнали о прошлых аномалиях, но с другой стороны - нам бы не приходили alert'ы каждые 5 минут. Обычно tuning thresholds - это непрерывный процесс и имеет смысл учитывать новую информацию и подкручивать лимиты срабатывания.
При имплементации мониторингов на практике я столкнулась с еще несколькими нюансами, которые мне бы хотелось подсветить в двух словах:
Есть параметры, распределения которых нет никакого смысла мониторить, например,
UserID
илиStartDate
, так что выбирайте с умом.В данных могут быть параметры с высокой кардинальностью. Например, параметр
StartURL
в нашем примере имеет 600К различных значений. Расчет L1 нормы для таких случаев может съедать много ресурсов. Так что, я рекомендую понизить кардинальность, например, округлить URL до domain или мониторить только топ-N значений, объединив остальные в группуother
.В целом, вы можете использовать этот подход и для числовых переменных, разбив значения на бакеты.
Бывают случаи, когда изменения в распределениях ожидаемы. Например, если вы мониторите версию приложения, то вы будете получать alert'ы при каждом update. Это может быть довольно удобно, поскольку поможет вам быть уверенным, что мониторинг все еще работает :)
Я надеюсь, эта статья была для вас полезной и сейчас вы знаете как мониторить качество данных или хотя бы как писать сложные запросы с array functions в ClickHouse. Stay tuned!
savostin
Ох уж этот Runglish... Сам такой :(
А за array functions спасибо, да.