今天跟大家分享的是我之前跟着做过的一门项目,非常的经典,也非常的详细,适合作为数据分析入门的项目。以下是有关的介绍。
数据来源于CDNow网站的用户购买明细。一共有用户ID,购买日期,购买数量,购买金额四个字段。
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
%matplotlib inline
plt.style.use('ggplot')
- 加载数据:首先需要的是加载数据,同时由于数据中缺乏表头,所以需要赋予。且读取时注意更改默认分隔符(数据由多个空格分隔)
columns = ['user_id', 'order_dt', 'order_products', 'order_amount']
df = pd.read_csv('CDNow_master.txt', names=columns, sep='\s+')
- 观察数据:其中需要注意的是,order_dr为日期,格式为int64,并非我们需要的日期格式,所以后续要进行更改。同时一个用户也可以在一天内进行多次购买,如user_id为2的用户就在19970112那天买了两次。
df.head()
user_id | order_dt | order_products | order_amount | |
---|---|---|---|---|
0 | 1 | 19970101 | 1 | 11.77 |
1 | 2 | 19970112 | 1 | 12.00 |
2 | 2 | 19970112 | 5 | 77.00 |
3 | 3 | 19970102 | 2 | 20.76 |
4 | 3 | 19970330 | 2 | 20.76 |
df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 69659 entries, 0 to 69658
Data columns (total 4 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 user_id 69659 non-null int64
1 order_dt 69659 non-null int64
2 order_products 69659 non-null int64
3 order_amount 69659 non-null float64
dtypes: float64(1), int64(3)
memory usage: 2.1 MB
- 数据处理:没有空值,很干净的数据。现在需要将时间的数据类型进行转换。
df['order_date'] = pd.to_datetime(df.order_dt, format='%Y%m%d')
df['month'] = df.order_date.values.astype('datetime64[M]')
%h是小时,%M是分钟,注意和月的大小写不一致,秒是%s。
另外之所以还将数据转换成月份格式,是因为我们将月份作为消费行为的主要事件窗口,选择哪种时间窗口取决于消费频率。
(也可以是以天或者年来划分)
df.head()
user_id | order_dt | order_products | order_amount | order_date | month | |
---|---|---|---|---|---|---|
0 | 1 | 19970101 | 1 | 11.77 | 1997-01-01 | 1997-01-01 |
1 | 2 | 19970112 | 1 | 12.00 | 1997-01-12 | 1997-01-01 |
2 | 2 | 19970112 | 5 | 77.00 | 1997-01-12 | 1997-01-01 |
3 | 3 | 19970102 | 2 | 20.76 | 1997-01-02 | 1997-01-01 |
4 | 3 | 19970330 | 2 | 20.76 | 1997-03-30 | 1997-03-01 |
df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 69659 entries, 0 to 69658
Data columns (total 6 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 user_id 69659 non-null int64
1 order_dt 69659 non-null int64
2 order_products 69659 non-null int64
3 order_amount 69659 non-null float64
4 order_date 69659 non-null datetime64[ns]
5 month 69659 non-null datetime64[ns]
dtypes: datetime64[ns](2), float64(1), int64(3)
memory usage: 3.2 MB
pandas中有专门的时间序列方法tseries,它可以用来进行时间偏移,也是处理时间类型的好方法。时间格式也能作为索引,在金融、财务等领域使用较多。
# df.date - pd.tseries.offsets.MonthBegin(1)
- 按每笔订单来统计分布由上述可得,用户平均每单购买2.4个商品,标准差为2.3,略有波动。中位数在2.0,75分位数为3,说明绝大多部分订单的购买力 都不多,最大值为99个,数字较高。而购买金额则同购买数量差不多情况,大部分订单都集中在小额。 一般而言,消费类的数据分布,都是长尾形态。大部分用户都是小额,然而小部分用户贡献了收入的大头,俗称二八法则。
df.describe()
user_id | order_dt | order_products | order_amount | |
---|---|---|---|---|
count | 69659.000000 | 6.965900e+04 | 69659.000000 | 69659.000000 |
mean | 11470.854592 | 1.997228e+07 | 2.410040 | 35.893648 |
std | 6819.904848 | 3.837735e+03 | 2.333924 | 36.281942 |
min | 1.000000 | 1.997010e+07 | 1.000000 | 0.000000 |
25% | 5506.000000 | 1.997022e+07 | 1.000000 | 14.490000 |
50% | 11410.000000 | 1.997042e+07 | 2.000000 | 25.980000 |
75% | 17273.000000 | 1.997111e+07 | 3.000000 | 43.700000 |
max | 23570.000000 | 1.998063e+07 | 99.000000 | 1286.010000 |
- 上面的消费行为数据粒度是每笔订单,我们转换成每位用户看一下。
user_grouped = df.groupby('user_id').sum()
user_grouped.head()
order_dt | order_products | order_amount | |
---|---|---|---|
user_id | |||
1 | 19970101 | 1 | 11.77 |
2 | 39940224 | 6 | 89.00 |
3 | 119833602 | 16 | 156.46 |
4 | 79882233 | 7 | 100.50 |
5 | 219686137 | 29 | 385.61 |
用group_by创建一个新对象。
user_grouped.describe()
order_dt | order_products | order_amount | |
---|---|---|---|
count | 2.357000e+04 | 23570.000000 | 23570.000000 |
mean | 5.902627e+07 | 7.122656 | 106.080426 |
std | 9.460684e+07 | 16.983531 | 240.925195 |
min | 1.997010e+07 | 1.000000 | 0.000000 |
25% | 1.997021e+07 | 1.000000 | 19.970000 |
50% | 1.997032e+07 | 3.000000 | 43.395000 |
75% | 5.992125e+07 | 7.000000 | 106.475000 |
max | 4.334408e+09 | 1033.000000 | 13990.930000 |
从用户角度来看,每位用户平均购买7张CD,最多的用户购买了1033张CD(太疯狂了)。用户平均的消费金额(客单价)为100,标准差为240,结合分位数和最大值看,平均值才和75分位接近,说明存在小部分的高额消费用户。
- 接下来按月的维度来分析。
df.groupby('month').order_products.sum().plot()
按月统计每个月的CD销量。从图中可以看出,前三个月销量非常的高,但后期下降较大,并趋于平稳(平稳中也略有下降)
df.groupby('month').order_amount.sum().plot()
情况同销量相同,也是前期非常多,后期平稳下降。
-
至于为什么会出现这种情况?我们假设是用户身上出了问题,早期时间段的用户中有异常值,第二假设是早期有各类促销营销,但这里只有消费数据,所以无法判断。
-
分析是否存在异常值
8.1 绘制每笔订单的散点图。从图中观察,订单消费金额和订单商品量呈规律性,每个商品十元左右。订单的极值极少,超过1000的较少,显然不是异常波动的罪魁祸首。
df.plot.scatter(x = 'order_amount', y = 'order_products')
8.2 绘制用户的散点图,用户也比较健康,而且规律性比订单更强。因为这是CD网站的销售数据,商品比较单一,金额和商品量的关系也因此呈线性,没几个离群点。
df.groupby('user_id').sum().plot.scatter(x = 'order_amount', y = 'order_products')
8.3 消费能力特别强的用户有,但是数量不多。为了更好的观察,用直方图。
plt.figure(figsize=(12, 4))
plt.subplot(121)
df.order_amount.hist(bins = 30)
plt.subplot(122)
df.groupby('user_id').order_amount.sum().hist(bins = 30)
plt.subplot用于绘制子图,子图用数字参数表示。121表示分成1*2个图片区域,占用第一个,即第一行第一列,122表示占用第二个。figure是尺寸函数,为了容纳两张子图,宽设置的大一点即可。
从直方图看,大部分用户的消费能力确实不高,高消费用户在图上几乎看不到。这也确实符合消费行为的行业规律。
8.4 观察完用户消费的金额和购买量,接下来看消费的时间节点。
df.groupby('user_id').month.min().value_counts()
1997-02-01 8476
1997-01-01 7846
1997-03-01 7248
Name: month, dtype: int64
从中不难发现,所有用户的第一次消费都集中在前三个月。我们可以这样认为,案例中的订单数据,只是选择了某个时间段消费的用户在18个月内的消费行为。
df.groupby('user_id').month.max().value_counts()
1997-02-01 4912
1997-03-01 4478
1997-01-01 4192
1998-06-01 1506
1998-05-01 1042
1998-03-01 993
1998-04-01 769
1997-04-01 677
1997-12-01 620
1997-11-01 609
1998-02-01 550
1998-01-01 514
1997-06-01 499
1997-07-01 493
1997-05-01 480
1997-10-01 455
1997-09-01 397
1997-08-01 384
Name: month, dtype: int64
所有用户的最后一次消费也主要是集中在前三个月,后续时间段内,依然有用户在消费,但是缓慢减少。
异常趋势的原因获得了解释,现在针对消费数据进一步细分。我们要明确,这只是部分用户的订单数据,所以有一定局限性。在这里,我们统一将数据上消费的用户定义为新客。
- 接下来分析消费中的复购率和回购率。首先将用户消费数据进行数据透视。
pivoted_counts = df.pivot_table(index='user_id', columns='month', values='order_dt', aggfunc='count').fillna(0)
columns_month = df.month.sort_values().astype('str').unique()
pivoted_counts.columns = columns_month
pivoted_counts.head()
1997-01-01 | 1997-02-01 | 1997-03-01 | 1997-04-01 | 1997-05-01 | 1997-06-01 | 1997-07-01 | 1997-08-01 | 1997-09-01 | 1997-10-01 | 1997-11-01 | 1997-12-01 | 1998-01-01 | 1998-02-01 | 1998-03-01 | 1998-04-01 | 1998-05-01 | 1998-06-01 | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
user_id | ||||||||||||||||||
1 | 1.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
2 | 2.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
3 | 1.0 | 0.0 | 1.0 | 1.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 2.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1.0 | 0.0 |
4 | 2.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1.0 | 0.0 | 0.0 | 0.0 | 1.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
5 | 2.0 | 1.0 | 0.0 | 1.0 | 1.0 | 1.0 | 1.0 | 0.0 | 1.0 | 0.0 | 0.0 | 2.0 | 1.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
在pandas中,数据透视有专门的函数pivot_table,功能非常强大。
使用数据透视表,需要明确获得什么结果。有些用户在某月没有进行过消费,会用NaN表示,这里用fillna填充。
生成的数据透视,月份是1997-01-01 00:00:00表示,比较丑,所以优化成标准格式。
首先求复购率,复购率的定义是在某时间窗口消费两次及以上的用户在总消费用户中占比。这里的时间窗口是月,如果一个用户在同一天内下了两笔订单,这里也将他算作复购用户。
将数据转换一下,消费两次及以上记为1,消费一次记为0,没有消费记为NaN。
pivoted_counts_trans = pivoted_counts.applymap(lambda x:1 if x>1 else np.NaN if x==0 else 0)
pivoted_counts_trans.head()
1997-01-01 | 1997-02-01 | 1997-03-01 | 1997-04-01 | 1997-05-01 | 1997-06-01 | 1997-07-01 | 1997-08-01 | 1997-09-01 | 1997-10-01 | 1997-11-01 | 1997-12-01 | 1998-01-01 | 1998-02-01 | 1998-03-01 | 1998-04-01 | 1998-05-01 | 1998-06-01 | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
user_id | ||||||||||||||||||
1 | 0.0 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
2 | 1.0 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
3 | 0.0 | NaN | 0.0 | 0.0 | NaN | NaN | NaN | NaN | NaN | NaN | 1.0 | NaN | NaN | NaN | NaN | NaN | 0.0 | NaN |
4 | 1.0 | NaN | NaN | NaN | NaN | NaN | NaN | 0.0 | NaN | NaN | NaN | 0.0 | NaN | NaN | NaN | NaN | NaN | NaN |
5 | 1.0 | 0.0 | NaN | 0.0 | 0.0 | 0.0 | 0.0 | NaN | 0.0 | NaN | NaN | 1.0 | 0.0 | NaN | NaN | NaN | NaN | NaN |
applymap针对DataFrame里的所有数据。用lambda进行判断,因为这里涉及了多个结果,所以要两个if else,记住,lambda没有elif的用法。
(pivoted_counts_trans.sum() / pivoted_counts_trans.count()).plot(figsize = (10, 4))
用sum和count相除即可计算出复购率。因为这两个函数都会忽略NaN,而NaN是没有消费的用户,count不论是0还是1都会统计,所以是总的消费用户数,而sum求和计算了两次以上的消费用户这里用了比较巧妙的替代法计算复购率,SQL中也可以用。
图中可以看出复购率在早期,因为大量新用户加入的关系,新客的复购率并不高,譬如1月新客们的复购率只有6%左右。而在后期,这时的用户都是大浪淘沙剩下的老客,复购率比较稳定,在20%左右。
单看新客和老客,复购率有三倍左右的差距。
- 接下来计算回购率。回购率是某一个时间窗口内消费的用户,在下一个时间窗口仍旧消费的占比。比方说我1月消费用户为1000,他们中有300个人在2月份依然消费,回购率是30%。
回购率的计算比较难,因为它设计了时间窗口的对比。
pivoted_amount = df.pivot_table(index='user_id', columns='month', values='order_amount', aggfunc='mean').fillna(0)
columns_month = df.month.sort_values().astype('str').unique()
pivoted_amount.columns = columns_month
pivoted_amount.head()
1997-01-01 | 1997-02-01 | 1997-03-01 | 1997-04-01 | 1997-05-01 | 1997-06-01 | 1997-07-01 | 1997-08-01 | 1997-09-01 | 1997-10-01 | 1997-11-01 | 1997-12-01 | 1998-01-01 | 1998-02-01 | 1998-03-01 | 1998-04-01 | 1998-05-01 | 1998-06-01 | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
user_id | ||||||||||||||||||
1 | 11.77 | 0.0 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.0 | 0.000 | 0.000 | 0.00 | 0.0 | 0.0 | 0.0 | 0.00 | 0.0 |
2 | 44.50 | 0.0 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.0 | 0.000 | 0.000 | 0.00 | 0.0 | 0.0 | 0.0 | 0.00 | 0.0 |
3 | 20.76 | 0.0 | 20.76 | 19.54 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.0 | 39.205 | 0.000 | 0.00 | 0.0 | 0.0 | 0.0 | 16.99 | 0.0 |
4 | 29.53 | 0.0 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 14.96 | 0.00 | 0.0 | 0.000 | 26.480 | 0.00 | 0.0 | 0.0 | 0.0 | 0.00 | 0.0 |
5 | 21.65 | 38.9 | 0.00 | 45.55 | 38.71 | 26.14 | 28.14 | 0.00 | 40.47 | 0.0 | 0.000 | 43.465 | 37.47 | 0.0 | 0.0 | 0.0 | 0.00 | 0.0 |
将消费金额进行数据透视,这里作为练习,使用了平均值。
pivoted_purchase = pivoted_amount.applymap(lambda x:1 if x>0 else 0)
pivoted_purchase.head()
1997-01-01 | 1997-02-01 | 1997-03-01 | 1997-04-01 | 1997-05-01 | 1997-06-01 | 1997-07-01 | 1997-08-01 | 1997-09-01 | 1997-10-01 | 1997-11-01 | 1997-12-01 | 1998-01-01 | 1998-02-01 | 1998-03-01 | 1998-04-01 | 1998-05-01 | 1998-06-01 | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
user_id | ||||||||||||||||||
1 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
2 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
3 | 1 | 0 | 1 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 1 | 0 |
4 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 |
5 | 1 | 1 | 0 | 1 | 1 | 1 | 1 | 0 | 1 | 0 | 0 | 1 | 1 | 0 | 0 | 0 | 0 | 0 |
再次用applymap+lambda转换数据,只要有购买过,记为1,否则为0。
def purchase_return(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
pivoted_purchase_return = pivoted_purchase.apply(purchase_return, axis=1, result_type='expand')
pivoted_purchase_return.columns = columns_month
pivoted_purchase_return.head()
1997-01-01 | 1997-02-01 | 1997-03-01 | 1997-04-01 | 1997-05-01 | 1997-06-01 | 1997-07-01 | 1997-08-01 | 1997-09-01 | 1997-10-01 | 1997-11-01 | 1997-12-01 | 1998-01-01 | 1998-02-01 | 1998-03-01 | 1998-04-01 | 1998-05-01 | 1998-06-01 | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
user_id | ||||||||||||||||||
1 | 0.0 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
2 | 0.0 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
3 | 0.0 | NaN | 1.0 | 0.0 | NaN | NaN | NaN | NaN | NaN | NaN | 0.0 | NaN | NaN | NaN | NaN | NaN | 0.0 | NaN |
4 | 0.0 | NaN | NaN | NaN | NaN | NaN | NaN | 0.0 | NaN | NaN | NaN | 0.0 | NaN | NaN | NaN | NaN | NaN | NaN |
5 | 1.0 | 0.0 | NaN | 1.0 | 1.0 | 1.0 | 0.0 | NaN | 0.0 | NaN | NaN | 1.0 | 0.0 | NaN | NaN | NaN | NaN | NaN |
新建一个判断函数。data是输入的数据,即用户在18个月内是否消费的记录,status是空列表,后续用来保存用户是否回购的字段。
因为有18个月,所以每个月都要济宁一次判断,需要用到循环。判断的主要逻辑是:如果用户本月进行过消费,且下月也有消费,记为1,没有记为0。如果本月没有进行过消费,为NaN,后续的统计中进行消除。
用apply函数应用在所有行上,获得想要的结果。
(pivoted_purchase_return.sum()/pivoted_purchase_return.count()).plot(figsize = (10, 4))
最后的计算和复购率大同小异,用count和sum求出。从图中可以看出,用户的回购率高于复购率,约在30%左右,波动性也比较强。新用户的回购率在15%左右,和老客差异不大。
将回购率和复购率综合分析,可以得出,新客整体质量低于老客,老客的忠诚度(回购率)表现较好,消费频次稍次,这是CDNow网站的用户消费特征。
- 接下来进行用户分层,我们按照用户的消费行为,简单划分几个维度:新用户、活跃用户 、不活跃用户和回流用户。
新用户的定义是第一次消费。活跃用户即为老客,在某一个时间窗口内有过消费。不活跃用户则是时间窗口内没有消费过的老客。回流用户是在上一个窗口没有消费,而在当前时间窗口有过消费。以上时间窗口都是按月统计。
比如某用户在1月第一次消费,那么他在1月份的分层就是新用户;他在2月份消费过,则是活跃用户;3月份没有消费,此时是不活跃用户;4月份再次消费,此时是回流用户,5月份还是消费,是活跃用户。
分层会涉及到比较多的逻辑判断。
def state_return(data):
status = []
for i in range(18):
if data[i] == 0:
if len(status) == 0:
status.append('unreg')
else:
if status[i-1] == 'unreg':
status.append('unreg')
else:
status.append('unact')
else:
if len(status) == 0:
status.append('new')
else:
if status[i-1] == 'unreg':
status.append('new')
elif status[i-1] == 'unact':
status.append('return')
else:
status.append('act')
return status
函数写得比较复杂,主要分为两部分来判断。如果本月没有消费,那么先判断这个月是不是第一个月,如果是的话必然还没有购买过(用unreg来表示),如果不是第一个月,那么判断上个月是不是也还没购买过,如果是就还是unreg,如果不是这个月就是不活跃用户unact;如果本月有消费,也是先判断这个月是不是第一个月,如果是那么说明他是新用户new,如果不是判断上个月注册过没,没有就是new,如果上个月是unact说明购买过那么就是回流用户,否则就是act。
pivoted_purchase_status = pivoted_purchase.apply(state_return, axis = 1, result_type='expand')
pivoted_purchase_status.columns = columns_month
pivoted_purchase_status.head()
1997-01-01 | 1997-02-01 | 1997-03-01 | 1997-04-01 | 1997-05-01 | 1997-06-01 | 1997-07-01 | 1997-08-01 | 1997-09-01 | 1997-10-01 | 1997-11-01 | 1997-12-01 | 1998-01-01 | 1998-02-01 | 1998-03-01 | 1998-04-01 | 1998-05-01 | 1998-06-01 | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
user_id | ||||||||||||||||||
1 | new | unact | unact | unact | unact | unact | unact | unact | unact | unact | unact | unact | unact | unact | unact | unact | unact | unact |
2 | new | unact | unact | unact | unact | unact | unact | unact | unact | unact | unact | unact | unact | unact | unact | unact | unact | unact |
3 | new | unact | return | act | unact | unact | unact | unact | unact | unact | return | unact | unact | unact | unact | unact | return | unact |
4 | new | unact | unact | unact | unact | unact | unact | return | unact | unact | unact | return | unact | unact | unact | unact | unact | unact |
5 | new | act | unact | return | act | act | act | unact | return | unact | unact | return | act | unact | unact | unact | unact | unact |
从结果看,用户每个月的分层状态以及变化已经被我们计算出来,这个方法是根据透视过的宽表计算,其实还有另一种写法,只提取时间窗口内的数据和上个窗口对比判断,封装成函数做循环,封装成函数做循环,这种方法更适合ETL的增量更新。
pivoted_status_counts = pivoted_purchase_status.replace('unreg', np.NaN).apply(lambda x:pd.value_counts(x))
pivoted_status_counts.head()
1997-01-01 | 1997-02-01 | 1997-03-01 | 1997-04-01 | 1997-05-01 | 1997-06-01 | 1997-07-01 | 1997-08-01 | 1997-09-01 | 1997-10-01 | 1997-11-01 | 1997-12-01 | 1998-01-01 | 1998-02-01 | 1998-03-01 | 1998-04-01 | 1998-05-01 | 1998-06-01 | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
act | NaN | 1155.0 | 1680 | 1773.0 | 852.0 | 747.0 | 746.0 | 604.0 | 528.0 | 532.0 | 624 | 632.0 | 512.0 | 472.0 | 569.0 | 517.0 | 458.0 | 446.0 |
new | 7814.0 | 8455.0 | 7231 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | 2 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
return | NaN | NaN | 595 | 1049.0 | 1362.0 | 1592.0 | 1434.0 | 1168.0 | 1211.0 | 1307.0 | 1402 | 1232.0 | 1025.0 | 1079.0 | 1489.0 | 919.0 | 1030.0 | 1060.0 |
unact | NaN | 6659.0 | 13994 | 20678.0 | 21286.0 | 21161.0 | 21320.0 | 21728.0 | 21761.0 | 21661.0 | 21474 | 21638.0 | 21965.0 | 21951.0 | 21444.0 | 22066.0 | 22014.0 | 21996.0 |
将unreg更改为NaN是为了避免被计入总数。此时已换成按月的统计量。
pivoted_status_counts.fillna(0).T.plot.area(figsize = (12, 6))
生成面积图。因为它还是某时间段消费过的用户的后续行为。所以蓝色和灰色的区域都可以不看。只看紫色回流和红色活跃 这两个分层,用户数比较稳定。这两个分层相加,就是消费用户占比(后期没新客)。
return_rata = pivoted_status_counts.apply(lambda x:x / x.sum(), axis = 1)
return_rata.loc['return'].plot(figsize = (12, 6))
用户回流占比在5%-8%,有下降趋势。所谓回流占比,就是回流用户在总用户中的占比。另外一种指标叫回流率,指上个月多少不消费(活跃)用户在本月消费(活跃)用户的占比。
return_rata.loc['act'].plot(figsize = (12, 6))
活跃用户的下降趋势更明显,占比在3%~5%之间。这里的活跃用户可以看作是连续消费用户,质量上在一定程度上高于回流用户。
结合回流用户和活跃用户来看,在后期的消费用户中,60%是回流用户,40%是活跃用户(连续消费用户),整体质量还不错,但是针对这两个分层依旧有改进的空间,可以继续细化数据。
- 接下来分析用户质量,因为消费行为有明显的二八倾向,我们需要知道高质量用户为消费贡献了多少份额。
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()
user_id | order_amount | amount_cumsum | |
---|---|---|---|
23565 | 7931 | 6497.18 | 2463822.60 |
23566 | 19339 | 6552.70 | 2470375.30 |
23567 | 7983 | 6973.07 | 2477348.37 |
23568 | 14048 | 8976.33 | 2486324.70 |
23569 | 7592 | 13990.93 | 2500315.63 |
新建一个对象,按用户的消费金额升序,使用cumsum函数,即累加函数,用于逐行计算累计的金额,最后的2500315便是总金额。
total_amount = user_amount.amount_cumsum.max()
user_amount['prop'] = user_amount.amount_cumsum.apply(lambda x:x / total_amount)
user_amount.tail()
user_id | order_amount | amount_cumsum | prop | |
---|---|---|---|---|
23565 | 7931 | 6497.18 | 2463822.60 | 0.985405 |
23566 | 19339 | 6552.70 | 2470375.30 | 0.988025 |
23567 | 7983 | 6973.07 | 2477348.37 | 0.990814 |
23568 | 14048 | 8976.33 | 2486324.70 | 0.994404 |
23569 | 7592 | 13990.93 | 2500315.63 | 1.000000 |
转换成百分比。
user_amount.prop.plot()
如上图所示,横坐标是按贡献金额大小排序而成,纵坐标则是用户累计贡献。可以很清楚的看到,前20000个用户贡献了40%的消费。后面3000多位用户则贡献了60%,确实呈现二八倾向。
user_counts = df.groupby('user_id').order_dt.count().sort_values().reset_index()
user_counts['counts_cumsum'] = user_counts.order_dt.cumsum()
total_counts = user_counts.counts_cumsum.max()
user_counts['prop'] = user_counts.counts_cumsum.apply(lambda x:x / total_counts)
user_counts.prop.plot()
此时统计销量,前20000位用户贡献了45%的销量,剩下的高消费用户则贡献了55%的销量。在消费领域中,狼抓高质量用户是万古不变的道理。
- 接下来计算用户的生命周期,这里定义第一次消费至最后一次消费为整个用户生命。
user_purchase = df[['user_id', 'order_products', 'order_amount', 'order_date']]
order_date_min = user_purchase.groupby('user_id').order_date.min()
order_date_max = user_purchase.groupby('user_id').order_date.max()
(order_date_max - order_date_min).head(10)
user_id
1 0 days
2 0 days
3 511 days
4 345 days
5 367 days
6 0 days
7 445 days
8 452 days
9 523 days
10 0 days
Name: order_date, dtype: timedelta64[ns]
先统计出了用户的第一次消费和最后一次消费,然后相减即可。因为数据中的用户都是前三月第一次消费,所以这里的生命周期代表的是1月~3月用户的生命周期。因为用户会持续消费,所以理论上,随着后续的消费,用户的平均生命周期会增大。
(order_date_max - order_date_min).describe()
count 23570
mean 134 days 20:55:36.987696224
std 180 days 13:46:43.039788104
min 0 days 00:00:00
25% 0 days 00:00:00
50% 0 days 00:00:00
75% 294 days 00:00:00
max 544 days 00:00:00
Name: order_date, dtype: object
求一下平均,所有用户的平均生命周期是134天,比预想的高,但是中位数是0,说明了由很多用户第一次购买和最后一次购买都是在同一天。
为了更清楚地查看用户的生命周期,我们需要看一下分布。
((order_date_max - order_date_min) / np.timedelta64(1, 'D')).hist(bins = 15)
因为这里的数据类型是timedelta时间,它无法直接作出直方图,所以先换算成数值。划算的方式直接除timedelta函数即可,这里的np.timedelta64(1, ‘D’)即可。
从图中可以看出,大部分用户只消费了一次,所有生命周期的大头都集中在了0天。但这不是我们想要的答案,不妨将只消费了一次的新客排除,来计算所有消费过两次以上的老客的生命周期。
life_time = (order_date_max - order_date_min).reset_index() # 转为dataframe
life_time.head()
user_id | order_date | |
---|---|---|
0 | 1 | 0 days |
1 | 2 | 0 days |
2 | 3 | 511 days |
3 | 4 | 345 days |
4 | 5 | 367 days |
life_time['life_time'] = life_time.order_date / np.timedelta64(1, 'D')
life_time[life_time.life_time > 0].life_time.hist(bins=100, figsize=(12, 6))
筛选出lifetime>0,即排除了仅消费了一次的那些人。做直方图。
这个图的价值明显高于上图,虽然仍旧有不少用户生命周期靠拢0天。这是双峰趋势图,部分质量差的用户,虽然消费了两次,但是仍旧无法持续,在用户首次消费30天内应该尽量引导。少部分用户集中在50天~300天,属于普通型的生命周期,高质量用户的生命周期,集中在400天以后,这已经属于忠诚用户了。
life_time[life_time.life_time > 400].life_time.count() / life_time[life_time.life_time > 0].life_time.count()
0.31703716568252865
生命周期在400天以上的用户占老客的比例为31.7%,挺高的数值了。
life_time[life_time.life_time > 0].life_time.mean()
276.0448072247308
消费两次以上的用户生命周期为276天,远高于总体。从策略上看,用户首次消费后应该花费更多时间精力去引导其进行多次消费,延长生命周期,这会带来2.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()
user_id | order_products | order_amount | order_date | order_date_min | |
---|---|---|---|---|---|
0 | 1 | 1 | 11.77 | 1997-01-01 | 1997-01-01 |
1 | 2 | 1 | 12.00 | 1997-01-12 | 1997-01-12 |
2 | 2 | 5 | 77.00 | 1997-01-12 | 1997-01-12 |
3 | 3 | 2 | 20.76 | 1997-01-02 | 1997-01-02 |
4 | 3 | 2 | 20.76 | 1997-03-30 | 1997-01-02 |
这里用到merge函数,它和SQL中的join差不多,用来将两个dataframe进行合并。我们选择了inner的方式,对标SQL中的inner join。
这里merge的目的是将用户消费行为和第一次消费时间对应上,形成一个新的dataframe。
user_purchase_retention['order_date_diff'] = user_purchase_retention.order_date - user_purchase_retention.order_date_min
user_purchase_retention.head()
user_id | order_products | order_amount | order_date | order_date_min | order_date_diff | |
---|---|---|---|---|---|---|
0 | 1 | 1 | 11.77 | 1997-01-01 | 1997-01-01 | 0 days |
1 | 2 | 1 | 12.00 | 1997-01-12 | 1997-01-12 | 0 days |
2 | 2 | 5 | 77.00 | 1997-01-12 | 1997-01-12 | 0 days |
3 | 3 | 2 | 20.76 | 1997-01-02 | 1997-01-02 | 0 days |
4 | 3 | 2 | 20.76 | 1997-03-30 | 1997-01-02 | 87 days |
这里将order_date和order_date_min想减,获得一个新的列,为用户每一次消费距第一次消费的时间差值。
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)
user_id | order_products | order_amount | order_date | order_date_min | order_date_diff | date_diff | |
---|---|---|---|---|---|---|---|
0 | 1 | 1 | 11.77 | 1997-01-01 | 1997-01-01 | 0 days | 0.0 |
1 | 2 | 1 | 12.00 | 1997-01-12 | 1997-01-12 | 0 days | 0.0 |
2 | 2 | 5 | 77.00 | 1997-01-12 | 1997-01-12 | 0 days | 0.0 |
3 | 3 | 2 | 20.76 | 1997-01-02 | 1997-01-02 | 0 days | 0.0 |
4 | 3 | 2 | 20.76 | 1997-03-30 | 1997-01-02 | 87 days | 87.0 |
5 | 3 | 2 | 19.54 | 1997-04-02 | 1997-01-02 | 90 days | 90.0 |
6 | 3 | 5 | 57.45 | 1997-11-15 | 1997-01-02 | 317 days | 317.0 |
7 | 3 | 4 | 20.96 | 1997-11-25 | 1997-01-02 | 327 days | 327.0 |
8 | 3 | 1 | 16.99 | 1998-05-28 | 1997-01-02 | 511 days | 511.0 |
9 | 4 | 2 | 29.33 | 1997-01-01 | 1997-01-01 | 0 days | 0.0 |
bin = [0,3,7,15,30,60,90,180,365]
user_purchase_retention['date_diff_bin'] = pd.cut(user_purchase_retention.date_diff, bins = bin)
user_purchase_retention.head(15)
user_id | order_products | order_amount | order_date | order_date_min | order_date_diff | date_diff | date_diff_bin | |
---|---|---|---|---|---|---|---|---|
0 | 1 | 1 | 11.77 | 1997-01-01 | 1997-01-01 | 0 days | 0.0 | NaN |
1 | 2 | 1 | 12.00 | 1997-01-12 | 1997-01-12 | 0 days | 0.0 | NaN |
2 | 2 | 5 | 77.00 | 1997-01-12 | 1997-01-12 | 0 days | 0.0 | NaN |
3 | 3 | 2 | 20.76 | 1997-01-02 | 1997-01-02 | 0 days | 0.0 | NaN |
4 | 3 | 2 | 20.76 | 1997-03-30 | 1997-01-02 | 87 days | 87.0 | (60.0, 90.0] |
5 | 3 | 2 | 19.54 | 1997-04-02 | 1997-01-02 | 90 days | 90.0 | (60.0, 90.0] |
6 | 3 | 5 | 57.45 | 1997-11-15 | 1997-01-02 | 317 days | 317.0 | (180.0, 365.0] |
7 | 3 | 4 | 20.96 | 1997-11-25 | 1997-01-02 | 327 days | 327.0 | (180.0, 365.0] |
8 | 3 | 1 | 16.99 | 1998-05-28 | 1997-01-02 | 511 days | 511.0 | NaN |
9 | 4 | 2 | 29.33 | 1997-01-01 | 1997-01-01 | 0 days | 0.0 | NaN |
10 | 4 | 2 | 29.73 | 1997-01-18 | 1997-01-01 | 17 days | 17.0 | (15.0, 30.0] |
11 | 4 | 1 | 14.96 | 1997-08-02 | 1997-01-01 | 213 days | 213.0 | (180.0, 365.0] |
12 | 4 | 2 | 26.48 | 1997-12-12 | 1997-01-01 | 345 days | 345.0 | (180.0, 365.0] |
13 | 5 | 2 | 29.33 | 1997-01-01 | 1997-01-01 | 0 days | 0.0 | NaN |
14 | 5 | 1 | 13.97 | 1997-01-14 | 1997-01-01 | 13 days | 13.0 | (7.0, 15.0] |
pivoted_retention = user_purchase_retention.pivot_table(index='user_id', columns='date_diff_bin', values='order_amount', aggfunc=sum)
pivoted_retention.head(10)
date_diff_bin | (0, 3] | (3, 7] | (7, 15] | (15, 30] | (30, 60] | (60, 90] | (90, 180] | (180, 365] |
---|---|---|---|---|---|---|---|---|
user_id | ||||||||
1 | 0.0 | 0.0 | 0.00 | 0.00 | 0.00 | 0.0 | 0.00 | 0.00 |
2 | 0.0 | 0.0 | 0.00 | 0.00 | 0.00 | 0.0 | 0.00 | 0.00 |
3 | 0.0 | 0.0 | 0.00 | 0.00 | 0.00 | 40.3 | 0.00 | 78.41 |
4 | 0.0 | 0.0 | 0.00 | 29.73 | 0.00 | 0.0 | 0.00 | 41.44 |
5 | 0.0 | 0.0 | 13.97 | 0.00 | 38.90 | 0.0 | 110.40 | 155.54 |
6 | 0.0 | 0.0 | 0.00 | 0.00 | 0.00 | 0.0 | 0.00 | 0.00 |
7 | 0.0 | 0.0 | 0.00 | 0.00 | 0.00 | 0.0 | 0.00 | 97.43 |
8 | 0.0 | 0.0 | 0.00 | 0.00 | 13.97 | 0.0 | 45.29 | 104.17 |
9 | 0.0 | 0.0 | 0.00 | 0.00 | 0.00 | 0.0 | 30.33 | 0.00 |
10 | 0.0 | 0.0 | 0.00 | 0.00 | 0.00 | 0.0 | 0.00 | 0.00 |
用pivot_table数据透视,获得的结果是用户在第一次消费之后,在后续各时间段内的消费总额。
这里不难发现如果没有消费的话就是0,但为方便后续我们只统计有消费的用户,将0更改为nan。
pivoted_retention.replace(0,np.nan).mean()
date_diff_bin
(0, 3] 35.905798
(3, 7] 36.385121
(7, 15] 42.669895
(15, 30] 45.986198
(30, 60] 50.215070
(60, 90] 48.975277
(90, 180] 67.223297
(180, 365] 91.960059
dtype: float64
此时计算用户在后续时间段的平均消费额,这里只统计有消费的平均值。虽然后面时间段的金额高,但是它的时间范围也宽广。从平均效果来看,用户第一次消费后的0-3天内,更可能消费更多。
但消费更多是一个相对的概念,我们还要看整体中有多少用户在0-3天消费。
pivoted_retention_trans = pivoted_retention.applymap(lambda x: 1 if x>0 else 0)
pivoted_retention_trans.head()
date_diff_bin | (0, 3] | (3, 7] | (7, 15] | (15, 30] | (30, 60] | (60, 90] | (90, 180] | (180, 365] |
---|---|---|---|---|---|---|---|---|
user_id | ||||||||
1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
2 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
3 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 1 |
4 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 1 |
5 | 0 | 0 | 1 | 0 | 1 | 0 | 1 | 1 |
如果有消费就记为1,没有就是0.
(pivoted_retention_trans.sum() / pivoted_retention_trans.count()).plot.bar()
只有2.5%的用户在第一次消费后的三天内有过消费,3%的用户在3-7天内有过消费。数字并不好看,不过CD购买确实不是高频消费行为。时间范围放宽后数字好看了不少,有20%的用户在第一次消费后的三个月到半年之间有过购买,27%的用户在半年后到一年内有过购买。从运营角度看,CD机营销在教育新用户的同时,应该注重用户忠诚度的培养,放长线钓大鱼,在一定时间内召回用户购买。
- 怎么算放长线钓大鱼呢?我们计算出用户的平均购买周期。
def diff(group):
d = group.date_diff.shift(-1) - group.date_diff
return d
last_diff = user_purchase_retention.groupby('user_id').apply(diff)
last_diff.head(10)
user_id
1 0 NaN
2 1 0.0
2 NaN
3 3 87.0
4 3.0
5 227.0
6 10.0
7 184.0
8 NaN
4 9 17.0
Name: date_diff, dtype: float64
此时已经求出了用户的每次购买距离上次购买的时间差。
last_diff.mean()
68.97376814424265
此时求出了用户的平均消费间隔是68天。所以想要召回用户,在60天左右的消费间隔是比较好的。
last_diff.hist(bins = 20)
看一下直方图,典型的长尾分布,大部分的用户的消费间隔确实比较短。不妨将时间召回点设为消费后立即赠送优惠券,消费10天询问用户CD怎么样,消费30天后提醒优惠券到期,消费60天后短信推送。这便是数据的应用了。
推荐关注的专栏