為什么要有路徑分析,舉個最簡單的例子,你的領導想要知道用戶在完成下單前的一個小時都做了什么?絕大多數人拿到這個需求的做法就是進行數據抽樣觀察以及進行一些簡單的問卷調參工作,這種方式不但費時費力還不具有代表性,那么這個時候你就需要一套用戶行為路徑分析的模型作為支撐,才能快速幫組你找到最佳答案
前言
clickhouse是我見過最完美的OLAP數據庫,它不僅將性能發揮到了極致,還在數據分析層面做了大量改進和支撐,為用戶提供了大量的高級聚合函數和基于數組的高階lambda函數。
企業中常用的路徑分析模型一般有兩種:
- 已經明確了要分析的路徑,需要看下這些訪問路徑上的用戶數據:關鍵路徑分析
- 不確定有哪些路徑,但是清楚目標路徑是什么,需要知道用戶在指定時間范圍內都是通過哪些途徑觸達目標路徑的:智能路徑分析
關鍵路徑分析
因為我們接下來要通過sequenceCount完成模型的開發,所以需要先來了解一下該函數的使用:
sequenceCount(pattern)(timestamp, cond1, cond2, ...)
該函數通過pattern指定事件鏈,當用戶行為完全滿足事件鏈的定義是會+1;其中time時間類型或時間戳,單位是秒,如果兩個事件發生在同一秒時,是無法準確區分事件的發生先后關系的,所以會存在一定的誤差。
pattern支持3中匹配模式:
(?N):表示時間序列中的第N個事件,從1開始,最長支持32個條件輸入;如,(?1)對應的是cond1
(?t op secs):插入兩個事件之間,表示它們發生時需要滿足的時間條件(單位為秒),支持 >=, >, <, <= 。例如上述SQL中,(?1)(?t<=15)(?2)即表示事件1和2發生的時間間隔在15秒以內,期間可能會發生若干次非指定事件。
.*:表示任意的非指定事件。
例如,boos要看在會員購買頁超過10分鐘才下單的用戶數據 那么就可以這么寫
SELECT
count(1) AS c1,
sum(cn) AS c2
FROM
(
SELECT
u_i,
sequenceCount('(?1)(?t>600)(?2)')(toDateTime(time), act = '會員購買頁', act = '會員支付成功') AS cn
FROM app.scene_tracker
WHERE day = '2020-09-07'
GROUP BY u_i
)
WHERE cn >= 1
┌──c1─┬──c2─┐
│ 102 │ 109 │
└─────┴─────┘
## c1是滿足條件的用戶數,c2是滿足條件的用戶行為總數
根據上面數據可以看出完成支付之前在會員購買頁停留超過10分鐘的用戶有100多個,那么是什么原因導致用戶遲遲不肯下單,接下來我們就可以使用智能路徑針對這100個用戶展開分析,看看他們在此期間都做了什么。
智能路徑分析
智能路徑分析模型比較復雜,但同時支持的分析需求也會更加復雜,如分析給定期望的路徑終點、途經點和最大事件時間間隔,統計出每條路徑的用戶數,并按照用戶數對路徑進行倒序排列
雖然clickhouse沒有提供現成的分析函數支持到該場景,但是可以通過clickhouse提供的高階數組函數進行曲線救國,大致SQL如下:
方案一
SELECT
result_chain,
uniqCombined(user_id) AS user_count
FROM (
WITH
toDateTime(maxIf(time, act = '會員支付成功')) AS end_event_maxt, #以終點事件時間作為路徑查找結束時間
arrayCompact(arraySort( #對事件按照時間維度排序后進行相鄰去重
x -> x.1,
arrayFilter( #根據end_event_maxt篩選出所有滿足條件的事件 并按照<時間, <事件名, 頁面名>>結構返回
x -> x.1 <= end_event_maxt,
groupArray((toDateTime(time), (act, page_name)))
)
)) AS sorted_events,
arrayEnumerate(sorted_events) AS event_idxs, #或取事件鏈的下標掩碼序列,后面在對事件切割時會用到
arrayFilter( #將目標事件或當前事件與上一個事件間隔10分鐘的數據為切割點
(x, y, z) -> z.1 <= end_event_maxt AND (z.2.1 = '會員支付成功' OR y > 600),
event_idxs,
arrayDifference(sorted_events.1),
sorted_events
) AS gap_idxs,
arrayMap(x -> x + 1, gap_idxs) AS gap_idxs_, #如果不加1的話上一個事件鏈的結尾事件會成為下個事件鏈的開始事件
arrayMap(x -> if(has(gap_idxs_, x), 1, 0), event_idxs) AS gap_masks, #標記切割點
arraySplit((x, y) -> y, sorted_events, gap_masks) AS split_events #把用戶的訪問數據切割成多個事件鏈
SELECT
user_id,
arrayJoin(split_events) AS event_chain_,
arrayCompact(event_chain_.2) AS event_chain, #相鄰去重
hasAll(event_chain, [('pay_button_click', '會員購買頁')]) AS has_midway_hit,
arrayStringConcat(arrayMap(
x -> concat(x.1, '#', x.2),
event_chain
), ' -> ') AS result_chain #用戶訪問路徑字符串
FROM (
SELECT time,act,page_name,u_i as user_id
FROM app.scene_tracker
WHERE toDate(time) >= '2020-09-30' AND toDate(time) <= '2020-10-02'
AND user_id IN (10266,10022,10339,10030) #指定要分析的用戶群
)
GROUP BY user_id
HAVING length(event_chain) > 1
)
WHERE event_chain[length(event_chain)].1 = '會員支付成功' #事件鏈最后一個事件必須是目標事件
AND has_midway_hit = 1 #必須包含途經點
GROUP BY result_chain
ORDER BY user_count DESC LIMIT 20;
實現思路:
將用戶的行為用groupArray函數整理成<時間, <事件名, 頁面名>>的元組,并用arraySort函數按時間升序排序;
利用arrayEnumerate函數獲取原始行為鏈的下標數組;
利用arrayFilter和arrayDifference函數,過濾出原始行為鏈中的分界點下標。分界點的條件是路徑終點或者時間差大于最大間隔;
利用arrayMap和has函數獲取下標數組的掩碼(由0和1組成的序列),用于最終切分,1表示分界點;
調用arraySplit函數將原始行為鏈按分界點切分成單次訪問的行為鏈。注意該函數會將分界點作為新鏈的起始點,所以前面要將分界點的下標加1;
調用arrayJoin和arrayCompact函數將事件鏈的數組打平成多行單列,并去除相鄰重復項。
調用hasAll函數確定是否全部存在指定的途經點。如果要求有任意一個途經點存在即可,就換用hasAny函數。當然,也可以修改WHERE謂詞來排除指定的途經點。
將最終結果整理成可讀的字符串,按行為鏈統計用戶基數,完成。
方案二
不設置途經點,且僅以用戶最后一次到達目標事件作為參考
SELECT
result_chain,
uniqCombined(user_id) AS user_count
FROM (
select
u_i as user_id,
arrayStringConcat( #獲取訪問路徑字符串
arrayCompact( #相鄰事件去重
arrayMap(
b - > tupleElement(b, 1),
arraySort( #對用戶事件進行排序得到用戶日志的先后順序
y - > tupleElement(y, 2),
arrayFilter(
(x, y) - > y - x.2 > 3600 #找到目標節點前1小時內的所有事件
arrayMap(
(x, y) - > (x, y),
groupArray(e_t),
groupArray(time)
),
arrayWithConstant(
length(groupArray(time)),
maxIf(time, e_t = '會員支付成功') #設置目標節點
)
)
)
)
),
'->'
) result_chain
from
bw.scene_tracker
where
toDate(time) >= '2020-09-30' AND toDate(time) <= '2020-10-02' AND user_id IN (10266,10022,10339,10030)
group by
u_i
) tab
GROUP BY result_chain
ORDER BY user_count DESC LIMIT 20;
簡單說一下上面用到的幾個高階函數:
- arrayJoin
可以理解為行轉列操作
SELECT arrayJoin([1, 2, 3, 4]) AS data
┌─data─┐
│ 1 │
│ 2 │
│ 3 │
│ 4 │
└──────┘
- uniqCombined
clickhouse中的高性能去重統計函數,類似count(distinct field),數據量比較小的時候使用數組進行去重,中的數據使用set集合去重,當數據量很大時會使用hyperloglog方式進行j近似去重統計;如果想要精度更改可以使用uniqCombined64支持64位bit
SELECT uniqCombined(data)
FROM
(
SELECT arrayJoin([1, 2, 3, 1, 4, 2]) AS data
)
┌─uniqCombined(data)─┐
│ 4 │
└────────────────────┘
- arrayCompact
對數組中的數據進行相鄰去重,用戶重復操作的事件只記錄一次
SELECT arrayCompact([1, 2, 3, 3, 1, 1, 4, 2]) AS data
┌─data──────────┐
│ [1,2,3,1,4,2] │
└───────────────┘
- arraySort
對數組中的數據按照指定列進行升序排列;降序排列參考arrayReverseSort
SELECT arraySort(x -> (x.1), [(1, 'a'), (4, 'd'), (2, 'b'), (3, 'c')]) AS data
┌─data──────────────────────────────┐
│ [(1,'a'),(2,'b'),(3,'c'),(4,'d')] │
└───────────────────────────────────┘
- arrayFilter
只保留數組中滿足條件的數據
SELECT arrayFilter(x -> (x > 2), [12, 3, 4, 1, 0]) AS data
┌─data─────┐
│ [12,3,4] │
└──────────┘
- groupArray
將分組下的數據聚合到一個數組集合中,類似hive中的collect_list函數
SELECT
a.2,
groupArray(a.1)
FROM
(
SELECT arrayJoin([(1, 'a'), (4, 'a'), (3, 'a'), (2, 'c')]) AS a
)
GROUP BY a.2
┌─tupleElement(a, 2)─┬─groupArray(tupleElement(a, 1))─┐
│ c │ [2] │
│ a │ [1,4,3] │
└────────────────────┴────────────────────────────────┘
- arrayEnumerate
或取數組的下標掩碼序列
SELECT arrayEnumerate([1, 2, 3, 3, 1, 1, 4, 2]) AS data
┌─data──────────────┐
│ [1,2,3,4,5,6,7,8] │
└───────────────────┘
- arrayDifference
參數必須是數值類型;計算數組中相鄰數字的差值,第一個值為0
SELECT arrayDifference([3, 1, 1, 4, 2]) AS data
┌─data──────────┐
│ [0,-2,0,3,-2] │
└───────────────┘
- arrayMap
對數組中的每一列進行處理,并返回長度相同的新數組
SELECT arrayMap(x -> concat(toString(x.1), ':', x.2), [(1, 'a'), (4, 'a'), (3, 'a'), (2, 'c')]) AS data
┌─data──────────────────────┐
│ ['1:a','4:a','3:a','2:c'] │
└───────────────────────────┘
- arraySplit
按照規則對數組進行分割
SELECT arraySplit((x, y) -> y, ['a', 'b', 'c', 'd', 'e'], [1, 0, 0, 1, 0]) AS data
┌─data──────────────────────┐
│ [['a','b','c'],['d','e']] │
└───────────────────────────┘
## 遇到下標為1時進行分割,分割點為下一個 數組的起始點;注意,首項為1還是0不影響結果
- has
判斷數組中是否包含某個數據
SELECT has([1, 2, 3, 4], 2) AS data
┌─data─┐
│ 1 │
└──────┘
- hasAll
判斷數組中是否包含指定子集
SELECT hasAll([1, 2, 3, 4], [4, 2]) AS data
┌─data─┐
│ 1 │
└──────┘
---
SELECT hasAll([1, 2, 3, 4], [0, 2]) AS data
┌─data─┐
│ 0 │
└──────┘
- arrayStringConcat
將數組轉為字符串,需要注意的是,這里的數組項需要是字符串類型
SELECT arrayStringConcat(['a', 'b', 'c'], '->') AS data
┌─data────┐
│ a->b->c │
└─────────┘
- arrayWithConstant
以某個值進行填充生成數組
SELECT arrayWithConstant(4, 'abc') AS data
┌─data──────────────────────┐
│ ['abc','abc','abc','abc'] │
└───────────────────────────┘