一、说明
为了确保数据一切正常,我们需要关注两件事:
- 没有丢失或重复的事件 - >事件和会话数在预期范围内。
- 数据是正确的 - >每个参数的值分布保持不变,另一个版本尚未开始将所有浏览器记录为 Safari 或完全停止跟踪购买。
今天,我想告诉大家我处理这项复杂任务的经历。作为奖励,我将展示 ClickHouse 数组函数的示例。
摄影:Luke Chesser on Unsplash
二、什么是网络分析?
网络分析系统会记录有关网站上事件的大量信息,例如,客户使用的浏览器和操作系统,他们访问了哪些URL,他们在网站上花费了多少时间,甚至他们添加到购物车并购买了哪些产品。所有这些数据都可用于报告(了解有多少客户访问了该网站)或分析(了解痛点并改善客户体验)。您可以在维基百科上找到有关网络分析的更多详细信息。
我们将使用ClickHouse的匿名网络分析数据。描述如何加载它的指南可以在这里找到。
让我们看一下数据。 是会话的唯一标识符,而其他参数是此会话的特征。 看起来像数字变量,但它们是浏览器和操作系统的编码名称。存储这些值(如数字),然后在应用程序级别解码值要高效得多。这种优化非常重要,如果您正在处理大数据,可以为您节省 TB 级。VisitID
UserAgent
OS
SELECTVisitID,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
在ClickHouse引擎中经常使用,因为它允许使用而不是(文档中通常的更多详细信息)。使用这种方法,您可以在更新的情况下每个会话有几行,然后系统在后台将其合并。使用修饰符,我们强制了这个过程。CollapsingMergeTree
inserts
updates
final
我们可以执行两个简单的查询来查看差异。
SELECTuniqExact(VisitID) AS unique_sessions,sum(Sign) AS number_sessions, -- number of sessions after collapsingcount() AS rows
FROM datasets.visits_v1┌─unique_sessions─┬─number_sessions─┬────rows─┐
│ 1676685 │ 1676581 │ 1680609 │
└─────────────────┴─────────────────┴─────────┘SELECTuniqExact(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 │
└─────────────────┴─────────────────┴─────────┘
使用在性能上有其自身的缺点。您可以在文档中找到有关它的更多信息。final
三、如何保证数据质量?
验证没有丢失或重复的事件非常简单。你可以找到很多方法来检测时间序列数据中的异常,从朴素的方法(例如,与前一周相比,事件数在 +20% 或 -20% 以内)到 ML 与 Prophet 或 PyCaret 等库。
数据一致性是一项比较棘手的任务。正如我之前提到的,网络分析服务跟踪有关客户在网站上行为的大量信息。它们记录了数百个参数,我们需要确保所有这些值看起来都有效。
参数可以是数字(持续时间或看到的网页数量)或分类(浏览器或操作系统)。对于数值,我们可以使用统计标准来确保分布保持不变——例如,柯尔莫哥罗夫-斯米尔诺夫检验。
因此,在研究了最佳实践之后,我唯一的问题是如何监控分类变量的一致性,是时候讨论它了。
四、分类变量
让我们以浏览器为例。我们的数据中有 62 个浏览器的唯一值。
SELECT uniqExact(UserAgent) AS unique_browsers
FROM datasets.visits_v1┌─unique_browsers─┐
│ 62 │
└─────────────────┘SELECTUserAgent,count() AS sessions,round((100. * sessions) / (SELECT count()FROM datasets.visits_v1FINAL), 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
因此,我开始考虑一种可以显示分布之间差异的指标。这个想法是比较现在 () 和之前 () 的浏览器份额。我们可以根据粒度选择上一个周期:T2T1
- 对于分钟数据——你可以看上一点,
- 对于每日数据 - 值得查看一周前的一天,以考虑每周的季节性,
- 对于月度数据 - 您可以查看一年前的数据。
让我们看下面的例子。
我的第一个想法是查看类似于机器学习中使用的L1规范的启发式指标(更多详细信息)。
对于上面的例子,这个公式将给我们以下结果 — 10%。实际上,这个指标是有意义的——它显示了浏览器已更改的分发事件中的最小份额。
之后,我和我的老板讨论了这个话题,他在数据科学方面有很多经验。他建议我看看Kullback-Leibler或Jensen-Shannon散度,因为这是计算概率分布之间距离的更有效的方法。
如果您不记得这些指标或以前从未听说过它们,请不要担心,我站在你的立场上。所以我用谷歌搜索了公式(本文彻底解释了这些概念)和我们示例的计算值。
import numpy as npprev = 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)/2return 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
如您所见,我们计算的距离差异很大。所以现在我们(至少)有三种方法来计算之前和现在浏览器份额之间的差异,下一个问题是为我们的监控任务选择哪种方式。
五、获胜者是...
估计不同方法性能的最佳方法是查看它们在现实生活中的表现。为此,我们可以模拟数据中的异常并比较效果。
数据中有两种常见的异常情况:
- 数据丢失:我们开始丢失来自其中一个浏览器的数据,并且所有其他浏览器的份额都在增加
- 更改:当来自一个浏览器的流量开始标记为另一个浏览器时。例如,我们现在看到的 10% 的 Safari 事件是未定义的。
我们可以获取实际的浏览器共享并模拟这些异常。为简单起见,我将把所有份额低于 5% 的浏览器分组到组中。browser = 0
WITH browsers AS(SELECTUserAgent,count() AS raw_sessions,(100. * count()) / (SELECT count()FROM datasets.visits_v1FINAL) AS raw_sessions_shareFROM datasets.visits_v1FINALGROUP BY 1)
SELECTif(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 │
└─────────┴──────────┴────────────────┘
是时候模拟这两种情况了。您可以在 GitHub 上找到所有代码。对我们来说,最重要的参数是实际效果——丢失或改变的事件份额。理想情况下,我们希望我们的指标等于这种效果。
作为模拟的结果,我们得到了两个图表,显示了事实效应和距离指标之间的相关性。
图表中的每个点都显示一个模拟的结果 — 实际效果和相应的距离。
您可以很容易地看到 L1 范数是我们任务的最佳指标,因为它最接近线。Kullback-Leibler和Jensen-Shannon的分歧很大,并且根据用例(哪个浏览器正在失去流量)具有不同的级别。distance = share of affected events
此类指标不适合监控,因为您将无法指定一个阈值,以便在超过 5% 的流量受到影响时向您发出警报。此外,我们无法轻松解释这些指标,而 L1 范数准确地显示了异常的程度。
六、L1范数计算
现在我们知道什么指标将向我们显示数据的一致性,剩下的最后一个任务是在数据库中实现 L1 范数计算(在我们的例子中是 — ClickHouse)。
我们可以为它使用广为人知的窗口函数。
with browsers as (selectUserAgent 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 eventsfrom datasets.visits_v1where (StartDate = '2014-03-18')-- filter by partition key is a good practiceand (event_time != 'other')group by param, event_time)
selectsum(abs_diff)/2 as l1_norm
from(selectparam,sumIf(share, event_time = 'current') as curr_share,sumIf(share, event_time = 'previous') as prev_share,abs(curr_share - prev_share) as abs_difffrom(selectparam,event_time,events,sum(events) over (partition by event_time) as total_events,events/total_events as sharefrom browsers)group by param)┌─────────────l1_norm─┐
│ 0.01515028932687386 │
└─────────────────────┘
ClickHouse有非常强大的数组函数,在支持窗口函数之前,我已经使用了很长时间。所以我想向你展示这个工具的强大功能。
with browsers as (selectUserAgent 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 eventsfrom datasets.visits_v1where StartDate = '2014-03-18' -- filter by partition key is a good practiceand event_time != 'other'group by param, event_timeorder by event_time, param)
select l1_norm
from(select-- aggregating all param values into arraysgroupArrayIf(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 themarrayIntersect(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 arraysgroupArrayIf(events, event_time = 'current') as curr_events,groupArrayIf(events, event_time = 'previous') as prev_events,-- calculating events sharesarrayMap(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 periodsarrayFilter(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 periodsarrayFilter(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 normarraySum(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_normfrom browsers)┌─────────────l1_norm─┐
│ 0.01515028932687386 │
└─────────────────────┘
这种方法对于具有pythonic思维的人来说可能很方便。凭借持久性和创造力,可以使用数组函数编写任何逻辑。
七、警报和监控
我们有两个查询,向我们显示浏览器在我们数据中的份额波动。可以使用此方法监视感兴趣的数据。
剩下的唯一一点就是在警报阈值上与团队保持一致。我通常会查看历史数据和以前的异常情况,以获取一些初始级别,然后使用新信息不断调整它们:误报警报或错过的异常。
此外,在实施监控的过程中,我遇到了一些细微差别,我想简要介绍一下:
- 例如,数据中存在在监视中没有意义的参数,或者 ,因此请明智地选择要包含的参数。
UserID
StartDate
- 您可能具有高基数的参数。例如,在 Web 分析中,数据具有超过 600K 个唯一值。为其计算指标可能会消耗资源。因此,我建议要么将这些值(例如,采用域或 TLD)存储,要么仅监控顶级值并将其他值分组到单独的组“其他”中。
StartURL
- 您可以使用存储桶对数值使用相同的框架。
- 在某些情况下,预计数据会发生重大变化。例如,如果您正在监视应用程序版本字段,则在每个版本发布后都会收到警报。此类事件有助于确保您的监控仍在:)