{T}

任务背景

通过上一次对营销短信的人群与时段的出色分析,推广活动圆满达成了目标,当日订单量不断刷新新高。但与此同时,另一个问题暴露出来:部分供应商对活动准备不足,导致生产跟不上,很多商品发货周期变长,用户的整体满意度受到影响。

接下来即将迎来下一次的大促节日。这次节日同以往不同:用户在开始活动的前三天内只能浏览商品、收藏或添加购物车,不能下单,等三天过后才开始秒杀下单。为了避免再次出现准备不足的问题。

这一次的任务很明确:根据用户前三天预热期的行为记录,预测出每款商品的销量,这样可提前知会供应商按照预测销量进行准备,避免之前准备不足的问题。

这次能拿到的数据和上次一样。

  • 用户行为表:最近 6 个月的用户行为记录。
  • vip 会员表:用户 vip 会员开通情况。
  • 用户信息表:用户的相关信息。

三个表的字段说明如下所示。

用户行为表(user_behavior_time_resampled.csv)

字段说明
user_id用户 id
item_id商品 id
seller_id店铺(商家)id
action_type行为类型:0 点击、1 加入购物车、2 下单、3 收藏
brand_id品牌 id(存在缺失值)
time行为发生时间

vip 会员表(vip_user.csv)

字段说明
user_id用户 id
merchant_id商家 id(后重命名为 seller_id 以便关联)
label是否为该商家 VIP 用户:1 是、0 否

用户信息表(user_info.csv)

字段说明
user_id用户 id
age_range年龄段
gender性别
其他用户基础属性字段

问题分析

从任务说明可以发现,核心任务是从其他行为(点击、添加收藏、添加购物车)预测商品的销量。这和上一次的分析维度不同,这次主要是从商品维度进行分析。

三个数据源中,VIP 会员表和用户信息表基本都是用户的信息,而用户行为表则包含了商品的信息,所以这次重点从用户行为表入手。但面临的挑战是:用户行为表是面向行为的,每一行代表一个用户的一次行为,可能是点击、可能是加入购物车等。首先需要从这个表中抽象出一个面向商品的表,然后再尝试建立预测销量的模型。

数据清洗

首先导入必要的工具包以及加载用户行为表。

code
import pandas as pd
import numpy as np
df_user_log = pd.read_csv("data_set/user_behavior_time_resampled.csv")
df_vip_user = pd.read_csv("data_set/vip_user.csv")

先回顾 user_log 表的基本情况,打印出来看一下。

code
df_user_log

输出为:

该表一共有 1098 万行记录,用户的每一次行为就是一行。其中本次任务主要关心的是 item_id(用于区分商品),以及 action_type(用户行为的标记)。

现在查看这个表的缺失值记录。

code
df_user_log.isnull().sum()

输出:

code
user_id            0
item_id            0
cat_id             0
seller_id          0
brand_id       18132
time_stamp         0
action_type        0
timestamp          0
click              0
cart               0
order              0
fav                0
dtype: int64

brand_id 有 18132 个缺失值,但本次任务并不需要考虑 brand_id,所以不需要处理。

下一步查看 action_type 的取值。从数据集的描述来看,action_type 只存在 0-3 的取值,由于这个字段很重要,确认是否存在异常值。

code
df_user_log["action_type"].value_counts()

输出为:

code
0    9709458
2     659577
3     600738
1      15293
Name: action_type, dtype: int64

可以看到,action_type 的取值没有异常值,并且点击行为远高于其他三种行为,其中加到购物车的数量最少。

目前数据集已基本满足需求,接下来进入下一个阶段:特征工程。

特征工程

为什么需要特征工程

在绝大多数需要建立模型的数据分析任务里,特征工程都是必不可少的环节。顾名思义,特征工程就是指准备特征、筛选特征的工作,只是这项工作具有一定的重要性和复杂度,所以也会叫作工程。

以线性回归模型为例,通过上一节的学习,都知道线性回归模型是找出自变量和因变量的关系。现实问题中,因变量很好找,因为往往因变量就是预测的任务。比如在这个例子中,因变量就是预测目标:商品的销量。

另一方面,自变量,也称为模型的特征,往往没那么明显。一个原因是因变量往往受非常多的因素影响,有直接的也有间接的;另一个原因是拿到的数据集上往往没有现成的特征,需要从原始数据集中经过分析、变换之后才能得到要的特征。

特征分析

特征工程的第一步,是根据已有的数据(哪怕不是直接可用的数据)以及因变量,来预设出一组特征;第二步是从原始数据中计算出这些特征;第三步是用这些特征对模型进行训练,如果训练出的模型有问题,则需要重新回到第一步,尝试重新选择其他的特征。

这次的因变量是商品的销量,能够作为参考的数据是用户在预热期三天的行为。用户在浏览商品时不能下单,主要的行为就是:点击商品、把商品添加到购物车、收藏商品。那对于商品来说,商品的点击数、加购物车的次数、收藏的次数是否可以作为销量的特征呢?

判断特征是否有效,可以从特征的值改变是否会影响因变量的值这个标准出发。这三个特征,不管是点击数、收藏数还是加购物车的次数越多,就说明商品越受欢迎,则越可能有用户来购买,反之亦然。所以这三个特征都可以作为本次任务的特征。

除了商品维度的特征,商品所在店铺的特征是否对商品的销量有帮助呢?比如在一些商品数比较多的店铺,往往用户搜索进入的概率就大,那商品被用户看到的概率也就变大。另一方面,店铺的 VIP 用户多寡似乎也能说明店铺是否优质,优质店铺的商品理论上也更好卖一些。

初步总结了五个可能对商品销量有影响的特征。

商品维度:

  • 点击数;
  • 添加到购物车的次数;
  • 收藏数。

商品所在店铺的维度:

  • 店铺的商品数;
  • 店铺所拥有的 VIP 用户数。

商品特征计算

首先尝试计算商品的特征。从三张表的描述可以得知,用户行为表中有用户对商品的行为记录,所以从用户行为表入手来计算商品的特征。

基本的思路就是把用户行为表按照 item_id 的维度聚合起来,点击数、加购物车数、收藏数和购买数都应该是聚合后的表的列。但目前用户行为表中没有这四列,相关的信息都由 action_type 来表示。

所以第一步,把 action_type 的内容展开成四列来分别表示:点击、加购物车、收藏和下单。

规则是:如果 action_type 是收藏时,则收藏列为 1,其他三列为 0;action_type 为下单时,下单一列为 1,其他三列为 0,以此类推。

这里涉及生成新列的时候,不仅是从现有列直接计算生成,还需要有一定的逻辑判断(判断 action_type 的值),所以用 Series 的 apply 函数来实现。apply 函数可以把一个函数执行到指定的 Series 上,然后把函数的返回值作为新的 Series 返回。

代码如下:

code
# 分别插入 click、cart、order、fav 四列,表示点击、加购物车、下单和收藏
# 规则见上面的文字描述
df_user_log["click"] = df_user_log.action_type.apply(lambda l:1 if l==0 else 0)
df_user_log["cart"] = df_user_log.action_type.apply(lambda l:1 if l==1 else 0)
df_user_log["order"] = df_user_log.action_type.apply(lambda l:1 if l==2 else 0)
df_user_log["fav"] = df_user_log.action_type.apply(lambda l:1 if l==3 else 0)
# 查看添加之后的 user_log 表
df_user_log

解释一下上面的代码,这里用到了 lambda 表达式。lambda 表达式是一种精简的函数表示法,形式如下:

code
def fn(参数):
  return 参数表达式

在 apply 函数中使用 lambda 表达式,Series 会对每个元素都执行一次这个表达式,当前执行的元素就是 lambda 表达式的参数,也就是 l。然后每一次执行,都会判断 l 的值(也就是 action_type)来决定返回 0 还是 1。

最后,所有 lambda 表达式的结果汇总起来成一个新的 Series,作为 apply 函数的返回值,然后将其赋值给创建的新列。

可以看到 click 这一列都是 1,而其他三列都是 0。click 行为本身就远比其他三个多,理论上也说得通。为了避免有 Bug,还是查看一下这几列的数据分布。

code
print("click 数据分布:\n",df_user_log.click.value_counts())
print("order 数据分布:\n",df_user_log.order.value_counts())
print("fav 数据分布:\n",df_user_log.fav.value_counts())
print("cart 数据分布:\n",df_user_log.cart.value_counts())

输出为:

code
click 数据分布:
 1    9709458
0    1275608
Name: click, dtype: int64
order 数据分布:
 0    10325489
1      659577
Name: order, dtype: int64
fav 数据分布:
 0    10384328
1      600738
Name: fav, dtype: int64
cart 数据分布:
 0    10969773
1       15293
Name: cart, dtype: int64

从上面的数据可以看出,基本符合预期。

之后,从原始的行为表中筛选出感兴趣的字段。

code
df_clean = df_user_log[["item_id", "click", "cart", "order","fav"]]
df_clean

输出为:

筛选之后,行没有变化,但只剩下五列了。接下来,将这个表以 item_id 聚合,同一个 item_id 的点击数、下单数、加购物车数和收藏数进行求和。这样就可以得到每个商品的汇总结果了。

code
df_item = df_clean.groupby(["item_id"]).sum()
df_item

输出为:

可以看到,输出的表中 item_id 已经成为索引,并且结果表只剩下 75 万行记录。核心原因就是把 item_id 相同的记录聚合了,所以现在 df_item 已经是一个商品维度的特征表。

以上面的 1113162 号商品为例,数值显示它被点击过 17 次、没有被加过购物车、被购买过两次、被收藏过一次。

至此,商品维度的特征就准备完毕了。

店铺特征计算

店铺特征包含两个,需要分别进行计算。但一致的是,都希望得到一个以店铺 id,也就是 seller_id 作为 index 的表,而特征则是表的列。

(1)店铺拥有的 vip 用户数

首先回顾一下 vip 用户表。

code
df_vip_user

输出为:

表里的 merchant_id 就是商家的 id,但是在用户行为表中,商家的 id 字段为 seller_id。所以为了后续对应方便,首先需要将该列重命名为 seller_id。

然后计算每一个商家的 VIP 用户数。该表存储的是用户是否是某个商家的 VIP,label 字段为 1 代表是。这里只考虑商家维度,所以只需简单按照 seller_id 聚合该表,让 seller_id 一致的记录对应的 label 字段求和,即可得到每个商家的 VIP 用户数。

code
# 重命名 merchant_id 为 seller_id
df_vip_user = df_vip_user.rename( columns={"merchant_id" : "seller_id"})
# 选取 seller_id和label 这两列,并将结果通过 seller_id 聚合,相同 seller_id 的记录的label求和
df_brand_vip_users = df_vip_user[["seller_id", "label"]].groupby("seller_id").sum()
# 查看保存了店铺 vip 用户数的表的内容
df_brand_vip_users

输出为:

聚合完毕后,表的索引就会是 seller_id,label 列则代表 VIP 用户的数量。比如最后一行记录的含义就是 id 为 4993 的店铺有 4 个 VIP 用户。这样,第一个店铺特征表已经准备完毕,表的名字为 df_brand_vip_user。

(2)计算每个店铺的商品数

计算店铺的商品数,首先从用户行为表中筛选出 seller_id 和 item_id。因为只关心店铺的商品数,所以先按 item_id 去重,这样留下来的记录就是商品的总数。然后在这个表的基础上,将 item_id 列都赋值为 1,再按 seller_id 聚合,让 item_id 做求和运算,就可以得出每个 seller_id 对应的商品数。

代码如下:

code
# 筛选出 seller_id 和 item_id
df_seller_item_count = df_user_log[["seller_id", "item_id"]]
# 按item_id 去重,因为 itemid 一样的记录,seller_id 肯定也一样,所以 seller_id没有影响
df_seller_item_count = df_seller_item_count.drop_duplicates("item_id")
# 将 item_id 列赋值为 1,方便做求和
df_seller_item_count["item_id"] = 1
# 按seller_id 聚合,然后针对 item_id 列求和
df_seller_item_count = df_seller_item_count.groupby("seller_id").sum()
# 将 item_id 列改为 item_count, 避免有歧义
df_seller_item_count = df_seller_item_count.rename(columns = {"item_id" : "item_count"})
# 查看结果
df_seller_item_count

输出为:

可以看到,第二个店铺特征的表也准备好了,index 为 seller_id,有一列 item_count 代表店铺的商品总数。

商品-店铺特征关联

接下来,把两个店铺特征表拼接到商品特征表中。

先看一下商品特征表。

code
df_item

输出为:

可以看到,目前商品特征表中只有 item_id,没有 seller_id,所以没有办法直接关联店铺特征表。所以第一步,首先需要给商品特征表增加 seller_id 字段。

要增加 seller_id,只需用类似之前的方法,从原始行为表中抽取出 seller_id 和 item_id 的对应关系表,然后再将对应关系表拼接进商品特征表中即可。代码如下:

code
# 从原始行为表中取出 item_id 和seller_id ,构成新表
df_brand_item_map = df_user_log[["item_id", "seller_id"]]
# 按照item_id去重,去重后得到的结果就相当于是 item id 和 seller_id 的映射关系
df_brand_item_map = df_brand_item_map.drop_duplicates("item_id")
# 将 df_brand_item_map 映射进 df_item, 以 item_id 为 key
df_item = df_item.merge(df_brand_item_map, how="left", on="item_id")
# 查看最新的商品特征表
df_item

输出:

可以看到,现在商品特征表已经多了 seller_id 字段,接下来将两个店铺特征表拼接进商品特征表。

首先拼接店铺的 VIP 用户数表。代码如下:

code
# 将店铺vip用户特征表拼接到商品特征表中,以 seller_id 为 key
df_item = df_item.merge(df_brand_vip_users,how = "left", on="seller_id")
df_item

输出:

从输出来看,店铺 VIP 会员数已经拼接到表里,但出现了空值,这也是符合预期的。毕竟 VIP 用户表里不是每个店铺都有 VIP 用户。为了不影响后续的模型,通过之前学习的填补缺失值的方法,给空值填为 0,这也是符合逻辑的。

code
# 用 0 填充缺失值
df_item["label"] = df_item["label"].fillna(0)
df_item

输出:

可以看到,通过 fillna 之后,缺失值被替换为 0。

下一步,拼接店铺的商品数特征表。代码如下:

code
df_item = df_item.merge(df_seller_item_count, how = "left", on = "seller_id")
df_item

输出:

现在商品特征表已经完成,不仅有商品的点击、加购物车、下单和收藏记录,还有商品所在店铺的 VIP 用户数和店铺的商品数。下一步基于这张表建立模型进行分析。

回归分析

通过特征工程的环节,已经整理好了能够预测商品销量的特征表,下面建立线性回归模型进行分析。

拆分训练、测试集合

在训练之前,还需要考虑一个问题:训练出模型后,如何衡量模型的好坏?总不能等到真正要在生产环境用上模型的时候才进行测试。

一般常见的做法是,将现有的数据拆成两份,比如 70% 一份、30% 一份。70% 的数据用来训练模型,训练完之后用剩下的 30% 的数据进行测试。这样就能够在模型上线之前就能衡量模型的好坏。sklearn 提供了现成的 train_test_split 函数,可以帮助实现数据集的分割。

首先第一步,从商品特征表中拆开自变量和因变量。

  • 因变量是很明显的,就是 order 列,代表商品被下单的数量。
  • 自变量就是除了 order 列之外的所有数值特征,也就是从商品特征表删掉 item_id、seller_id 和 order 列,剩下的都可以作为自变量。

代码如下:

code
# 导入分割的方法
from sklearn.model_selection import train_test_split
# 自变量的数据表
X = df_item.drop(columns=["order", "seller_id","item_id"])
# 因变量
y = df_item["order"]
# 分别切割出训练集,测试集,测试机的比例是 20%
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size = 0.20, random_state=42)
# 查看自变量的测试集合
X_train

输出:

商品特征表本身有 75 万行。这里打印出的训练集有 60 万行,刚好占比 80%。可以看到 train_test_split 函数工作正常。

建立线性回归模型

接下来的步骤比较简单:建立回归模型,然后调用 fit 方法从 X_train 和 y_train 中训练模型。

code
linear_model = LinearRegression()
linear_model.fit(X_train, y_train)

训练结束之后,用 linear_model 来 predict X_test,然后拿 predict 函数返回的结果和 y_test 比较,就能知道模型的误差。

code
# predict X_test 对应的 y 值
y_pred = linear_model.predict(X_test)
# 用 scores 方法,查看模型自变量和因变量的相关性
print("Scores:", linear_model.score(X_test, y_test))
# 查看 predict y 和 y test的平均绝对误差print("MAE:", mean_absolute_error(y_test, y_pred))
# 查看模型的 b 值
print("intercept:", linear_model.intercept_)
# 查看模型的系数
print("coef_:", linear_model.coef_)

输出:

code
Scores: 0.4417000916369106
MAE: 0.9939945446528016
intercept: 0.32490105754921395
coef_: [-1.09570715e-03  7.64421017e+00  7.20958736e-01  6.07684870e-04
 -3.10573289e-04]

模型的相关性分数是 0.44,对于现实世界中的回归问题来说也算是不错的成绩,说明特征还是很大程度能够影响下单量。MAE 是 0.99,说明对于测试集而言,模型预测的结果和实际的真实结果非常接近,说明模型的拟合比较好。

从模型系数可以看到,前三个特征 click、fav、cart 的系数比较大,后两个特征 label 和 item_count 的系数比较小,说明对于商品的下单量而言,前三个特征更加重要、关联性更强,后两者则对结果影响相对较小。

有时候凭主观判断对特征重要性的判断可能不准,可以都一并带上,然后在模型训练环节通过拟合算法自动找到不同特征的重要性,这样就比人的判断可靠得多。

测试一下模型:假设某个商品在预热期间一共有 100 次点击、2 次加购物车、6 次收藏,这个商品所在的店铺有 10 个 vip 用户、一共有 30 个商品,根据模型预测这个商品的下单数:

code
print(linear_model.predict([[100, 2 ,6, 10, 30]]))

输出:

code
[19.82626275]

代表可能会被买 19 次。

小结

至此,本文实战介绍完毕,总结一下本文主要学习的内容。

  • 回归类型任务的分析思路与方法;
  • 常见的特征工程的思路与方法;
  • 通过 df.groupby("xxx").sum() 的形式来聚合数据表;
  • 通过 df.merge 函数来拼接数据表,前提是两个数据表有相同的字段;
  • 通过 train_test_split 来将数据集拆分为训练集和测试集;
  • 通过 LinearRegression 来建立线性回归模型。

通过本文的学习,可以对回归预测类型的分析建立初步认识。当然这是一块非常大的领域,挑战也很多,本文重点还是以入门为主,有兴趣的读者可以自行深入研究。

版本差异(数据科学栈 → 当前版本)

本文编写时当前稳定版升级要点
Python3.8-3.123.143.12+ 起性能显著提升;3.14 PEP 649/750
NumPy1.x/2.02.3.xnp.float_ 等别名移除;NEP 50 类型提升
Pandas1.x/2.x3.0.xCopy-on-Write 默认开启;inplace 移除;字符串 dtype 变化
Matplotlib3.x3.x 稳定版API 兼容,样式更新
Seaborn0.12/0.130.13.xAPI 稳定
scikit-learn1.x1.7.xAPI 稳定,新算法持续加入

本文讲解的数据分析流程(读取→清洗→分析→可视化)与核心 API 在最新版本中成立;升级时重点关注 Pandas 3.0 的 Copy-on-Write 与 NumPy 2.x 的类型变化。