案例:英国电商用户行为数据分析
1.1 数据集简介
https://archive.ics.uci.edu/ml/datasets/online+retail#
该数据集为英国在线零售商在2010年12月1日至2011年12月9日间发生的所有网络交易订单信息。
1.2 数据集内容
数据集为xlsx格式,文件大小22.6M。数据共计8个字段,541908条。具体字段如下:
InvoiceNo:发票编号。为每笔订单唯一分配的6位整数。若以字母'C'开头,则表示该订单被取消。
StockCode:产品代码。为每个产品唯一分配的编码。
Description:产品描述。
Quantity:数量。每笔订单中各产品分别的数量。
InvoiceDate:发票日期和时间。每笔订单发生的日期和时间。
UnitPrice:单价。单位产品价格,单位为英镑。
CustomerID:客户编号。为每个客户唯一分配的5位整数。
Country:国家。客户所在国家/地区的名称。
根据数据集提出问题如下:
1.订单维度:笔单价和连带率是多少?订单金额与订单内商品件数的关系如何?
2.客户维度:客单价是多少?客户消费金额与消费件数的关系如何?
3.商品维度:商品的价格定位是高是低?哪种价位的商品卖得好?哪种价位的商品带来了实际上最多的销售额?
4.时间维度:各月/各日的销售情况是什么走势?可能受到了什么影响?
5.区位维度:客户主要来自哪几个国家?哪个国家是境外主要市场?哪个国家的客户平均消费能力最强?
6.客户行为:客户的生命周期、留存情况、购买周期如何?
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
sales_df = pd.read_excel('./Online Retail.xlsx', sheet_name='Online Retail')
再查看一下各列的数据类型:
3.1 列名重命名
观察到字段InvoiceDate并非只包含了日期信息,同时也涵盖了具体的时分秒维度,故将其重命名为InvoiceTime:
# 列名重命名
sales_df.rename(columns={'InvoiceDate': 'InvoiceTime'}, inplace= True)
3.2 删除重复值
我们规定,若所有字段的值都完全相同,则视为重复数据,仅保留一条。
删除重复值,并根据去重前后的行数变化计算重复值数量:
# 删除重复值
rows_before = sales_df.shape[0] # 留意pandas对象的shape属性
sales_df.drop_duplicates(inplace= True)
rows_after = sales_df.shape[0]
print('原行数:', rows_before, '现行数:', rows_after, '删除行数:', rows_before - rows_after)
由于重复数据的删除,此时索引值和行数已不相符,进行索引的重设,并删除原索引:
# 重设索引,并删除原索引
sales_df.reset_index(drop=True, inplace = True) # 如果没有drop的话,会保留原索引
3.3 缺失值处理
# 查看缺失值
sales_df.isnull().sum()
Description是商品的文字描述,不是我们的分析重点,存在1454个空值,不予处理。
CustomerID是客户的唯一编号,很重要,缺失了135037行,将近总行数的1/5。但并不能直接删去有缺失值的行,会影响整体销售情况的分析。也无法采用插值法。姑且用‘0’来填充。虽说客户ID都是五位数字,但填充前还是先确认下是否真的不存在ID为‘0’的客户。
sales_df[sales_df['CustomerID'] == '0']
# 填充缺失的CustomerID
sales_df['CustomerID'].fillna('0', inplace=True)
3.4 一致化处理
3.4.1 时间相关信息的一致化
首先将InvoiceTime转为pandas能处理的时间格式datetime:
# 一致化处理
sales_df['InvoiceTime']=pd.to_datetime(sales_df['InvoiceTime'], errors='coerce')
再新增字段Date存放InvoiceTime中的日期部分:
sales_df['Date'] = pd.to_datetime(sales_df['InvoiceTime'].dt.date, errors='coerce')
新增Month存放月份信息:
sales_df['Month'] = sales_df['InvoiceTime'].dt.month # 这样得到month依旧是一个整形数据
由于我们对时间相关字段的操作有可能产生缺失值,再次查看缺失值情况:
sales_df.isnull().sum()
3.4.2 重新规整数字类型
将UnitPrice转为浮点型,Quantity和CustomerID转为整型:
sales_df['Quantity'] = sales_df['Quantity'].astype('int32')
sales_df['UnitPrice'] = sales_df['UnitPrice'].astype('float')
sales_df['CustomerID'] = sales_df['CustomerID'].astype('int32')
sales_df['InvoiceNo'] = sales_df['InvoiceNo'].astype('str') # 后面检查C字段订单的时候,发现这里需要是字符串类型
增加字段SumPrice用于存放该行数据的总价:
# 计算总价
sales_df['SumPrice'] = sales_df['Quantity'] * sales_df['UnitPrice']
3.5 异常值处理
为了探究是否存在异常值,我们查看总体的描述性统计情况:
sales_df.describe()
发现Quantity数量、UnitPrice单价、SumPrice总价都存在负值的情况,且绝对值较大。而总价是由数量和单价相乘得到,总价为负实际上也是数量和单价二者之一为负值导致的。
故查看数量或单价非正值的数据:
sales_df[(sales_df['Quantity'] <= 0)|(sales_df['UnitPrice'] <= 0)]
初步浏览,注意到主要是 ①C字头的被取消订单 和 ②单价为0的免费订单 导致的异常。
3.5.1 C字头的取消订单
取消订单产生的负值在之后销售情况的分析中会产生干扰,考虑将sales_df分为只含成功订单和只含取消订单两部分。
我们需要探究取消订单是直接在原订单上进行的修改,还是用来抵消原订单的新增数据。以上图第一行为例来说,即是否存在536379订单,与C536379订单对应。(注意这种思考的逻辑)
将sales_df分为成功订单和取消订单两部分,划分依据是发票编号InvoiceNo是否含有“C”:
query_c = sales_df['InvoiceNo'].str.contains('C')
# 只含取消订单
sales_cancel = sales_df.loc[query_c,:].copy()
# 只含成功订单
sales_success = sales_df.loc[-query_c,:].copy()
为sales_cancel增加字段SrcInvoiceNo,用于存放去掉“C”的发票编号:
# 增加原订单号
sales_cancel['SrcInvoiceNo'] = sales_cancel['InvoiceNo'].str.split('C', expand=True)[1]
将sales_cancel和sales_success进行合并:
print('merge之前,sales_cancel的shape为:{}'.format(sales_cancel.shape))
print('merge之前,sales_success的shape为:{}'.format(sales_success.shape))
new_data = pd.merge(sales_cancel, sales_success, left_on='SrcInvoiceNo',right_on='InvoiceNo')
print('merge之后,new_data的shape为:{}'.format(new_data.shape))
可以确认发现取消订单和成功订单并无对应关系。
原来的536641行数据中,取消订单占了9251行。
3.5.2 单价为0的免费订单
推测单价为0的订单是促销活动的赠品,对于订单量、件单价、连带率等指标的计算造成干扰,故也单独分出一张表存放,之后再对免费订单进行分析:
query_free = sales_success['UnitPrice'] == 0
# 只含免费订单
sales_cancel = sales_success.loc[query_free,:].copy()
# 只含普通订单
sales_success = sales_success.loc[-query_free,:]
print(sales_success.describe())
3.5.3 单价为负的订单
查询出此类订单,并消除:
query_minus = sales_success['UnitPrice'] < 0
sales_success = sales_success.loc[-query_minus, :]
print('至今,sales_success还有数据:{}'.format(sales_success.shape))
print(sales_success.describe())
至此数据清洗告一段落,sales_success还余524878行数据:!
数据可视化之前的所有code:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
# 读入数据
sales_df = pd.read_excel('./Online Retail.xlsx', sheet_name='Online Retail')
# 列名重命名
sales_df.rename(columns={'InvoiceDate': 'InvoiceTime'}, inplace=True)
# 删除重复值
rows_before = sales_df.shape[0]
sales_df.drop_duplicates(inplace=True)
rows_after = sales_df.shape[0]
# 填充空值
sales_df['CustomerID'].fillna('0', inplace=True)
# 增添新列(日期和月份)
sales_df['InvoiceTime'] = pd.to_datetime(sales_df['InvoiceTime'], errors='coerce')
sales_df['Date'] = pd.to_datetime(sales_df['InvoiceTime'].dt.date, errors='coerce')
sales_df['Month'] = sales_df['InvoiceTime'].dt.month
# 数据类型转换
sales_df['Quantity'] = sales_df['Quantity'].astype('int32')
sales_df['UnitPrice'] = sales_df['UnitPrice'].astype('float')
sales_df['CustomerID'] = sales_df['CustomerID'].astype('int32')
sales_df['InvoiceNo'] = sales_df['InvoiceNo'].astype('str')
# 计算总价
sales_df['SumPrice'] = sales_df['Quantity'] * sales_df['UnitPrice']
# 拆分订单
query_c = sales_df['InvoiceNo'].str.contains('C')
# 只含取消订单
sales_cancel = sales_df.loc[query_c, :].copy()
# 只含成功订单
sales_success = sales_df.loc[-query_c, :].copy()
# 增加原订单号
sales_cancel['SrcInvoiceNo'] = sales_cancel['InvoiceNo'].str.split('C', expand=True)[1]
new_data = pd.merge(sales_cancel, sales_success, left_on='SrcInvoiceNo',right_on='InvoiceNo')
query_free = sales_success['UnitPrice'] == 0
# 只含免费订单
sales_free = sales_success.loc[query_free, :].copy()
# 只含普通订单
sales_success = sales_success.loc[-query_free, :]
query_minus = sales_success['UnitPrice'] < 0
sales_success = sales_success.loc[-query_minus, :]
print('至今,sales_success还有数据:{}'.format(sales_success.shape))
4.1.1 订单维度
首先将sales_success按订单号分组,对Quantity商品数量和SumPrice总价分组求和:
invoice_grouped = sales_success.groupby('InvoiceNo')[['Quantity', 'SumPrice']].sum()
在这里理解一个东西,字段InvoiceNo是订单ID,字段StockCode 是产品ID,但是有可能一个订单会有多种产品,因此会有重复的InvoiceNo,在这里以536365为例,打印一下:
通过describe获得笔单价(每笔订单的平均交易金额)和连带率(每笔订单平均购买的产品件数):
# 笔单价 与 连带率
# 笔单价 = 总销售额 / 总笔数
# 连带率 = 售出商品总数 / 总笔数
invoice_grouped.describe()
统计区间(2010年12月1日-2011年12月9日)内共产生有效订单19960笔,笔单价为533.17英镑,连带率约为279件,说明以批发性质的订单为主。订单交易金额和订单内商品件数,其均值都高于中位数;订单交易金额的均值甚至高于Q3分位数。说明订单总体差异大,存在部分购买力极强的客户。
接下来绘制订单交易金额的分布图:
invoice_grouped['SumPrice'].hist(bins = 100, figsize = (12, 4), color = 'c')
plt.title('SumPrice Distribution of Orders')
plt.ylabel('Frequency')
plt.xlabel('SumPrice')
部分订单交易金额过大,影响图表的可读性,筛去1000英镑及以上的订单:
invoice_grouped[invoice_grouped.SumPrice < 1000]['SumPrice'].hist(bins = 100, figsize = (12, 4), color = 'c')
plt.title('SumPrice Distribution of Orders (Below 500)')
plt.ylabel('Frequency')
plt.xlabel('SumPrice')
订单金额集中在400英镑内,三个峰值分别为20英镑内、100-230英镑、300-320英镑。其中300-320英镑的订单数量特别多,不知道是否存在某种共性,之后可以进行进一步探究。
对订单内商品数量的分布同样绘制柱形图,并筛去2000件及以上的订单:
invoice_grouped[invoice_grouped.Quantity < 2000]['Quantity'].hist(bins = 50, figsize = (12, 4), color = 'c')
plt.title('Quantity Distribution of Orders (Below 2000)')
plt.ylabel('Frequency')
plt.xlabel('Quantity')
订单内的商品数量呈现出很典型的长尾分布,大部分订单的商品数量在250件内,商品数量越多,订单数相对越少。
为了进一步探究订单交易金额与订单内商品件数的关系,我们绘制散点图:
plt.figure(figsize=(14,4))
# plt.subplot用于绘制子图,121表示分成1*2个图片区域,占用第一个。
plt.subplot(121)
plt.scatter(invoice_grouped['Quantity'], invoice_grouped['SumPrice'], color = 'c')
plt.title('SumPrice & Quantity')
plt.ylabel('SumPrice')
plt.xlabel('Quantity')
# 筛去商品件数在20000及以上的订单
plt.subplot(122)
plt.scatter(invoice_grouped[invoice_grouped.Quantity < 20000]['Quantity'], invoice_grouped[invoice_grouped.Quantity < 20000]['SumPrice'], color = 'c')
plt.title('SumPrice & Quantity (Quantity < 20000)')
plt.ylabel('SumPrice')
plt.xlabel('Quantity')
plt.show()
总体来说订单交易金额与订单内商品件数是正相关的,订单内的商品数越多,订单金额也相对越高。但在Quantity靠近0的位置也有若干量少高价的订单,后续可以试探究。
4.1.2 客户维度
仅对含有CustomerID的客户进行分析:
sales_customer = sales_success[sales_success['CustomerID'] != 0].copy()
统计各个客户的订单数量、消费金额和商品购买数。先按客户ID和订单编号分组,对同笔订单的商品数量和销售金额求和;再用reset_index重设索引;最后再按客户ID分组:(这个逻辑无敌)
人均购买笔数为4笔,中位数为2笔,25%以上的客户仅下过一次单,并未留存。每位客户平均购买了1187件商品,甚至超过了Q3分位数,最多的客户购买了196915件;客单价为2049英镑,平均值同样超过了Q3分位数,说明客户的购买力存在较大差距,存在小部分的高消费用户拉高了人均数值。
进一步观察客户消费金额的分布:
customer_grouped.SumPrice.hist(bins = 50, figsize = (12, 4), color = 'b')
plt.title('SumPrice Distribution of Customers')
plt.ylabel('Frequency')
plt.xlabel('SumPrice')
plt.show()
从直方图看,大部分用户的消费能力确实不高,高消费用户在图上几乎看不到。这也确实符合消费行为的行业规律。
截取消费额5000英镑以内的客户:
plt.figure()
customer_grouped[customer_grouped.SumPrice < 5000].SumPrice.hist(bins = 60, figsize = (12, 4), color = 'b')
plt.title('SumPrice Distribution of Customers (Below 5000)')
plt.ylabel('Frequency')
plt.xlabel('SumPrice')
plt.show()
与前面订单金额的多峰分布相比,客户消费金额的分布呈现单峰长尾形态,金额更为集中,峰值在83-333英镑间。
绘制客户消费金额与消费件数的散点图:
plt.figure(figsize=(14,4))
plt.subplot(121)
plt.scatter(customer_grouped['Quantity'], customer_grouped['SumPrice'], color = 'b')
plt.title('SumPrice & Quantity')
plt.ylabel('SumPrice')
plt.xlabel('Quantity')
plt.subplot(122)
plt.scatter(customer_grouped[customer_grouped.Quantity < 25000]['Quantity'], customer_grouped[customer_grouped.Quantity < 25000]['SumPrice'], color = 'b')
plt.title('SumPrice & Quantity (Quantity<25000)')
plt.ylabel('SumPrice')
plt.xlabel('Quantity')
plt.show()
客户群体比较健康,而且规律性比订单更强,同时拥有一定数量消费能力强的用户。总体来说客户的消费金额与购买的商品数量是正相关的,客户购买的东西越多,消费金额相对就越高。
4.1.3 商品维度
根据观察,发现相同的商品在不同的订单中单价不同,可知商品的单价会发生波动,以商品10002为例:
# 要确认好使 数值类型 还是 字符串类型
sales_success.loc[sales_success['StockCode'] == 10002,:].UnitPrice.value_counts()
所以接下来求每件商品的平均价格,思路是平均价格=该商品的总销售额 / 该商品的销售数量。具体来说,先按商品编号进行分组,对数量和总价分别求和,即得到对应商品的总销售金额和总销售量,取商即得到平均价格:
goods_grouped = sales_success.groupby('StockCode')[['Quantity', 'SumPrice']].sum()
goods_grouped['AvgPrice'] = goods_grouped['SumPrice'] / goods_grouped['Quantity']
goods_grouped.head()
goods_grouped.AvgPrice.hist(bins=100)
plt.title('AvgPrice Distribution')
plt.ylabel('Frequency')
plt.xlabel('SumPrice')
plt.show()
发现商品价位基本上全部集中在100英镑内,出现了极少量的天价商品影响观测,将其筛去:
plt.figure()
goods_grouped[goods_grouped.AvgPrice < 100].AvgPrice.hist(bins=100,figsize = (12, 4))
plt.title('AvgPrice Distribution (Below 100)')
plt.ylabel('Frequency')
plt.xlabel('SumPrice')
plt.show()
峰值是1-2英镑,单价10英镑以上的商品已经很少见,看来该电商的定位主要是价格低的小商品市场。
接下来查看商品单价和商品销量的散点图,可以看出哪种价位的商品更受欢迎:
# 商品单价和销售数量的散点图
plt.figure(figsize=(14,4))
plt.subplot(121)
plt.scatter(goods_grouped['AvgPrice'], goods_grouped['Quantity'], color = 'r')
plt.title('AvgPrice & Quantity')
plt.ylabel('Quantity')
plt.xlabel('AvgPrice')
plt.subplot(122)
plt.scatter(goods_grouped[goods_grouped.AvgPrice < 50]['AvgPrice'], goods_grouped[goods_grouped.AvgPrice < 50]['Quantity'], color = 'r')
plt.title('AvgPrice & Quantity (AvgPrice < 50)')
plt.ylabel('Quantity')
plt.xlabel('AvgPrice')
plt.show()
从商品的销量上来看,毫无疑问是低于5英镑的低价区商品大获全胜,受到了客户们的喜爱。
那么是否价格低廉的商品也带来了实际上最多的销售额呢?不妨绘制商品单价和商品总销售额的散点图:
# 商品单价和销售金额的散点图
plt.figure(figsize=(14,4))
plt.subplot(121)
plt.scatter(goods_grouped['AvgPrice'], goods_grouped['SumPrice'], color = 'r')
plt.title('AvgPrice & SumPrice')
plt.ylabel('SumPrice')
plt.xlabel('AvgPrice')
plt.subplot(122)
plt.scatter(goods_grouped[goods_grouped.AvgPrice < 50]['AvgPrice'], goods_grouped[goods_grouped.AvgPrice < 50]['SumPrice'], color = 'r')
plt.title('AvgPrice & SumPrice (AvgPrice < 50)')
plt.ylabel('SumPrice')
plt.xlabel('AvgPrice')
plt.show()
低价区的商品笑到了最后,不仅在销售数量上一骑绝尘,也构成了销售额的主要部分;高价的商品虽然单价高昂,但销量很低,并没有带来太多的销售额。据此,建议平台采购部门可以多遴选售价低于10英镑的产品,来进一步扩充低价区的品类。
4.1.4 时间维度
按订单号分组,提取出我们需要的信息:
time_grouped = sales_success.groupby('InvoiceNo').agg({'Date': np.min, 'Month': np.min, 'Quantity': np.sum, 'SumPrice': np.sum}).reset_index()
这里Date和Month取最小值或最大值都可以,因为都是相同的。(这是同一个订单,时间当然相同)
以月份为单位进行折线图绘制,这里对Quantity和SumPrice分组求和,代表每月的销量和销售额,对InvoiceNo计数,代表每月的订单数。此处采用双坐标图,销量和销售额为左轴,参数secondary_y = 'InvoiceNo’表示订单数为右轴:
month = time_grouped.groupby('Month').agg({'Quantity': np.sum, 'SumPrice': np.sum, 'InvoiceNo': np.size}).plot(secondary_y = 'InvoiceNo', x_compat=True, figsize = (12, 4))
month.set_ylabel('Quantity & SumPrice')
month.right_ax.set_ylabel('Order quantities')
plt.show()
需要注意此处2011年12月仅统计了前9天,如果全月能基本保持前9天的销售情况,销售额会远超2010年同期。
观察到三条折线总体上呈现相近的趋势,除了2011年2月和4月略低外,2010年12月至2011年8月基本维持相近的销售情况;随后在9月-11月连续增长,达到高峰。考虑该电商平台主营礼品,受节日影响可能较大。欧洲重视的万圣节(11月1日)和圣诞节(12月25日)都在年末,与图中的趋势能够相呼应;同时虽然感恩节(11月第4个周四)是美国节日,但“黑五”的营销方式对全球都产生了一定影响。
将日期设为索引,按日绘制折线图:
plt.figure()
time_grouped = time_grouped.set_index('Date')
day = time_grouped.groupby('Date').agg({'Quantity': np.sum, 'SumPrice': np.sum, 'InvoiceNo': np.size}).plot(secondary_y = 'InvoiceNo', figsize = (15, 5))
day.set_ylabel('Quantity & SumPrice')
day.right_ax.set_ylabel('Order quantities')
plt.show()
可见销量Quantity和销售额SumPrice的趋势是极趋同的,这也和前一节中分析出该电商以低价商品为主相吻合,商品单价低且价位集中,则销售额主要随销量变化而涨跌。
注意到在最后一天(即2011年12月9日),销量、销售额显著激增,我们放大看看:
# 节取2011年10月1日至2011年12月9日
plt.figure()
day_part = time_grouped['2011-10-01':'2011-12-09'].groupby('Date').agg({'Quantity': np.sum, 'SumPrice': np.sum, 'InvoiceNo': np.size}).plot(
secondary_y = 'InvoiceNo', figsize = (15, 5))
day_part.set_ylabel('Quantity & SumPrice')
day_part.right_ax.set_ylabel('Order quantities')
plt.show()
2011年12月的前8天基本延续了11月下旬的销售趋势,但在12月9日订单量大幅下降时,却创造了样本区间内销量和销售额的历史新高。说明存在某笔或某几笔购买量极大的订单,从而使得销售额大幅上升。
将当日的销售详单拉取出来:
sales_success[sales_success.Date == '2011-12-09'].sort_values(by='SumPrice', ascending=False).head()
破案了,有一个英国的客户,一口气购买了8万余件的纸工艺品,贡献了168469.60英镑的销售额。建议对大客户配备固定客服,好及时获知对方的需求与意见,并增加客户的认同感。不过反过来说,12月之后的20余天应该是无法保持这么迅猛的销售势头了。
4.1.5 区位维度
首先提取出一张客户ID及其国家的关系表:
sales_country = sales_success.drop_duplicates(subset=['CustomerID', 'Country'])[['CustomerID', 'Country']]
按客户分组,计算消费总额:
country_grouped = sales_success.groupby('CustomerID')[['SumPrice']].sum().reset_index()
将上述两张表合并:
country_grouped = pd.merge(country_grouped, sales_country, left_on='CustomerID', right_on='CustomerID')
按国家再次分组,计算出各国客户的消费总额和客户总数:
country_grouped = country_grouped.groupby('Country').agg({'SumPrice': np.sum, 'CustomerID': np.size})
新增AvgAmount字段,用于存放该国家客户的人均消费金额:
country_grouped['AvgAmount'] = country_grouped['SumPrice'] / country_grouped['CustomerID']
对消费总额降序排列:
country_grouped.sort_values(by='SumPrice',ascending=False).head(20)
可知绝大部分客户仍来自英国本土,主要境外收入来源也多为英国周边国家,基本上符合以英国为圆心向外辐射的情况。这种现象可能和运输成本及语言等有关,也可能是影响力随距离而衰减,可以尝试增加境外的宣传投放,提高知名度;同时建议网站做好多国语言的适配,也可以在网站上对于境外物流费用计算及手续办理等事项给出更易懂的说明。
4.2.1 客户的生命周期
该数据集的统计对象为2010年12月1日至2011年12月9日的全部订单,我们这节研究的客户是指所有成功的普通订单中有CustomerID的客户,不包含未记录CustomerID的客户,下同。
其实样本中的客户有许多未进行完整的生命周期 ,我们并不知道在统计时段前他们是否购买过,也不知道在统计时段后他们中的哪些会继续购买。所以这里计算的生命周期是有局限性的,真实的客户平均生命周期必然会更长。
首先筛选出CustomerID不为空的客户(NaN之前被我们填充为0):
sales_customer = sales_success[sales_success['CustomerID'] != 0].copy()
查看用户的初次与末次(最近)消费时间:
# 客户的初次消费时间
mindate = sales_customer.groupby('CustomerID')[['Date']].min()
# 客户的末次消费时间
maxdate = sales_customer.groupby('CustomerID')[['Date']].max()
查看用户的初次/末次消费集中在哪些日期:
mindate.Date.value_counts().head(10)
maxdate.Date.value_counts().head(10)
发现初次消费的高频日期为统计时段的初期,末次消费的高频日期为统计时段的末期。说明有大量用户的生命周期被低估,实际上还要向前向后延伸。
末次消费日期减去初次消费日期得到统计时段内的生命周期,展示前5行:
(maxdate - mindate).head()
再看一下客户生命周期的总体情况:
life_time = maxdate - mindate
life_time.describe()
共有4338个有CustomerID的客户,其平均生命周期为130天,中位数则是93天,说明有部分生命周期很长的忠实客户拉高了均值;而最小值和Q1分位数都为0天,说明存在25%以上的客户仅消费了一次,生命周期的分布呈两极分化的状态。
接下来,绘制柱形图观察客户生命周期的实际分布。
这里的时间差是timedelta类型,无法绘制柱形图,将其先转化为数值:
life_time['life_time'] = life_time['Date'].dt.days # .dt.days提取出来就是数值了!
绘制20个分组的柱形图:
life_time['life_time'].hist(bins=20, color='c')
plt.title('Life Time Distribution')
plt.ylabel('Customer number')
plt.xlabel('Life time (days)')
plt.show()
横坐标代表生命周期的天数区间,纵坐标为区间内的客户数。
许多客户仅消费过一次,没有留存下来,需要更加重视客户初次购买的体验感,可以考虑通过网站内服务评价、客服电询等方式获知新客对于购买流程中不满意之处,针对性地加以改进;同时应该对新客采取吸引其二次购买的手段,如发放有时限的优惠券等。有趣的是在350天左右出现一个次高峰,不妨将生命周期为0天的客户排除掉再看看分布:
# 将分组增多至100,并拉宽图表的尺寸
plt.figure()
life_time[life_time['life_time'] > 0].life_time.hist(bins = 100, figsize = (12, 6), color = 'c')
plt.title('Life Time Distribution without One-time Deal Hunters')
plt.ylabel('Customer number')
plt.xlabel('Life time (days)')
plt.show()
这个数据漂亮得有些惊人。生命周期在0-75天的客户数略高于75-170天,可以考虑加强前70天内对客户的引导。约1/4的客户集中在170天-330天,属于较高质量客户的生命周期;而在330天以后,则是数量可观的死忠客户,拥有极高的用户粘性。考虑到这些客户中有许多未进行完整的生命周期 ,实际的客户平均生命周期会更长。
# 消费两次及以上的用户平均生命周期
life_time[life_time['life_time'] > 0].life_time.mean()
# out:203.32867383512544
消费两次及以上的用户平均生命周期是203天,远高于总体均值103天。从策略看,用户首次消费后应该花更多的精力引导其进行多次消费,能有效提高生命周期。
4.2.2 客户的留存情况
客户的生命周期实际上是首次和末次消费的时间差,故无法对客户各月的消费情况获得直观的感受。因此接下来我们对客户的留存情况展开探究。这里需要说明的是,同样由于样本统计区间的缘故,我们无法判断2010年12月1日至2011年12月9日内的首次消费是否是该客户的历史首次消费。
先将用户首次消费日期合并进sales_customer中,suffixes参数是对重名的字段自定义后缀:
customer_retention = pd.merge(sales_customer, mindate, left_on='CustomerID', right_index=True, how='inner', suffixes=('', 'Min'))
新增字段DateDiff,用于存放本次消费日期与首次消费日期的时间差,并转为数值:
customer_retention['DateDiff'] = (customer_retention.Date - customer_retention.DateMin).dt.days
对时间差分段,我这里将3天、7天、30天、60天、90天、180天作为区间端点,并新增字段DateDiffBin来存放:
date_bins = [0, 3, 7, 30, 60, 90, 180]
customer_retention['DateDiffBin'] = pd.cut(customer_retention.DateDiff, bins = date_bins)
customer_retention['DateDiffBin'].value_counts()
DateDiffBin代表客户该笔订单的消费时间距其首次消费属于哪个时间段。因为计算的是留存,如果客户仅消费了一次(当日多次消费也视作一次),我们认为该客户是流失了的,这里DateDiff=0,并不会被划分入(0, 3]天的开闭区间内。
接下来用pivot_table作数据透视表,这里index相当于数据透视表的行,columns相当于列,values表示聚合对象,aggfunc表示聚合方法。对SumPrice求和,获得的结果是客户首次消费后,在后续各时间段内的消费总金额:
retention_pivot = customer_retention.pivot_table(index = ['CustomerID'], columns = ['DateDiffBin'], values = ['SumPrice'], aggfunc= np.sum)
print(retention_pivot)
NaN表示该客户在该区间内未进行过消费,聚合值全为NaN的客户会被过滤,即透视表中全为消费2次及以上的留存客户。
将数据转换成是否,1代表在该时间段内有后续消费,0代表没有:
retention_pivot_trans = retention_pivot.fillna(0).applymap(lambda x:1 if x > 0 else 0)
retention_pivot_trans.head()
统计留存客户首次消费后各时间段内的购买率,这里对各列中的1求和,再除以计数值:
(retention_pivot_trans.sum()/ retention_pivot_trans.count())
(retention_pivot_trans.sum()/ retention_pivot_trans.count()).plot.bar()
plt.show()
在这些老客户中,只有3.2%在第一次消费的次日至3天内有过消费,6.6%的客户在4-7天有过消费。分别有40.5%和37.4%的客户在首次消费后的第二个月内和第三个月内有过购买行为。将时间范围继续放宽,有高达67%的客户在90天至半年内消费过。说明该电商网站的客户群体,其采购并非高频行为,但留存下来的老客户忠诚度却极高。结合前文,仅有首次购买行为的客户占总客户的37.5%,如能提高这部分群体的留存率,将会带来很高的收益。
4.2.3 客户的购买周期
接下来计算客户的购买周期,思路是对其相邻两次消费日期相减,这里我们通过之前得到的DateDiff来运算。
对customer_retention去除客户编号和消费日期都相同的重复数据(即相同订单的不同商品),参数keep='first’表示保留重复值中的第一条;由于要考虑到相邻的问题,再对日期进行升序排序:
sales_cycle = customer_retention.drop_duplicates(subset=['CustomerID', 'Date'], keep='first')
sales_cycle.sort_values(by = 'Date',ascending = True)
定义函数diff,用于计算相邻两次消费的时间差:
def diff(group):
d = group.DateDiff - group.DateDiff.shift()
return d
shift()是往上偏移一个位置,shift(-1)是往下偏移一个位置,求客户本次消费与上次消费的时间间隔,用当前值减去shift()即可。若为NaN,则仅消费过一次。
先按客户编码分组,在应用diff函数:
last_diff = sales_cycle.groupby('CustomerID').apply(diff)
得到结果如下:
last_diff.head(10)
第一列为客户编码,第二列是索引值,第三列表示本次消费同上次消费的时间差。
保险起见,可以提取客户编码12347的订单详情进行核对,确保我们的函数没有写错:
sales_cycle[customer_retention['CustomerID'] == 12347]
与last_diff的日期间隔一致,函数使用正确。经过计算正好是50天。
画出按订单统计的购买周期柱状图,查看其分布:
last_diff.hist(bins = 70, figsize = (12, 6), color = 'c')
典型的长尾分布,大部分购买行为的消费间隔比较短。但这是所有订单的购买周期分布,并不是对客户个体为统计单位的购买周期分布。故对客户编号进行分组:
last_diff_customer = last_diff.groupby('CustomerID').mean()
last_diff_customer.hist(bins = 70, figsize = (12, 6), color = 'c')
一个右偏分布,峰值在15-70天,说明大部分留存客户的购买周期集中于此。建议可以每隔30天左右对客户进行些优惠活动的信息推送,比较符合大部分老客户的购买周期。
有效订单共19960笔,笔单价为533.17英镑,连带率约为279件。订单以批发性质为主,订单间差异较大,存在部分购买力极强的客户。总体来说订单交易金额与订单内商品件数正相关。
客单价为2049英镑,客户的购买力存在较大差距,拥有一定数量消费能力强的客户。客户群体比较健康,其消费金额与购买商品数量正相关,而且规律性比订单更强。
商品的单价会发生波动,集中于1-2英镑,定位主要是低价的小商品市场。低于5英镑的商品最受客户喜爱,同时也构成了销售额的主要部分。高价的商品虽然单价不菲,但销量很低,并没有带来太多的销售额。建议平台采购部门可以多遴选售价低于10英镑的产品,来进一步扩充低价区的品类。
订单数、销量、销售额总体上呈现相近的趋势, 在2011年9月-11月连续增长,达到高峰。考虑到主营商品为礼品,猜测受节日影响可能较大,如万圣节(11月1日)和圣诞节(12月25日),也可能受到“黑五”(11月第4个周四)影响。商品单价低且价位集中,销售额主要随销量变化而涨跌。
绝大部分客户来自英国本土,主要境外收入也多来自周边国家,基本上符合以英国为圆心向外辐射的情况。可能和运输成本及语言等有关,也可能是影响力随距离而衰减。可以考虑增加境外的宣传投放,提高知名度;同时建议网站做好多国语言的适配。
平均生命周期为130天,生命周期的分布呈两极分化的状态。消费两次及以上的客户平均生命周期是203天,远高于总体均值103天。建议更加重视客户初次消费的体验感,可以考虑通过网站内服务评价、客服电询等方式获知新客对于购买流程中不满意之处,针对性地加以改进;并且花更多的精力引导其进行再次消费,如发放有时限的优惠券等。
客户群体的采购并非高频行为,但留存下来的老客户忠诚度极高。而仅有首次购买行为的客户占总客户的37.5%,如能提高这部分群体的留存率,将会带来很高的收益。
大部分留存客户的购买周期集中在15-70天,建议可以每隔30天左右对客户进行些优惠活动的信息推送。