CDNow是一家CD销售公司,我们需要基于其历史销售数据,分析其产品销售趋势和用户行为。因为初次学习用户消费行为分析,所以写的会很详细。
import pandas as pd#加载包
import numpy as np
import matplotlib.pyplot as plt
from datetime import datetime
%matplotlib inline
plt.style.use('ggplot')#更改设计风格
columns = ['user_id','order_dt','order_products','order_amount']
df = pd.read_table('数据/CDNOW_master.txt',names = columns,sep='\s+')
用read_table加载数据,赋予表头,字符串是空格分割,所以用\s+表示匹配任意空白符。
df.head()
user_id代表用户ID
order_dt代表购买日期
order_products代表购买产品数
order_amount代表购买金额
观察数据可以发现,order_dt表示时间,但是它现在是整数形式,后面需要进行清洗。
df.describe()
对数据进行描述统计发现:大部分订单只消费了少数商品(均值2.4),受极值干扰 。中位数为2个商品,75分位数为3个商品,说明绝大部分订单的购买量都不多。最大值在99个,数字比较高。购买金额的情况差不多,大部分订单都集中在小额。
一般而言,消费类的数据分布,都是长尾形态。大部分用户都是小额,然而小部分用户贡献了收入的大头,俗称二八分布。
df.info()#可以看出日期是整数形式,需要进行清洗
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 69659 entries, 0 to 69658
Data columns (total 4 columns):
user_id 69659 non-null int64
order_dt 69659 non-null int64
order_products 69659 non-null int64
order_amount 69659 non-null float64
dtypes: float64(1), int64(3)
memory usage: 2.1 MB
数据中没有空值,接下来进行order_dt的数据类型转换:
df['order_dt'] = pd.to_datetime(df.order_dt,format="%Y%m%d")#解析日期
df['month']=df.order_dt.values.astype('datetime64[M]')
df.head()
pd.to_datetime可以将特定的字符串或者数字转换成时间格式,其中的format参数用于匹配。例如19970101,%Y匹配前四位数字1997,如果y小写只匹配两位数字97,%m匹配01,%d匹配01。另外,小时是%h,分钟是%M,秒是%s。若是1997-01-01这形式,则是%Y-%m-%d,以此类推。
astype也可以将时间格式进行转换,比如[M]转化成月份。我们将月份作为消费行为的主要事件窗口,选择哪种时间窗口取决于消费频率。
上图是转化后的格式。月份依旧显示日,只是变为月初的形式。
df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 69659 entries, 0 to 69658
Data columns (total 5 columns):
user_id 69659 non-null int64
order_dt 69659 non-null datetime64[ns]
order_products 69659 non-null int64
order_amount 69659 non-null float64
month 69659 non-null datetime64[ns]
dtypes: datetime64ns, float64(1), int64(2)
memory usage: 2.7 MB
1.用户消费趋势分析(按月)
- 每月的消费总额
- 每月的消费次数
- 每月的产品购买量
- 每月的消费人数
grouped_month = df.groupby('month')
order_month_amount = grouped_month.order_amount.sum()
order_month_amount.head()
得到
month
1997-01-01 299060.17
1997-02-01 379590.03
1997-03-01 393155.27
1997-04-01 142824.49
1997-05-01 107933.30
Name: order_amount, dtype: float64
order_month_amount.plot()
由上图,消费金额在前三个月达到最高峰,后续消费金额比较稳定,有轻微下降趋势。金额一样呈现早期销售额多,后期平稳下降的趋势。一方面有可能是用户身上出了问题,早期时间段的用户中有异常值,另外有可能是因为各类促销营销,但这里只有消费数据,所以无法判断。
grouped_month.user_id.count().plot()
可以看出,前三个月消费订单数在10000笔左右,后续平均消费人数在2500人左右。
grouped_month.order_products.sum().plot()
购买产品数于购买金额和消费订单数的变化趋势大致相同。
df.groupby('month').user_id.apply(lambda x:len(x.drop_duplicates())).plot()
#每月消费人数,x.drop_duplicates()为去重
由图可知,每月消费人数低于每月消费次数,但差异不大。前三个月每月的消费人数在8000~10000之间,后续月份,平均消费人数不到2000人。
#或者直接使用数据透视分析
df.pivot_table(index = 'month',
values = ['order_products','order_amount','user_id'],
aggfunc = {'order_products':'sum',
'order_amount':'sum',
'user_id':'count'}).head()
除此之外还可以通过计算
- 每月用户平均消费金额的趋势
- 每月用户平均消费次数的趋势
来进行分析
2.用户个体消费信息
- 用户消费金额、消费次数的描述统计
- 用户消费金额和消费次数的散点图
- 用户消费金额的分布图
- 用户消费次数的分布图
- 用户累计消费金额占比(百分之多少的用户占了百分之多少的消费额)
grouped_user = df.groupby('user_id')
grouped_user.sum().describe()
- 用户平均购买了7张CD,但中位数只有3,说明数据左偏,平均数受极值影响,小部分用户购买了大量的CD
- 同理,用户平均消费金额为106元,中位数只有43元,受极值干扰。
df.plot.scatter(x = 'order_amount',y = 'order_products')
我们可以从每笔订单的散点图观察到:订单消费金额和订单商品量呈规律性,每个商品十元左右。订单的极值较少,超出1000的就几个。显然异常波动不是极值导致的。
df.groupby('user_id').sum().query('order_amount < 4000').plot.scatter(x = 'order_amount',y = 'order_products')
#query起到过滤数据极值的作用
接下来绘制用户的散点图,因为销售数据来自CD网站,商品类型比较单一,所以金额和商品量大致呈线性关系(CD价格比较统一且稳定),离群点少。
grouped_user.sum().order_products.hist(bins = 20)
从直方图看出,大部分用户的消费能力确实不高,高消费用户在图上几乎看不到。这也确实符合消费行为的行业规律。用户消费金额绝大部分呈现集中趋势,小部分异常值干扰判断,可以使用过滤操作。
grouped_user.sum().query('order_products < 100').order_products.hist(bins = 20)
也可以选择用切比雪夫定理过滤掉异常值,计算95%的数据的分布情况。
计算累计消费金额通常要把数据进行升序排列。
user_cumsum = grouped_user.sum().sort_values('order_amount').apply(lambda x:x.cumsum() / x.sum())
user_cumsum.reset_index().order_amount.plot()
- sort_valuse升序排列,cumsum()累加,reset_index()去除索引方便作图。
- apply(func,args,*kwargs)当一个函数的参数存在于一个元组或一个字典中时,用来间接的调用这个函数,并肩元组或者字典中的参数按照顺序传递给参数。
-
apply的返回值就是函数func的返回值,kwargs是一个包含关键字参数的字典,而其中args如果不传递,kwargs需要传递,则必须在args的位置留空。
由图可知,50%的用户仅贡献了15%的消费额度,而排名前5000的用户就贡献了60%的消费额。
3.用户消费行为
- 用户第一次消费(首购)
- 用户最后一次消费
- 新老客消费比
- 多少用户仅消费了一次
- 每月新客占比?
- 用户分层
- RFM
- 新、老、活跃、回流、流失
- 用户购买周期(按订单)
- 用户消费周期描述
- 用户消费周期分布
- 用户生命周期(按第一次&最后一次消费)
- 用户生命周期描述
- 用户生命周期分布
df.groupby('user_id').month.min().value_counts()
用groupby函数将用户分组,并且求月份的最小值,最小值即用户消费行为中的第一次消费时间。可以看出,所有用户的第一次消费都集中在前三个月。我们可以这样认为,案例中的订单数据,只是选择了某个时间段消费的用户在18个月内的消费行为。
df.groupby('user_id').month.max().value_counts()
观察用户的最后一次消费时间。绝大部分数据依然集中在前三个月。后续的时间段内,依然有用户在消费,但是缓慢减少。
异常趋势的原因获得了解释,现在针对消费数据进一步细分。我们要明确,这只是部分用户的订单数据,所以有一定局限性。在这里,我们统一将数据上消费的用户定义为新客。
grouped_user.min().order_dt.value_counts().plot()
用户第一次购买分布集中在前三个月,
其中在2月11日至2月25日有一次剧烈的波动。
grouped_user.max().order_dt.value_counts().plot()
用户最后一次购买的分布比第一次分布广,大部分用户只购买了一次就不再购买,并且大部分最后一次购买集中在前三个月,随着时间递增,最后一次购买数也在递增,消费呈现流失上升的状况。
#多少客户仅消费了一次
user_life = grouped_user.order_dt.agg(['min','max'])
user_life.head()
(user_life['min'] == user_life['max']).value_counts()
True 12054
False 11516
dtype: int64
可以看出,一半以上的人只消费了一次
- RFM:消费额,消费次数,用户据今一次消费三个维度
rfm = df.pivot_table(index = 'user_id',
values = ['order_products','order_amount','order_dt'],
aggfunc = {'order_dt':'max',#最近一次消费时间
'order_amount' : 'sum',#消费额
'order_products':'sum'})#消费产品数
rfm.head()
rfm['R'] = -(rfm.order_dt - rfm.order_dt.max()) / np.timedelta64(1 ,'D')
rfm.rename(columns = {'order_products' :'F','order_amount' : 'M'},inplace = True)
#rfm.order_dt - rfm.order_dt.max()间隔天数,负号将其转换为正值,np.timedelta64(1 ,'D')转换时间维度为数字。
rfm
rfm[['R','F','M']].apply(lambda x:x-x.mean())#把消费距今天数分为三个维度
def rfm_func(x):
level = x.apply(lambda x:'1' if x>=0 else '0')
label = level.R+level.F+level.M
d = {
'111':'重要价值客户',
'011':'重要保持客户',
'101':'重要发展客户',
'001':'重要挽留客户',
'110':'一般价值客户',
'010':'一般保持客户',
'100':'一般挽留客户',
'000':'一般发展客户'
}
result = d[label]
return result
rfm['label'] = rfm[['R','F','M']].apply(lambda x:x-x.mean()).apply(rfm_func,axis=1)
rfm
![image.png](https://upload-images.jianshu.io/upload_images/23525311-133cb96c5c12767e.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
重要保持用户提供了159万的消费额。
rfm.loc[rfm.label == '重要价值客户','color'] = 'g'
rfm.loc[~(rfm.label == '重要价值客户'),'color'] = 'r'
rfm.plot.scatter('F','R',c=rfm.color)#新增颜色字段
由RFM分层可知,大部分用户为重要保持用户,但是这是由于极值的影响,所以RFM的划分标准应该以业务为准。
- 尽量用小部分的用户覆盖大部分的额度。
- 不要为了数据好看划分等级。
接下来按用户生命周期分层:新、活跃、回流、流失(不活跃)。
pivoted_counts = df.pivot_table(index = 'user_id',columns = 'month',values='order_dt',aggfunc = 'count').fillna(0)
pivoted_counts.head()
#将数据进行简化
df_purchase = pivoted_counts.applymap(lambda x: 1 if x > 0 else 0)
df_purchase.tail()
#applymap针对所有数值进行处理,如果用SQL用left join连接即可,比较简单。
并不是所有顾客都是从一月份开始消费的,所以计算生命周期需要在编写一个函数进行判断。
def active_status(data):
status = []
for i in range(18):#一月份到十八月份
#若本月没有消费
if data[i] == 0:
if len(status) > 0:
if status[i-1] =='unreg':#未注册用户的判断
status.append('unreg')
else:
status.append('unactice')#否则为不活跃用户
else:
status.append('unreg')#防止数据为空
#若本月消费
else:
if len(status) == 0:
status.append('new')
else:
if status[i-1] == 'unactive':
status.append('return')#回流
elif status[i-1] == 'unreg':
status.append('new')#新用户
else:
status.append('active')#活跃
return status
若本月没有消费
- 若之前未注册,则仍为未注册
- 若之前有消费,则为流失/不活跃
- 其他情况,为未注册
若本月有消费
- 若为第一次消费,则为新用户
- 如果之前有过消费,则上个月为不活跃,则为回流
- 如果上个月为未注册,则为因用户
- 除此之外为活跃
purchase_stats = df_purchase.apply(lambda x: active_status(x),axis = 1)
purchase_stats.head(5)
(purchase_b.sum() / purchase_b.count()).plot(figsize = (10,4))
回流用户逐渐减少,说明运营情况不够好,需要对用户进行挽回。
user_amount = df.groupby('user_id').order_amount.sum().sort_values().reset_index()
user_amount['amount_cumsum'] = user_amount.order_amount.cumsum()
user_amount.tail()
新建一个对象,按用户的消费金额生序。使用cumsum,它是累加函数。逐行计算累计的金额,最后的2500315便是总消费额。
amount_total = user_amount.amount_cumsum.max()
user_amount['prop'] = user_amount.apply(lambda x:x.amount_cumsum / amount_total,axis=1)
user_amount.tail()
转换成百分比。
user_amount.prop.plot()
绘制趋势图,横坐标是按贡献金额大小排序而成,纵坐标则是用户累计贡献。可以很清楚的看到,前20000个用户贡献了40%的消费。后面4000位用户贡献了60%,确实呈现28倾向。
user_counts = df.groupby('user_id').order_dt.count().sort_values().reset_index()
user_counts['counts_cumsum'] = user_counts.order_dt.cumsum()
counts_total = user_counts.counts_cumsum.max()
user_counts['prop'] = user_counts.apply(lambda x:x.counts_cumsum / counts_total,axis=1)
user_counts.prop.plot()
统计销量,前两万个用户贡献了45%的销量,高消费用户贡献了55%的销量。因此需要抓住高质量用户。
下面分析用户购买周期(按订单)
order_diff = grouped_user.apply(lambda x:x.order_dt - x.order_dt.shift())
order_diff.head(10)#shift将所有数据进行错位
用户第一次消费时间减最后一次消费的时间得到每一位用户的生命周期。因为数据中的用户都是前三个月第一次消费,所以这里的生命周期代表的是1月~3月用户的生命周期。另外,用户会持续消费,所以理论上,随着后续的消费,用户的平均生命周期会增长。
#把数据清洗一下
(order_diff / np.timedelta64(1,'D')).hist(bins =20)
因为这里的数据类型是timedelta时间,它无法直接作出直方图,所以先换算成数值。换算的方式直接除timedelta函数即可,这里的np.timedelta64(1, 'D'),D表示天,1表示1天,作为单位使用的。因为max-min已经表示为天了,两者相除就是周期的天数。
由上图可以看出
- 订单周期呈指数分布
- 用户的平均购买周期是68天
- 绝大部分用户的购买周期都低于100天
接下来看用户生命周期(按第一次和最后一次消费)
(user_life['max'] - user_life['min']).describe()
- 用户的生命周期受只购买一次的用户影响比较厉害(可以排除);
- 用户平均消费134天,中位数仅为0天。
((user_life['max'] - user_life['min'])/np.timedelta64(1, 'D')).hist(bins = 40)
u_1 = ((user_life['max']-user_life['min']).reset_index()[0] / np.timedelta64(1,'D'))
u_1[u_1 >0].hist(bins = 40)
筛选出lifetime>0,即排除了仅消费了一次的那些人。做直方图。
得到的分布类似于双峰图,虽然仍旧有不少用户生命周期靠拢在0天。部分质量差的用户,虽然消费了两次,但是仍旧无法持续,在用户首次消费30天内应该尽量引导。少部分用户集中在50天~300天,属于普通型的生命周期,高质量用户的生命周期,集中在400天以后属于忠诚用户。
4.复购率和回购率分析
- 复购率
- 自然月内,购买多次的用户占比
- 回购率
- 曾经购买过的用户在某一时期内的再次购买的占比。
下面分析消费中的复购率和回购率。首先将用户消费数据进行数据透视。
- 曾经购买过的用户在某一时期内的再次购买的占比。
pivoted_counts.head()
转换数据,消费两次及以上记为1,消费一次记为0,没有消费记为NaN。
purchase_r = pivoted_counts.applymap(lambda x: 1 if x>1 else np.NaN if x==0 else 0)
purchase_r.head()
(purchase_r.sum( ) / purchase_r.count()).plot(figsize = (10,4))
#复购人数 / 总的消费人数 得到复购率
applymap针对DataFrame里的所有数据。用lambda进行判断,因为这里涉及了多个结果,所以要两个if else(lambda没有elif的用法)。
用sum和count相除即可计算出复购率。因为这两个函数都会忽略NaN,而NaN是没有消费的用户,count不论0还是1都会统计,所以是总的消费用户数,而sum求和计算了两次以上的消费用户。这里用了比较巧妙的替代法计算复购率,SQL中也可以用。
可以看出,后期复购率稳定在20%左右,而早期有一个月有大量新用户涌入,但他们只购买了一次,导致复购率降低。
接下来计算回购率。回购率是某一个时间窗口内消费的用户,在下一个时间窗口仍旧消费的占比,比如1月消费用户为1000个,他们中有300个2月依然消费,回购率是30%。
回购率的计算比较难,因为它涉及了横向跨时间窗口的对比。
df_purchase.head()
def purchase_back(data):
status = []
for i in range(17):
if data[i] == 1:#当月消费过
if data[i+1] == 1:#下月仍旧消费
status.append(1)
if data[i+1] == 0:#下月没有消费
status.append(0)
else:
status.append(np.NaN)#当月没有消费过,使用空值不参与计算
status.append(np.NaN)#填充最后一月份数据
return status
purchase_b = df_purchase.apply(purchase_back,axis = 1)
purchase_b.head(5)
新建一个判断函数。data是输入的数据,即用户在18个月内是否消费的记录,status是空列表,后续用来保存用户是否回购的字段。
因为有18个月,所以每个月都要进行一次判断,需要用到循环。if的主要逻辑是,如果用户本月进行过消费,且下月消费过,记为1,没有消费过记为0。如果本月没有进行过消费,为NaN,后续的统计中会排除。
(purchase_b.sum() / purchase_b.count()).plot(figsize = (10,4))
计算与复购率大致相同。从图中可以看出,用户的回购率高于复购,约在30%左右,波动性也较强。新用户的回购率在15%左右,和老客差异不大。
综合回购率和复购率分析得到,新客的整体质量低于老客,老客的忠诚度(回购率)表现较好,消费频次稍次,这是CDNow网站的用户消费特征。
purchase_stats_ct.fillna(0).T.apply(lambda x:x/x.sum(),axis =1)
purchase_stats_ct.fillna(0).T.plot.area()
生成面积图,蓝色和灰色区域都可以不看,只看紫色回流和红色活跃这两个分层,用户数比较稳定。这两个分层相加,就是消费用户占比(后期没新客)。
5.留存率分析
接下来计算留存率,留存率也是消费分析领域的经典应用。它指用户在第一次消费后,有多少比率进行第二次消费。和回流率的区别是留存倾向于计算第一次消费,并且有多个时间窗口。
user_purchase_retention = pd.merge(left = user_purchase,right = order_date_min.reset_index(),
how = 'inner',on = 'user_id',
suffixes=('','_min'))
user_purchase_retention.head(5)
这里用到merge函数(类似于SQL中的join)来将两个DataFrame进行合并。这里merge的目的是将用户消费行为和第一次消费时间对应上,形成一个新的DataFrame。suffxes参数是如果合并的内容中有重名column,加上后缀。除了merge,还有join,concat也可以使用。
user_purchase_retention['order_date_diff'] = user_purchase_retention.order_date - user_purchase_retention.order_date_min
user_purchase_retention.head()
order_date_diff为用户每一次消费距第一次消费的时间差值。
date_trans = lambda x: x/np.timedelta64(1,'D')
user_purchase_retention['date_diff'] = user_purchase_retention.order_date_diff.apply(date_trans)
user_purchase_retention.head(10)
将日期转换成时间
bin = [0,3,7,15,30,90,180,365]
user_purchase_retention['date_diff_bin'] = pd.cut(user_purchase_retention.date_diff,bins = bin)
将时间差值分成0~3天内,3~7天内,7~15天等,代表用户当前消费时间距第一次消费属于哪个时间段呢。这里date_diff=0并没有被划分入0~3天,因为计算的是留存率,如果用户仅消费了一次,留存率应该是0。另外一方面,如果用户第一天内消费了多次,但是往后没有消费,也算作留存率0。
pivoted_retention = user_purchase_retention.pivot_table(index = 'user_id',columns = 'date_diff_bin',values = 'order_amount',aggfunc=sum)
pivoted_retention.head(10)
![image.png](https://upload-images.jianshu.io/upload_images/23525311-5ef0243537f6f531.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
用pivot_table数据透视,获得的结果是用户在第一次消费之后,在后续各时间段内的消费总额。
计算用户在后续各时间段的平均消费额,这里只统计有消费的平均值。虽然后面时间段的金额高,但是它的时间范围也宽广。从平均效果看,用户第一次消费后的0~3天内,更可能消费更多。
但消费更多是一个相对的概念,我们还要看整体中有多少用户在0~3天消费。
pivoted_retention_trans = pivoted_retention.fillna(0).applymap(lambda x:1 if x>0 else 0)
pivoted_retention_trans.head()
依旧将数据转换成是否,1代表在该时间段内有后续消费,0代表没有。
(pivoted_retention_trans.sum() / pivoted_retention_trans.count()).plot.bar()
只有2.5%的用户在第一次消费的次日至3天内有过消费,3%的用户在3~7天内有过消费。把时间范围放宽,有20%的用户在第一次消费后的三个月到半年之间有过购买,27%的用户在半年后至1年内有过购买。从运营角度看,CD机营销在教育新用户的同时,应该注重用户忠诚度的培养,在一定时间内召回用户购买。
接下来计算出用户的平均购买周期。
def diff(group):
d = group.date_diff - group.date_diff.shift(-1)
return d
last_diff = user_purchase_retention.groupby('user_id').apply(diff)
last_diff.head(10)
我们将用户上下两次消费时间相减求出消费间隔。shift函数是一个偏移函数类似于excel上的offset。
last_diff.mean()
用mean函数即可求出用户的平均消费间隔时间是68天。在60天左右的消费间隔召回用户比较好。
last_diff.hist(bins = 20)
直方图是典型的长尾分布,大部分用户的消费间隔确实比较短。不妨将时间召回点设为消费后立即赠送优惠券,消费后10天询问用户CD怎么样,消费后30天提醒优惠券到期,消费后60天短信推送。
参考文献:https://zhuanlan.zhihu.com/p/27910430
学习课程:https://www.bilibili.com/video/BV1d7411d7Ft