RFM用户分层:从静态模型到时间滚动(平移、平滑)式动态模型

RFM动态模型.png

背景:公司某产品线多年合作的个别优质客户已停止合作。具体原因可能技术上的,或是客户那边换了领导,和我们的老板不熟不想合作。亦或者是市场所致,即我们的业务不满足客户们的需求,被细分业务领域更强的单位给代替了。

从历史数据中挖线索不难发现,这些多年合作客户在某一时间节点上,处于一个半月以上未产生业务量,但一年的业务量及产生的金额多。RFM这种多指标综合模型(https://www.jianshu.com/p/666ef32ba938),往往可以根据此类业务特性,达到提前预警流失客户的功能,做到提前预知客户的合作态度,使得后续业务动作变得更为有效。

基本流程与思路:按传统做静态RFM模型,然后以1个月为步长1年回滚数据的筛选方式,去找历史阶段中某一客户在RFM上的用户分层结果,最重要的是它能反映客户"上一期"的变化,有可能从“重要客户”变为“不重要客户”。

读取数据

df = pd.read_csv('RFM建模数据.csv')
df['time'] = pd.to_datetime(df['time'])
df
image.png

首先我们看一下这份数据的范围


image.png

选择2021.5.15-2022.5.15的数据,给数据留出一点空间,为了在数据更新后,能选择2021.6.15-2022.6.15的数据。

df_model = df[(df['time']>='2021-05-15')&(df['time']<'2022-05-16')]

为什么不用小于等于


image.png

如果使用小于等于,时间不会选到5.15

构造R、F、数据更新日期

构造F

max_time = df_model['time'].max()#2022-05-15

(df_model.groupby(['code','cate'])
         .agg(最近诊断时间 = pd.NamedAgg(column='time',aggfunc='max')#以code和cate作为分组,计算time的最大值
             ,F = pd.NamedAgg(column='code',aggfunc='count'))#以code和cate作为分组,计算code的产生的业务量
         .reset_index()
)

image.png

上述写法为pandas链式操作,省去了赋值变量即每一次步骤做一个等号的场景,能够极大提升数据处理的效率。参见:https://zhuanlan.zhihu.com/p/530218705

构造R

RFM_pre = (df_model.groupby(['code','cate'])
   .agg(最近诊断时间 = pd.NamedAgg(column='time',aggfunc='max') #以code和cate作为分组,计算time的最大值
       ,F = pd.NamedAgg(column='code',aggfunc='count'))#以code和cate作为分组,计算code的产生的业务量
   .reset_index()
   .assign(数据更新日期 = max_time
           ,R = lambda x:(x['数据更新日期'] - x['最近诊断时间']).dt.days
          )
)[['code', 'cate', '最近诊断时间', '数据更新日期', 'R','F']]
RFM_pre
image.png

构造M

看一下单价表

weight_M = pd.read_excel(r'金额表.xlsx',sheet_name='Sheet1')
weight_M
image.png

按不同subcate(子类别)去计算产生的总金额

(df_model.groupby(['code','cate','subcate']).code.count().to_frame('次数').reset_index()
         .pipe(lambda x:x.merge(weight_M,how = 'left'))
         .assign(总金额 = lambda x:x['次数']*x['price'])
)
image.png
weight_M = pd.read_excel(r'金额表.xlsx',sheet_name='Sheet1')
M_group = (df_model.groupby(['code','cate','subcate']).code.count().to_frame('次数').reset_index()#按code,cate,subcate计算统计量
    .pipe(lambda x:x.merge(weight_M,how = 'left'))
    .assign(总金额 = lambda x:x['次数']*x['price'])
    .groupby(['code','cate']).总金额.sum().to_frame('总金额').reset_index()#将项目小类做个汇总
).rename(columns={'总金额':'M'})

RFM_result = (RFM_pre
    .merge(M_group,how='left')
    .fillna(0)

)
RFM_result
image.png

看一下相关性系数

image.png

没什么问题,我以为F(频率)、M(金额)的相关性系数较高。如果碰到此种情形,我认为去掉M即可,RFM模型降级成RF模型,维度越低可视化效果越好。

遍历每个大类的RFM结果

需要根据自己的业务逻辑去定阈值,这里设定的阈值是45天未发生业务,一年业务量的中位数,以及一年产生金额的中位数。

LS = []
for cate in RFM_result['cate'].unique():
    RFM = (RFM_result[RFM_result['cate']==cate]
        .assign(R_ = lambda x:np.where(x['R']>45,0,1)#如果超过一个半月未发生业务,则返回0,否则1
                ,F_ = lambda x:np.where(x['F']>x['F'].median(),1,0)#业务量大于中位数的返回1,否则0
                ,M_ = lambda x:np.where(x['M']>x['M'].median(),1,0)#产生金额大于中位数的返回1,否则0
               )
    )
    LS.append(RFM)
RFM_model_res = pd.concat(LS).reset_index(drop=True)
RFM_model_res
image.png

拼接RFM编码

RFM_model_res['RFM'] = RFM_model_res['R_'].astype(str)+RFM_model_res['F_'].astype(str)+RFM_model_res['M_'].astype(str)
RFM_model_res
image.png

给RFM打标签

def transfer_user(x):
    
    if x['RFM']== '000':
        return '可能流失客户'
    elif x['RFM']=='010':
        return '紧急回访客户'
    elif x['RFM']=='100':
        return '一般发展客户'
    elif x['RFM']=='110':
        return '一般价值客户'
    elif x['RFM']=='001':
        return '紧急挽留客户'
    elif x['RFM']=='011':
        return '紧急维护客户'
    elif x['RFM']=='101':
        return '重要发展客户'
    elif x['RFM']=='111':
        return '优质客户'

RFM_model_res['用户分层'] = RFM_model_res.apply(transfer_user,axis=1)
RFM_model_res
image.png

此时,RFM建模完成了。但这只是静态模型,记得一开始选择时间段是2021.5.15-2022.5.15。若倒推"一个月",选择2021.4.15-2022.4.15是不是可以了解这些用户类型是否发生了变化?例如某些客户从"一般发展客户"变成了"紧急维护客户"。

有了这个想法之后,后续的数据处理设计就有了模糊的创造点。先尝试自定义一个函数,使得以上流程可以通过仅改变时间就能返回想要的结果。

动态RFM模型的第一次设计

将上述数据处理流程封装到RFM_Model里面。

def RFM_Model(df,start_time,end_time):
    df_model = df[(df['time']>= start_time)&(df['time']< end_time)]
    max_time = df_model['time'].max()

    RFM_pre = (df_model.groupby(['code','cate'])
       .agg(最近诊断时间 = pd.NamedAgg(column='time',aggfunc='max')
           ,F = pd.NamedAgg(column='code',aggfunc='count'))
       .reset_index()
       .assign(数据更新日期 = max_time
               ,R = lambda x:(x['数据更新日期'] - x['最近诊断时间']).dt.days
              )
       .sort_values(by='最近诊断时间')
    )[['code', 'cate', '最近诊断时间', 
        '数据更新日期', 'R','F'
      ]]
    weight_M = pd.read_excel(r'金额表.xlsx',sheet_name='Sheet1')
    
    M_group = (df_model.groupby(['code','cate','subcate']).code.count().to_frame('次数').reset_index()
        .pipe(lambda x:x.merge(weight_M,how = 'left'))
        .assign(总金额 = lambda x:x['次数']*x['price'])
        .groupby(['code','cate']).总金额.sum().to_frame('总金额').reset_index()
    ).rename(columns={'总金额':'M'})

    RFM_result = (RFM_pre
        .merge(M_group,how='left')
        .fillna(0)
    )
    
    LS = []
    for cate in RFM_result['cate'].unique():
        RFM = (RFM_result[RFM_result['cate']==cate]
            .assign(R_ = lambda x:np.where(x['R']>45,0,1)
                    ,F_ = lambda x:np.where(x['F']>x['F'].median(),1,0)
                    ,M_ = lambda x:np.where(x['M']>x['M'].median(),1,0)
                   )
        )
        LS.append(RFM)
    RFM_model_res = pd.concat(LS).reset_index(drop=True)
    
    RFM_model_res['RFM'] = RFM_model_res['R_'].astype(str)+RFM_model_res['F_'].astype(str)+RFM_model_res['M_'].astype(str)
    
    #import swifter
    def transfer_user(x):
        if x['RFM']== '000':
            return '可能流失客户'
        elif x['RFM']=='010':
            return '紧急回访客户'
        elif x['RFM']=='100':
            return '一般发展客户'
        elif x['RFM']=='110':
            return '一般价值客户'
        elif x['RFM']=='001':
            return '紧急挽留客户'
        elif x['RFM']=='011':
            return '紧急维护客户'
        elif x['RFM']=='101':
            return '重要发展客户'
        elif x['RFM']=='111':
            return '优质客户'

    #RFM_model_res['用户分层'] = RFM_model_res.swifter.apply(transfer_user,axis=1)
    RFM_model_res['用户分层'] = RFM_model_res.apply(transfer_user,axis=1)
    
    return RFM_model_res

调用自定义函数RFM_Model

拼接相邻一个月,不同时间段的RFM

mod1 = RFM_Model(df,'2021-05-15','2022-05-16').assign(类型= '2022-05-15')
mod2 = RFM_Model(df,'2021-04-15','2022-04-16').assign(类型= '2022-04-15')
(pd.concat([mod1,mod2])
    .pipe(lambda x:pd.crosstab([x['code'],x['cate']],x['类型'],values=x['用户分层'],aggfunc=sum)).reset_index()    
).fillna('本期无数据')
image.png

简单查一下变化


image.png

code为794的客户在ECG的大类(产品线)中,从"优质客户"变为了"紧急回访客户",
下一步检查下794客户的RFM明细


image.png

看来逻辑是跑得通的。

动态RFM模型的第二次设计

再次回想我们是怎么选择时间的,开始时间:2021-05-15,2021-04-15....,结束时间:2022-05-15,2021-04-15。现在就是让RFM_Model(df,start_time,end_time)中start_time和end_time参数不断发生改变,最终拿到2020-15至2022-05-15的所有RFM结果。

参数怎么选择

从end_time来看,我们要依次选择2022.5.15,2022.4.15,...2021.5.15

  • 首先:选择2021.5-2022.5的每月结尾时间点
end_time_ls = list(pd.date_range('2021-05-15','2022-06-01',freq='M').astype(str))
end_time_ls
image.png
  • 其次:使用replace函数将尾数替换为15,用列表推导式就能得到这个时间段每月的15号
end_time_rs = [end_time_ls[x].replace(end_time_ls[x][8:10],'15') for x in range(len(end_time_ls))]
end_time_rs
image.png
  • 最后:梳理好逻辑后放到循环里面跑结果,那么模型就会依次输入想要的时间,并输出最终的RFM模型
from tqdm import tqdm
end_time_ls = list(pd.date_range('2021-05-15','2022-06-01',freq='M').astype(str))
end_time_rs = [end_time_ls[x].replace(end_time_ls[x][8:10],'15') for x in range(len(end_time_ls))]
start_time_ls = list(pd.date_range('2020-05-15','2021-06-01',freq='M').astype(str))
start_time_rs = [start_time_ls[x].replace(start_time_ls[x][8:10],'15') for x in range(len(start_time_ls))]

RFM_LS = []
for time_index in tqdm(range(len(end_time_rs))):
    rfm_mod = RFM_Model(df,start_time_rs[time_index],list(pd.to_datetime(end_time_rs)+pd.DateOffset(days=1))[time_index])
    rfm_mod['类型'] = end_time_rs[time_index]
    RFM_LS.append(rfm_mod)
RFM_application = pd.concat(RFM_LS).reset_index(drop=True)
RFM_application
image.png

因为这次的循环带上了自定义模型以及时间运算,因此使用了tqdm工具,它可以看到循环进度,如果运行时间过长那么需要重新设计程序。

这时候我们再去看RFM动态结果

(RFM_application
    .pipe(lambda x:pd.crosstab([x['code'],x['cate']],x['类型'],values=x['用户分层'],aggfunc=sum)).reset_index()
).fillna('本期无数据')
image.png

这时,我们分13次记录了2020.5.15-2022.5.15的用户分层结果,可以看到"可能流失客户"会变成"无数据","优质客户"变成了"紧急维护客户",RFM模型因增加了一个时间维度而动了起来。

PowerBI中的应用效果

将第一次建模结果展示在第一页,给一个页面跳转,去看用户分层的历史变化。


image.png
image.png

总结+闲谈

本次建模还有诸多不足,无法通过PowerBI的DAX函数,对RFM进行复杂操作。如果可以,那么RFM模型的动态效果将十分亮眼。欣赏PowerBI佐罗了作品https://zhuanlan.zhihu.com/p/387233842,实属令人羡慕。佐罗的文章和视频我看了一些,这个人的优点:
1.思维有张力,不仅有着大开大合般的分析思路,而且在技术细节上精益求精,这种能力十分适合做数据分析师。

2.有着一颗让业务员自己设计"模型"的心,曾在视频中谈到工程师最大的问题就是不断地对业务需求,而业务需求又五花八门,难以统一。DAX语言的出现给解决这一痛点带来了希望,使得业务人员自己设计"模型"成为了可能。譬如本案例,如果DAX功力够深,可以设计RFM模型让业务员自己去设置模型里的阈值,去返回不同模型的客户分层结果。那么,业务员的个性化需求就得到了满足。

PowerBI实在太难了,随着它逐渐走进人们的视野,DAX的各种黑箱、玄乎操作,将在数据可视化领域成为人们口中津津乐道的“炼丹术”。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,658评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,482评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,213评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,395评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,487评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,523评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,525评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,300评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,753评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,048评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,223评论 1 343
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,905评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,541评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,168评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,417评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,094评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,088评论 2 352

推荐阅读更多精彩内容