留存
對於數據分析師和運營人員來說,留存分析這幾個字並不陌生,通過觀察其留存規律,在用戶行為領域,通過數據分析方法的科學應用,經過理論推導,能夠相對完整地揭示用戶行為的內在規律。基於此幫助企業實現多維交叉分析,幫助企業建立快速反應、適應變化的敏捷商業智能決策。最近一直在做關於留存分析項目,下面就我自己的經驗,來談一下如何在hive和clickhouse中計算留存,着重突出在clickhouse中留存的計算方式,在這之前先回顧一下留存的基本定義。
留存分析是一種用來分析用戶參與情況/活躍程度的分析模型,考察進行初始行為的用戶中,有多少人會進行后續行為。這是用來衡量產品對用戶價值高低的重要方法。
留存分析可以幫助回答以下問題:
-
一個新客戶在未來的一段時間內是否完成了您期許用戶完成的行為?如支付訂單等;
-
某個社交產品改進了新注冊用戶的引導流程,期待改善用戶注冊后的參與程度,如何驗證?
-
想判斷某項產品改動是否奏效,如新增了一個邀請好友的功能,觀察是否有人因新增功能而多使用產品幾個月。
通過觀察一段時間內留存規律,進而獲取這一段時間內進行了留存事件的用戶,來對這些留存人群進行一定的營銷策略。
通過我們在求留存時,一般只求次日留存,三日留存,7日留存,15日留存,30留存,次日留存即是指在某一天活躍(或注冊)的用戶在活躍日(或注冊日)的第二天仍然有活躍,這一部分留下的用戶即是次日留存用戶群體,次日留存用戶群體的基數即為次日留存數。同樣的三日、五日、7日、15日、30日都是同樣的含義。
一般來講,我們所說的N日留存即第N天仍活躍的用戶,但是其他兩種概念的留存,即連續N天內留存和N天內留存,連續N天內留存即連續N天內均有活躍的用戶,N天內即指N天內活躍過的用戶,前者的留存數會越來越小,后者會越來越大,用戶最多的則是第N天活躍的用戶。按照不同的應用場景來選擇不同的留存類型。
計算留存
從上面留存的定義來看,在計算留存的時候需要將當天的數據和之前的數據關聯起來,例要求2021-07-30的三日留存,即需要將7.30號的數據和8.2號的數據關聯一起,正常來說利用如下sql來求解。通過inner join
將兩天的用戶關聯起來,這些雖然也可以求出來,但是這樣有個弊端就是如果是要一次性求次日、3日、5日、7日、15日、30日留存,那得join
到什么時候去,而且這樣的sql性能上面巨差。因此不采用這種寫法。
select t1.uid from origin_table as t1 inner join origin_table as t2 on t1.uid = t2.uid where t1.order_date = '2021-08-02' and t2.order_date = '2021-07-30'
借助dateDiff來求第N天留存
鑒於上面的情況,我們采用新的方法來求留存,這種留存計算方法主要是通過求取用戶之前出現的日期與之后出現日期的差值,則可以判斷該用戶是否為某天的N日留存,若某用戶在7.1號出現,此使該用戶則是7.1號的活躍用戶,若該用戶7.2號又出現了,則此時前后兩次出現的日期差為1天,則可以確定為該用戶為7.1號的留存用戶,若該用戶在7.3號又出現了,則該用戶為7.1的2日留存用戶,下面利用具體的實例來進行說明介紹,以clickhouse數據庫舉例說明,在clickhouse中建如下表:
CREATE TABLE login_log ( `uid` Int32, `login_time` DateTime ) ENGINE = MergeTree PARTITION BY uid ORDER BY uid ;
往上面表中塞入一些數據,如下所示,4個用戶的登錄活躍情況。
要計算兩個日期的datediff
,我們首先login_log表進行子連接,其中一張作為"初始表",一張作為"留存表",通過子連接,則可以將該用戶前后出現的日期連接起來,具體邏輯如下:
select a.uid, a.log_date t1, b.log_date t2 from ( SELECT distinct uid, toDate(login_time) as log_date from login_log ) a inner join ( SELECT distinct uid, toDate(login_time) as log_date from login_log ) b on a.uid = b.uid where a.log_date <= b.log_date
就拿其中一個用戶來說,如下圖所示,用戶2201,在6.20、6.21號出現過,t1
列表示2201用戶活躍的時間,t2
表示該用戶在t1
活躍之后出現過的日期。
有了上述兩列,下面則利用上面的結果求日期差:
select *, dateDiff('day',tab1.t1,tab1.t2) as diff from ( select a.uid, a.log_date t1, b.log_date t2 from ( SELECT distinct uid, toDate(login_time) as log_date from login_log ) a inner join ( SELECT distinct uid, toDate(login_time) as log_date from login_log ) b on a.uid = b.uid where a.log_date <= b.log_date ) tab1 ;
按照之前所說的,日期差=0,表示用戶2201是6.20號和6.21號的當天活躍用戶,日期=1表示2201用戶是6.20號的次日留存用戶。基於對於diff
的分類,則可求出每一天的當天活躍用戶以及第N日留存用戶數。
select tab2.t1, count(distinct case when diff = 0 then tab2.uid else null end) as activity_cnt, count(distinct case when diff = 1 then tab2.uid else null end) as after_uid_cnt_1, count(distinct case when diff = 3 then tab2.uid else null end) as after_uid_cnt_3, count(distinct case when diff = 5 then tab2.uid else null end) as after_uid_cnt_5, count(distinct case when diff = 7 then tab2.uid else null end) as after_uid_cnt_7, count(distinct case when diff = 15 then tab2.uid else null end) as after_uid_cnt_15, count(distinct case when diff = 30 then tab2.uid else null end) as after_uid_cnt_30 from ( select *, dateDiff('day', tab1.t1, tab1.t2) as diff -- 這個地方求日期差的寫法是clickhouse中的,hive中聯合這三個函數from_unixtime,unix_timestamp,datediff進行計算 from ( select a.uid, a.log_date t1, b.log_date t2 from ( SELECT distinct uid, toDate(login_time) as log_date from login_log ) a inner join ( SELECT distinct uid, toDate(login_time) as log_date from login_log ) b on a.uid = b.uid where a.log_date <= b.log_date ) tab1 ) tab2 group by tab2.t1 ;
通過上面邏輯,可以得到如下留存結果,從下圖中,可以得知20號當天的活躍用戶數為3,核對原始數據(圖1)可以發現,6.20當天的活躍用戶數為3分別是1101、2201、4401
,其次日留存即在6.20號和6.21號均活躍的用戶,通過核對發現只有兩個用戶其是1101 、2201
,通過比對其他日期可以發現,這種算法是正確的。而且利用datediff
這樣求留存要比最上面所說的left join
要快速有效。
上面這種計算的思路,只要是個數據庫均可以實現,要比動輒就要left join
好多次的邏輯要快很多,但是我們還需要注意的是,此使還是left join
了一次,多於大表來說還是比較影響性能的。但是對於接觸過clickhouse
的小伙伴來說,應該知道clickhouse
支持復雜的數據類型,比如array、bitmap
等,因此利用clickhouse
的這些特有函數可以提升查詢性能,而且本身clickhouse
就是列式存儲的其查詢的性能要比其他數據庫要快很多。下面就clickhouse
自帶的一些函數來計算留存。
利用Retention
函數
retention
該函數將一組條件作為參數,類型為1到32個 UInt8
類型的參數,用來表示事件是否滿足特定條件。任何條件都可以指定為參數(如
語法:
retention(cond1, cond2, ..., cond32);
繼續利用上面的例子來談談retention
的應用,利用retention
來求2019-06-20
號的當天活躍用戶、次日留存。首先,利用retention
函數進行日期判斷,要求2019-06-20
號的當天活躍用戶以及次日留存數,則對於用戶判斷其活躍日期是否等於2029-06-20
,活躍日期+1是否等於2019-06-20
SELECT uid, retention( toDate(login_time) = '2019-06-20', toDate(login_time) = '2019-06-21' ) as arr FROM login_log GROUP BY uid ;
通過上面sql可以查到如下結果,arr是一個數組,其第一位表示,該用戶是否在2019-06-20
號活躍,1表示活躍過,0表示未活躍,第二位表示,該用戶是否在2019-06-21
號活躍過,從該結果可以發現在2019-06-20號出現過的用戶有1101、2201、4401
三個用戶,在2019-06-21
號出現過的用戶為1101、2201
,從原始數據我們可以發現3301
在21
號有出現過,但是這個地方為0,其主要是3301
用戶並未在20
號出現過,第一個條件非真,因此第二位也非真,因此此處為0。
繼續上面的結果來求留存,
SELECT sum(arr[1]) as active_user, sum(arr[2]) as day_after_1 FROM ( SELECT uid, retention( toDate(login_time) = '2019-06-20', toDate(login_time) = '2019-06-21' ) as arr FROM login_log GROUP BY uid ) a ;
可以發現結果利用datediff求的一致
,但是這樣相比較上面的一個缺點是不能夠把每一天的留存結果一次性得到(也可能是我沒找到方法😂),如果計算留存的時候還有一些條件,在retention
的每一位添加即可。
利用retention來求留存的方法,其說到底是利用了clickhouse
中數據這一結構,因此,靈活的應用這一數據結構。
利用array
系列函數求留存
利用arrayMap
Step1: 獲取每一個用戶出現的日期
SELECT distinct uid, groupArray(distinct toDate(login_time)) AS arr FROM login_log group by uid ;
這一步出現的結果就是圖1,此使再不贅述。
Step2: arrayMap判斷日期
語法
arrayMap(fun,arr)
arrayMap
對數組arr
中的每一個元素應用fun
操作,例arrayMap(x-> x *10,arr)
即使對數組arr
中的每一個元素乘以10,其和python
中的map
是一個性質。利用arrayMap
來判斷該用戶是否在相應的日期活躍。
select uid, stat_time, arrayMap(x -> x = addDays(stat_time, 1), arr) as day_1_un, arrayMap(x -> x = addDays(stat_time, 3), arr) as day_3_un, arrayMap(x -> x = addDays(stat_time, 5), arr) as day_5_un, arrayMap(x -> x = addDays(stat_time, 7), arr) as day_7_un, arrayMap(x -> x = addDays(stat_time, 15), arr) as day_15_un, arrayMap(x -> x = addDays(stat_time, 30), arr) as day_30_un from ( SELECT distinct uid, groupArray(distinct toDate(login_time)) AS arr FROM login_log group by uid ) t1 array join arr as stat_time group by uid, stat_time, arr order by uid ;
同樣的,來看一下用戶2201
在stat_time = 2019-06-20
號的結果,day_1_un
的結果為[1,0],元素1表示用戶在21號有活躍,元素0表示用戶在22號沒有出現過。用戶2201
在stat_time=2019-06-21
號的結果,day_1_un
的結果為[0,0],第一個元素0表示該用戶在22號沒有出現過,第二個0表示該用戶在23號也未出現過。總感覺這個地方多計算了一些東西😂,但是不重要,最終結果是對的。
Step3: 結果匯總
最終按照stat_time
進行GROUP BY
, 要求次日留存,則是sumday_1_un
中元素1的個數即可,其他第N天留存同理。通過下圖可以發現和之前的結果是一致的。
select stat_time, uniq(uid) as day_0, uniq(case when has(day_1_un, 1) then uid end) as day_1, uniq(case when has(day_3_un, 1) then uid end) as day_3, uniq(case when has(day_5_un, 1) then uid end) as day_5, uniq(case when has(day_7_un, 1) then uid end) as day_7, uniq(case when has(day_15_un, 1) then uid end) as day_15, uniq(case when has(day_30_un, 1) then uid end) as day_30 from ( select uid, stat_time, arrayMap(x -> x = addDays(stat_time, 1), arr) as day_1_un, arrayMap(x -> x = addDays(stat_time, 3), arr) as day_3_un, arrayMap(x -> x = addDays(stat_time, 5), arr) as day_5_un, arrayMap(x -> x = addDays(stat_time, 7), arr) as day_7_un, arrayMap(x -> x = addDays(stat_time, 15), arr) as day_15_un, arrayMap(x -> x = addDays(stat_time, 30), arr) as day_30_un from ( SELECT distinct uid, groupArray(toDate(login_time)) AS arr FROM login_log group by uid ) t1 array join arr as stat_time group by uid, stat_time, arr ) p1 group by stat_time order by stat_time ;
利用arrayIntersect
arrayIntersect
語法
arrayIntersect(array1,array2,array3,....)
-- 用來求多個數組之間的交集
求第N天留存
同樣,這里地方,我們利用arrayIntersect
來求2019-06-20
的次日留存,邏輯如下:
with source as ( select toDate(login_time) as stat_time, groupArray(distinct uid) as user_arr from login_log group by stat_time ) select arrayIntersect( (select user_arr from source where stat_time = '2019-06-20'), (select user_arr from source where stat_time = '2019-06-21')) as day_1_un
如圖所示得到了06-20
號的次日留存用戶為1101、2201
,這個結果和之前其他方法求的是統一結果。最終對於arrayIntersect
應用length
即可得到相應的留存數。利用arrayIntersect
求多天的留存則需要不斷地union
。不過也可以參考一下,有的時候還是有用的。
求連續N天留存
連續N天留存和第N天不一樣的點是,只需求連續N天的用戶的交集即可。同樣針對上面的例子,來求2019-06-19
和2019-06-20
號的連續2天留存,連續1天留存
with source as ( select toDate(login_time) as stat_time, groupArray(distinct uid) as user_arr from login_log group by stat_time ) select arrayIntersect( (select user_arr from source where stat_time = '2019-06-19'), (select user_arr from source where stat_time = '2019-06-19')) as day_0_un, arrayIntersect( (select user_arr from source where stat_time = '2019-06-19'), (select user_arr from source where stat_time = '2019-06-20')) as day_1_un, arrayIntersect( (select user_arr from source where stat_time = '2019-06-19'), (select user_arr from source where stat_time = '2019-06-20'), (select user_arr from source where stat_time = '2019-06-21')) as day_2_un
按照上述邏輯求得如下結果,day_0_un
表示所求日期當天活躍用戶,day_1_un
為連續留存1天用戶,day_2_un
為連續留存2天用戶。下圖所示的兩張結果圖中,第一張為19
號的連續2天的留存結果,第二為20
號的留存結果。從下圖的結果可以看到留存結果越來越小。
N天內留存
N天內留存,即N天之內的活躍用戶都算在內。示例如下:
with source as ( select toDate(login_time) as stat_time, groupArray(distinct uid) as user_arr from login_log group by stat_time ) select arrayIntersect( (select user_arr from source where stat_time = '2019-06-19'), (select user_arr from source where stat_time = '2019-06-19')) as day_0_un, arrayIntersect(arrayConcat( (select user_arr from source where stat_time = '2019-06-19'), (select user_arr from source where stat_time = '2019-06-20'))) as day_1_un, arrayIntersect(arrayConcat( arrayConcat( (select user_arr from source where stat_time = '2019-06-19'), (select user_arr from source where stat_time = '2019-06-20'), arrayConcat( (select user_arr from source where stat_time = '2019-06-19'), (select user_arr from source where stat_time = '2019-06-21'))))) as day_2_un
終於搞完了,期待我的下一篇隨筆吧!!!