ClickHouse應用之留存分析


留存

對於數據分析師和運營人員來說,留存分析這幾個字並不陌生,通過觀察其留存規律,在用戶行為領域,通過數據分析方法的科學應用,經過理論推導,能夠相對完整地揭示用戶行為的內在規律。基於此幫助企業實現多維交叉分析,幫助企業建立快速反應、適應變化的敏捷商業智能決策。最近一直在做關於留存分析項目,下面就我自己的經驗,來談一下如何在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 類型的參數,用來表示事件是否滿足特定條件。任何條件都可以指定為參數(如 WHERE)。除了第一個以外,條件成對適用:如果第一個和第二個是真的,第二個結果將是真的,如果第一個和第三個是真的,第三個結果將是真的等等,這一點和第N天留存的計算邏輯基本一致,當求時間t的次日留存,則需要用戶在t 、t+1 都出現過,兩日留存則是t、t+2均出現過的用戶。

語法:

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,從原始數據我們可以發現330121號有出現過,但是這個地方為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
;

同樣的,來看一下用戶2201stat_time = 2019-06-20號的結果,day_1_un的結果為[1,0],元素1表示用戶在21號有活躍,元素0表示用戶在22號沒有出現過。用戶2201stat_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主要用來求元素之間的交集,對於第N天的留存每次在計算的時候,則需要可以直接將兩天的求交集即可。

語法

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-192019-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

 終於搞完了,期待我的下一篇隨筆吧!!!

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM