案例(一) 利用RFM模型做用户价值分析

RFM模型

同步更新在个人网站:http://www.wangpengcufe.com/machinelearning/python-python1/

一、案例背景

在产品迭代过程中,通常需要根据用户的属性进行归类,也就是通过分析数据,对用户进行归类,以便于在推送及转化过程中获得更大的收益。

本案例是基于某互联网公司的实际用户购票数据为研究对象,对用户购票的时间,购买的金额进行了采集,每个用户用手机号来区别唯一性。数据分析人员根据用户购买的时间和金额,通过建立RFM模型,来计算出用户最近最近一次购买的打分,用户购买频率的打分,用户购买金额的打分,然后根据三个分数进行一个加权打分,和综合打分。业务人员可以根据用户的打分情况,对不同的用户进行个性化营销和精准营销,例如给不同的用户推送定制的营销短信,不同优惠额度的打折券等等。

通过RFM方法,可以根据用户的属性数据分析,对用户进行了归类。在推送、转化等很多过程中,可以更加精准化,不至于出现用户反感的情景,更重要的是,对产品转化等商业价值也有很大的帮助。

二、RFM概念

RFM模型是衡量客户价值和客户创利能力的重要工具和手段。在众多的客户关系管理(CRM)的分析模式中,RFM模型是被广泛提到的。该机械模型通过一个客户的近期购买行为、购买的总体频率以及花了多少钱3项指标来描述该客户的价值状况。

RFM分析 就是根据客户活跃程度和交易金额的贡献,进行客户价值细分的一种方法。其中:

R(Recency):客户最近一次交易时间的间隔。R值越大,表示客户交易发生的日期越久,反之则表示客户交易发生的日期越近。

F(Frequency):客户在最近一段时间内交易的次数。F值越大,表示客户交易越频繁,反之则表示客户交易不够活跃。

M(Monetary):客户在最近一段时间内交易的金额。M值越大,表示客户价值越高,反之则表示客户价值越低。

客户价值

R打分:基于最近一次交易日期计算的得分,距离当前日期越近,得分越高。例如5分制。

F打分:基于交易频率计算的得分,交易频率越高,得分越高。如5分制。

M打分:基于交易金额计算的得分,交易金额越高,得分越高。如5分制。

RFM总分值:RFM=Rx100+Fx10+Mx1

RFM分析的主要作用:

  • 识别优质客户。可以指定个性化的沟通和营销服务,为更多的营销决策提供有力支持。

  • 能够衡量客户价值和客户利润创收能力。

三、代码实现

3.1、引包

首先我们引入需要用的包,数据分析常用的numpy包,pandas包,等。

import time
import numpy as np
import pandas as pd
import mysql.connector

3.2、读取数据

接下来我们开始用pd.read_csv方法读取用户的数据

print(time.strftime('%Y-%m-%d %H:%M:%S',time.localtime(time.time()))+':读取数据...')

config = {
    'host' : '127.0.0.1',
    'user' : 'root',
    'password' : 'test123',
    'port' : 3306,
    'database' : 'user',
    'charset' : 'gb2312'
}
cnn = mysql.connector.connect(**config) # 建立MySQL连接
cursor = cnn.cursor() # 获得游标
sql = "SELECT  phoneNo AS PHONENO,create_date AS ORDERDATE,order_no AS ORDERNO,ROUND(pay_amount/100,2) AS PAYAMOUNT " \
      "FROM user.`event_record_order`" # SQL语句
raw_data = pd.read_sql(sql,cnn,index_col='PHONENO')
cursor.close() # 关闭游标
cnn.close() # 关闭连接
print(time.strftime('%Y-%m-%d %H:%M:%S',time.localtime(time.time()))+':读取数据完毕!')
print(time.strftime('%Y-%m-%d %H:%M:%S',time.localtime(time.time()))+':开始建立RFM模型...')

介绍一下config 里的参数信息:host是数据库的ip信息,本案例用的是本地数据库,实际部署生产服务器时,改成生产的ip地址即可。user 是数据库的用户名,password是密码,port是数据库的端口号,database是连接的数据库名 (schema),charset是字符集编码。

购票时间(ORDERDATE),订单号(ORDERID)是object类型,订单金额(AMOUNTINFO)是浮点类型。index_col指定了数据中用户的唯一性用 USERID来表示。

time.strftime('%Y-%m-%d %H:%M:%S',time.localtime(time.time())打印了当前的系统时间,用来记录日志信息。

3.3、数据审查

print('Data Overview :')
print(raw_data.head(4)) #打印原始数据前4条
print('-' * 30)
print('Data DESC:')
print(raw_data.describe())  #打印原始数据基本描述性信息

我们用raw_data.head(n)来指定取出数据的前几条,'-'*30是用来输出打印分隔线,下文再出现时不再重复解释,用raw_data.describe()来获得数据的基本描述性信息。输出结果:

Data Overview:
                      ORDERDATE               ORDERNO  PAYAMOUNT
PHONENO                                                         
135****0930 2019-10-02 13:37:36  01201910021336227979        7.0
183****1153 2019-09-30 06:22:29  0120190930062149F9AF        4.5
150****6073 2019-10-30 18:21:45  01201910301821065CFD        2.0
173****7295 2019-10-21 15:13:23  01201910211512498153        7.0
------------------------------
Data DESC:
          PAYAMOUNT
count  96323.000000
mean       4.212409
std        3.049499
min        0.000000
25%        2.600000
50%        3.600000
75%        5.000000
max       80.000000

我们看到结果中的 count表示总共的记录条数,mean表示了均值,std表示标准差,min表示最小值,25%表示下四分位,也叫第一四分位,50%表示中位值,也叫第二四分位,75%表示上四分位,也叫第三四分位。

na_cols = raw_data.isnull().any(axis=0) #查看每一列是否具有缺失值
print('NA Cols:')
print(na_cols)
print('-' * 30)
na_lines = raw_data.isnull().any(axis=1) #查看每一行是否具有缺失值
print('NA Records:')
print('Total number of NA lines is :{0}'.format(na_lines.sum()))  #查看具有缺失值的行总记录数
print(raw_data[na_lines])  #只查看具有缺失值的行信息

我们用raw_data.isnull()来判断是否有缺失值,其中参数axis=0表示的是列,axis=1表示的是行,用:{0}'.format()的方式在字符串中传入参数。输出结果:

NA Cols:
ORDERDATE    False
ORDERNO      False
PAYAMOUNT    False
dtype: bool
------------------------------
NA Records:
Total number of NA lines is :0
Empty DataFrame
Columns: [ORDERDATE, ORDERNO, PAYAMOUNT]
Index: []

通过结果可以看到,实际的交易用户数据还是比较完整的,没有缺失数据的情况,可能这批数据被技术人员采集过来已经处理过了,不讨论了。如果数据有缺失的情况怎么办?那就要对缺失的数据进行一个预处理。

3.4、数据预处理

数据预处理,包括数据异常,格式转换,单位转化(如果有单位不统一的情况)等。

我们先来看异常值处理:

sales_data = raw_data.dropna() #丢弃带有缺失值的行记录
sales_data = sales_data[sales_data['PAYAMOUNT'] > 1]

这里,我用代码去除了小于1元的订单,正常出行连1块钱都不用,那应该是测试数据了,现在谁出门做个公交还不得1元起步。对于用户有缺失值的记录进行了丢弃,当然也可以用其他的方法,例如平均值补全法。

然后看日期格式转换:

sales_data['ORDERDATE'] = pd.to_datetime(sales_data['ORDERDATE'])
print('Raw Dtype:')
print(sales_data.dtypes)

用pd.to_datetime()方法对用户的订单日期进行了格式化转换。输出结果:

Raw Dtype:
ORDERDATE    datetime64[ns]
ORDERNO              object
PAYAMOUNT           float64
dtype: object

最后看数据转换:

recency_value = sales_data['ORDERDATE'].groupby(sales_data.index).max()  #计算原始最近一次购买时间
frequency_value = sales_data['ORDERDATE'].groupby(sales_data.index).count()    #计算原始订单数
monetray_value = sales_data['PAYAMOUNT'].groupby(sales_data.index).sum()  #计算原始订单总金额

这里根据订单日期的聚合运算得到了用户的最近一次购买时间,用户总的购买数,和购买金额,max()得到了购买时间,count()得到了购买数量,sum()得到了购买金额。

3.5、计算RFM得分

得到了最近的购买时间,购买数,和购买金额,下面就可以开始计算RFM得分了。

deadline_date = pd.datetime(2019,11,15)
r_interval = (deadline_date - recency_value).dt.days
r_score = pd.cut(r_interval,5,labels=[5,4,3,2,1])
f_score = pd.cut(frequency_value,5,labels=[1,2,3,4,5])
m_score = pd.cut(monetray_value,5,labels=[1,2,3,4,5])

我们又把客户分成五等分,这个五等分分析相当于是一个“忠诚度的阶梯”(loyalty ladder),如购买一次的客户为新客户,购买两次的客户为潜力客户,购买三次的客户为老客户,购买四次的客户为成熟客户,购买五次及以上则为忠实客户。其诀窍在于让消费者一直顺着阶梯往上爬,把销售想象成是要将两次购买的顾客往上推成三次购买的顾客,把一次购买者变成两次的。

我们用deadline_date来表示分析的截止日期,那么统计用户的时间范围就是从数据中最早开始的购买时间到deadline_date。

用pandas.series.dt.days可以对操作后的datatime直接进行取数。pandas.cut用来把一组数据分割成离散的区间。

简单介绍一下pandas.cut的用法:

pandas.cut(x, bins, right=True, labels=None, retbins=False, precision=3, include_lowest=False, duplicates='raise')
  • x:被切分的类数组(array-like)数据,必须是1维的(不能用DataFrame);
  • bins:bins是被切割后的区间(或者叫“桶”、“箱”、“面元”),有3中形式:一个int型的标量、标量序列(数组)或者pandas.IntervalIndex 。
    • 一个int型的标量,当bins为一个int型的标量时,代表将x平分成bins份。x的范围在每侧扩展0.1%,以包括x的最大值和最小值。
    • 标量序列,标量序列定义了被分割后每一个bin的区间边缘,此时x没有扩展。
    • pandas.IntervalIndex,定义要使用的精确区间。
  • right:bool型参数,默认为True,表示是否包含区间右部。比如如果bins=[1,2,3],right=True,则区间为(1,2],(2,3];right=False,则区间为(1,2),(2,3)。
  • labels:给分割后的bins打标签,比如把年龄x分割成年龄段bins后,可以给年龄段打上诸如青年、中年的标签。labels的长度必须和划分后的区间长度相等,比如bins=[1,2,3],划分后有2个区间(1,2],(2,3],则labels的长度必须为2。如果指定labels=False,则返回x中的数据在第几个bin中(从0开始)。
  • retbins:bool型的参数,表示是否将分割后的bins返回,当bins为一个int型的标量时比较有用,这样可以得到划分后的区间,默认为False。
  • precision:保留区间小数点的位数,默认为3.
  • include_lowest:bool型的参数,表示区间的左边是开还是闭的,默认为false,也就是不包含区间左部(闭)。
  • duplicates:是否允许重复区间。有两种选择:raise:不允许,drop:允许。

重点理解我标粗的几个参数,其他参数有需要用到时查阅。

RFM数据合并

rfm_list = [r_score,f_score,m_score]  #将r、f、m三个维度组成列表
rfm_cols = ['r_score','f_score','m_score'] #设置r、f、m 三个维度列名
rfm_pd = pd.DataFrame(np.array(rfm_list).transpose(),dtype=np.int32,columns=rfm_cols,index=frequency_value.index) #建立r、f、m数据框

我们把RFM的数据进行了合并,首先是将r、f、m三个维度组成一个列表,然后取了三个列名,把数据,列名组装成一个数据框DataFrame.

print('RFM Score Overview:')
print(rfm_pd.head(4))

输出结果:

RFM Score Overview:
             r_score  f_score  m_score
PHONENO                               
13001055088        4        1        1
13001061903        4        1        1
13001066446        5        1        1
13001123218        4        1        1
rfm_pd['rfm_wscore'] = rfm_pd['r_score'] * 0.6 + rfm_pd['f_score'] * 0.3 + rfm_pd['m_score'] * 0.1
rfm_pd_tmp = rfm_pd.copy()
rfm_pd_tmp['r_score'] = rfm_pd_tmp['r_score'].astype('str')
rfm_pd_tmp['f_score'] = rfm_pd_tmp['f_score'].astype('str')
rfm_pd_tmp['m_score'] = rfm_pd_tmp['m_score'].astype('str')
rfm_pd['rfm_comb'] = rfm_pd_tmp['r_score'].str.cat(rfm_pd_tmp['f_score']).str.cat(rfm_pd_tmp['m_score'])

理论上,上一次消费时间越近的顾客应该是比较好的顾客,对提供即时的商品或是服务也最有可能会有反应。营销人员若想业绩有所成长,只能靠偷取竞争对手的市场占有率,而如果要密切地注意消费者的购买行为,那么最近的一次消费就是营销人员第一个要利用的工具。历史显示,如果我们能让消费者购买,他们就会持续购买。这也就是为什么,0至3个月的顾客收到营销人员的沟通信息多于3至6个月的顾客。

这里,对RFM进行了加权打分,R占60%,F占30%,M占10%,当然也可以根据业务的实际情况进行相应的权重调整。综合打分是根据RFM=R100+F10+M*1。

3.6、保存结果

print('Final RFM Score Overview:')
print(rfm_pd.head(4))
print('-'*30)
print('Final RFM Score DESC:')
print(rfm_pd.describe())

rfm_pd.to_csv('sales_rfm_score.csv')

输出结果:

Final RFM Score Overview:
             r_score  f_score  m_score  rfm_wscore rfm_comb
PHONENO                                                    
13001055088        4        1        1         2.8      411
13001061903        4        1        1         2.8      411
13001066446        5        1        1         3.4      511
13001123218        4        1        1         2.8      411
------------------------------
Final RFM Score DESC:
            r_score       f_score       m_score    rfm_wscore
count  53064.000000  53064.000000  53064.000000  53064.000000
mean       3.732172      1.006407      1.002148      2.641441
std        0.944452      0.113022      0.055212      0.570417
min        1.000000      1.000000      1.000000      1.000000
25%        3.000000      1.000000      1.000000      2.200000
50%        4.000000      1.000000      1.000000      2.800000
75%        5.000000      1.000000      1.000000      3.400000

3.7、写入数据库

建立数据库连接

table_name = 'sale_rfm_score'
#数据框基本信息
config = {
    'host' : '172.0.0.1',
    'user' : 'root',
    'password' : 'test123',
    'port' : 3306,
    'database' : 'skpda',
    'charset' : 'gb2312'
}
con = mysql.connector.connect(**config)
cursor = con.cursor()

cursor.execute("show tables")  #
table_object = cursor.fetchall()  # 通过fetchall方法获得所有数据
table_list = []  # 创建库列表
for t in table_object:  # 循环读出所有库
    table_list.append(t[0])  # 每个每个库追加到列表
if not table_name in table_list:  # 如果目标表没有创建
    cursor.execute('''
    CREATE TABLE %s (
    phone_no               VARCHAR(20),
    r_score               int(2),
    f_score              int(2),
    m_score              int(2),
    rfm_wscore              DECIMAL(10,2),
    rfm_comb              VARCHAR(10),
    create_date              VARCHAR(20)
    )ENGINE=InnoDB DEFAULT CHARSET=gb2312
    ''' % table_name)  # 创建新表
print(time.strftime('%Y-%m-%d %H:%M:%S',time.localtime(time.time()))+ ':开始清除 table {0}的历史数据...'.format(table_name)) # 输出开始清历史数据的提示信息
delete_sql = 'truncate table {0}'.format(table_name)
cursor.execute(delete_sql)
print(time.strftime('%Y-%m-%d %H:%M:%S',time.localtime(time.time()))+ ':清除 table {0}的历史数据完毕!'.format(table_name)) # 输出清除历史数据完毕的提示信息

连接的参数不再介绍,上文已经介绍过。通过fetchall方法获得所有数据,读出所有的表,如果没有表则创建。用cursor.execute先执行truncate语句,把表中的信息先清除,然后重新写入数据。

将数据写入数据库

phone_no = rfm_pd.index # 索引列
rfm_wscore = rfm_pd['rfm_wscore']  #RFM 加权得分列
rfm_comb = rfm_pd['rfm_comb']  #RFM组合得分列
timestamp = time.strftime('%Y-%m-%d',time.localtime(time.time())) # 写库日期
print('开始写入数据库表 {0}'.format(table_name)) # 输出开始写库的提示信息
for i in range(rfm_pd.shape[0]):
    insert_sql = "INSERT INTO `%s` VALUES ('%s',%s,%s,%s,%s,%s,'%s')" % \
                 (table_name, phone_no[i], r_score.iloc[i], f_score.iloc[i], m_score.iloc[i], rfm_wscore.iloc[i],
                  rfm_comb.iloc[i], timestamp)  # 写库SQL依据
    cursor.execute(insert_sql)
    con.commit()
cursor.close()
con.close()
print('写入数据库结束,总记录条数为: %d' %(i+1))

先从数据集合 rfm_pd (rfm_pd 是一个DataFrame)中获取到rfm的每个字段, ’....{0}'.format(table_name)表示的是在字符串中拼接参数,{0}代表一个字符串占位符。

四、案例结果分析

根据RFM模型的建立,我们在数据库里生成了数据。

数据库表生成

然后前段工程师根据数据库里的数据得到了用户RFM的价值打分页面,如图(后台展示页面)。

运营人员根据页面的打分情况来衡量客户价值和客户创利能力,了解客户差异。将客户分别按照R、F、M参数分组后,假设某个客户同时属于R5、F4、M3三个组,则可以得到该客户的RFM代码543。同理,我们可以推测,有一些客户刚刚成功交易、且交易频率高、总采购金额大,其RFM代码是555,还有一些客户的RFM代码是554、545……每一个RFM代码都对应着一小组客户,开展市场营销活动的时候可以从中挑选出若干组进行。


后台展示页面

用户是根据RFM的打分倒序排列,可以直接找到重点客户的信息,点开手机号,查看客户的详细信息(这一步由前端开发人员实现),针对重点客户展开各种个性化营销。

重点客户详细信息

RFM三个指标每个维度再细分出5份,这样就能够细分出5x5x5=125类用户,再根据每类用户精准营销……显然125类用户已超出普通人脑的计算范畴了,更别说针对125类用户量体定制营销策略。实际运用上,我们只需要把每个维度做一次两分即可,这样在3个维度上我们依然得到了8组用户。

这样,就可以得到以下解读(编号次序RFM,1代表高,0代表低)
重要价值客户(111):最近消费时间近、消费频次和消费金额都很高,必须是VIP啊!
重要保持客户(011):最近消费时间较远,但消费频次和金额都很高,说明这是个一段时间没来的忠诚客户,我们需要主动和他保持联系。
重要发展客户(101):最近消费时间较近、消费金额高,但频次不高,忠诚度不高,很有潜力的用户,必须重点发展。
重要挽留客户(001):最近消费时间较远、消费频次不高,但消费金额高的用户,可能是将要流失或者已经要流失的用户,应当基于挽留措施。

案例结论:

  • 表现处于一般水平以上的用户的比例太小,低于1%(R、F、M三个维度得分均在3以上的用户数),VIP客户太少。
  • 会员中99%以上的客户消费状态都不容乐观,主要体现在消费频率低R、消费总金额低M。这可能跟公司的地铁出行的业务有关系,公司的业务分布在全国中小城市,大部分用户都是使用一次的用户。
  • 低价值客户有262个,占总比例的 0.4%,运营人员可以导出下载这批用户。

完整代码下载: rfm.py 点击下载 (访问密码: 7287)

下一节,讲一下在linux服务器上部署python应用。

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