作者 | gongyouliu
编辑 | gongyouliu
我们在上一章中利用Netflix prize数据集讲解了最基础、最简单的一些推荐系统召回、排序算法,大家应该对怎么基于Python实现推荐算法有了一些基本的了解了。接着上一章的思路,本章我们会基于一个更复杂、更近代一点的数据集来实现一些我们在前面章节中讲到的更复杂的一些推荐召回、排序算法。本章我们讲解的算法跟上一章完全不重复,因此是对上一章内容的增补。
本章的讲解逻辑我们采用跟上一章类似的结构。我们会分数据集介绍、数据预处理、推荐算法实现等3个部分来讲解。通过本章的内容讲解,希望读者可以充分熟悉该数据集及怎么基于此实现各种更复杂的召回、排序算法。
19.1 H&M数据集介绍
H&M集团应该大家都知道,它是一家全球连锁的服装品牌,在全球有近5000家线下门店,我相信很多读者曾经也买过他们家的衣服。有了这个熟悉的背景,相信大家可以更好地理解下面我们将要讲解的数据集。
这个数据集是在2022年H&M在kaggle上组织的一次推荐系统竞赛(见参考文献1)。H&M的网上商店为购物者提供了大量可供浏览的产品。但由于选择太多,客户可能无法很快找到他们感兴趣的商品或他们正在寻找的商品,最终他们可能无法购买。为了提升购物体验,产品推荐是关键。所以,基于此背景他们组织了这次竞赛,希望参赛者能够提供一些比较好的推荐算法思路,可以帮助他们提升网上商店的购物体验。
在本次比赛中,H&M集团希望参赛者根据以前交易的数据以及客户和产品元数据制定产品推荐策略。可用的元数据从简单的数据(如服装类型、客户年龄等)到产品描述中的文本数据,再到服装海报中的图像数据。H&M数据集具体数据参考我们的github工程(https://github.com/liuq4360/recommender_systems_abc)中data目录下的hm子目录,包含如下5类数据,下面我们对数据进行详细说明。
images,目录下是商品的图片,不是每个商品都有图片的,子目录的名字是商品id的前3个数字;
articles.csv,商品相关信息,包含如下25个字段:
字段 | 说明 |
article_id | 物品id,10位数字字符,如 0108775015 |
product_code | 产品code,7位数字字符,如 0108775,是 article_id 的前 7 位 |
prod_name | 产品名,如 Strap top(系带上衣) |
product_type_no | 产品类型no,2位或者3位数字,有 -1 值 |
product_type_name | 产品类型名。如 Vest top(背心) |
product_group_name | 产品组名称,如 Garment Upper body(服装上身) |
graphical_appearance_no | 图案外观no,如 1010016 |
graphical_appearance_name | 图案外观名,如 Solid(固体;立体图形) |
colour_group_code | 颜色组code,如 09,2位数字 |
colour_group_name | 颜色组名称, 如 Black |
perceived_colour_value_id | 感知颜色值id, -1,1,2,3,4,5,6,7,一共这几个值。 |
perceived_colour_value_name | 感知颜色值名称,如 Dark(黑暗的),Dusty Light等 |
perceived_colour_master_id | 感知颜色主id,1位或者2位数字 |
perceived_colour_master_name | 感知颜色主名称,如 Beige(浅褐色的) |
department_no | 部门no,4位数字 |
department_name | 部门名称, 如 Outdoor/Blazers DS |
index_code | 索引code,单个大写字母,如 A G F D 等 |
index_name | 索引名,如 Lingeries/Tights(内衣/紧身裤) |
index_group_no | 索引组no,1位或者2位数字 |
index_group_name | 索引组名称, 如 Ladieswear(女装) |
section_no | 部门no, 2位数字 |
section_name | 部门名称, 如 Womens Everyday Basics(女性日常基础知识) |
garment_group_no | 服装组no,4位数字 |
garment_group_name | 服装组名称, 如 Jersey Basic(泽西岛基本款) |
detail_desc | 细节的文本描述, 如 Jersey top with narrow shoulder straps(窄肩带的泽西上衣) |
customers.csv,用户相关信息,包含如下7个字段:
字段 | 说明 |
customer_id | 用户id,字符串,如:00000dbacae5abe5e23885899a1fa44253a17956c6d1c3d25f88aa139fdfc657 |
FN | 35%有值,值为1,65% 缺失 |
Active | 34%值为1, 66% 无值 |
club_member_status | 俱乐部成员状态, 93% ACTIVE,7% PRE-CREATE(预先创建) |
fashion_news_frequency | 时尚新闻频率, 64% NONE,35% Regularly(有规律地),1% 其它值 |
age | 年龄,有缺失值,1%缺失 |
postal_code | 邮政代码,很长的字符串。例如,52043ee2162cf5aa7ee79974281641c6f11a68d276429a91f8ca0d4b6efa8100 |
sample_submission.csv,提交预测文件,需要为这里面的每个用户进行推荐预测,包含如下2个字段:
字段 | 说明 |
customer_id | 用户id,字符串,如:00000dbacae5abe5e23885899a1fa44253a17956c6d1c3d25f88aa139fdfc657 |
prediction | 推荐的预测值,如:0706016001 0706016002 0372860001 0610776002 0759871002 0464297007 0372860002 0610776001 0399223001 0706016003 0720125001 0156231001 这里预测的是物品id,空格隔开 |
transactions_train.csv,用户行为数据,包含如下5个字段:
字段 | 说明 |
t_dat | 时间,就是商品的购买时间。如2019-09-18,只精确到日。 |
customer_id | 用户id |
article_id | 商品id |
price | 价格 |
sales_channel_id | 销售渠道id,值只有1、2两个,应该是线上、线下两个 |
上面对H&M数据集提供的5类数据进行了简单介绍,更细节的了解,可以查看原始数据或者对数据进行简单的统计分析(kaggle官网上有数据的基础统计分析,见参考文献1,我们这里就不做分析了)。我们在下面一节对构建推荐算法依赖的数据进行预处理,构建相关特征。
19.2 数据预处理于特征工程
前面一节我们讲解了H&M几个数据集的基本特性和各自的字段描述。为了方便我们后面构建各种召回、排序算法,我们在本节对相关数据进行预处理和相关特征工程的工作。
19.2.1 基于物品信息构建物品特征矩阵
我们在19.3.1.3节会利用kmeans算法进行物品聚类,在这之前我们需要对物品数据(即上面提到的articles.csv)进行处理,构建特征矩阵。具体实现代码如下:
art = pd.read_csv("../../data/hm/articles.csv")
# customers = pd.read_csv("../../data/hm/customers.csv")
# len(pd.unique(art['article_id'])) 某列唯一值的个数
# trans = pd.read_csv("../../data/hm/transactions_train.csv")
# 类似用户画像部分,我们只关注下面6个类别特征,先将类别特征one-hot编码,然后进行聚类。
art = art[['article_id', 'product_code', 'product_type_no', 'graphical_appearance_no','colour_group_code', 'perceived_colour_value_id', 'perceived_colour_master_id']]
# 'product_code' : 47224 个不同的值。
# 'product_type_no':132 个不同的值。
# 'graphical_appearance_no':30 个不同的值。
# 'colour_group_code':50 个不同的值。
# 'perceived_colour_value_id':8 个不同的值。
# 'perceived_colour_master_id':20 个不同的值。
# product_code:取出现次数最多的前10个,后面的合并。
most_freq_top10_prod_code = np.array(Counter(art.product_code).most_common(10))[:, 0]
# 如果color不是最频繁的10个color,那么就给定一个默认值0,减少one-hot编码的维度
art['product_code'] = art['product_code'].apply(lambda t: t if t in most_freq_top10_prod_code else -1)
# product_type_no:取出现次数最多的前10个,后面的合并。
most_frequent_top10_product_type_no = np.array(Counter(art.product_type_no).most_common(10))[:, 0]
# 如果color不是最频繁的10个color,那么就给定一个默认值0,减少one-hot编码的维度
art['product_type_no'] = art['product_type_no'].apply(lambda t: t if t in most_frequent_top10_product_type_no else -1)
one_hot = OneHotEncoder(handle_unknown='ignore')
one_hot_data = art[['product_code', 'product_type_no', 'graphical_appearance_no','colour_group_code', 'perceived_colour_value_id', 'perceived_colour_master_id']]
one_hot.fit(one_hot_data)
feature_array = one_hot.transform(np.array(one_hot_data)).toarray()
# 两个ndarray水平合并,跟data['id']合并,方便后面两个DataFrame合并
feature_array_add_id = np.hstack((np.asarray([art['article_id'].values]).T, feature_array))
# one_hot_features_df = DataFrame(feature_array, columns=one_hot.get_feature_names())
df_train = DataFrame(feature_array_add_id, columns=np.hstack((np.asarray(['article_id']),one_hot.get_feature_names_out())))
df_train['article_id'] = df_train['article_id'].apply(lambda t: int(t))
# df_train = df_train.drop(columns=['article_id'])
# index = 0 写入时不保留索引列。
df_train.to_csv('../../output/hm/kmeans_train.csv', index=0)
通过上面的处理之后,我们可以获得一个130维的特征矩阵,行代表的是每个物品,列是对应特征或者原始特征做one-hot编码后的特征。由于列数比较多,我们这里就不展示出来了。
19.2.2 基于标签构建用户画像
我们先简单描述一下用户画像的构建逻辑,然后再给出对应的代码实现。具体逻辑是:物品是有特征的(比如颜色、品牌等),那么如果用户购买过某物品,该物品对应的特征就可以自动成为该用户的兴趣画像特征了(比如用户买了黑色的衣服,那么黑色就是用户的兴趣特征),我们的用户画像就是这么构建的。
首先,我们先生成物品对应的特征,我们构建一个物品到特征对应的字典,方便后面构建用户画像的时候使用,代码如下:
# 为每个物品生成对应的特征,这里我们只用到了product_code、product_type_no、graphical_appearance_no、
# colour_group_code、perceived_colour_value_id、perceived_colour_master_id这6个特征。
art = pd.read_csv("../../data/hm/articles.csv")
article_dict = dict() # {12:{id1,id2,...,id_k}, 34:{id1,id2,...,id_k}}, 这里面每个物品对应的特征权重都一样
for _, row in art.iterrows():article_id = row['article_id']product_code = row['product_code']product_type_no = row['product_type_no']graphical_appearance_no = row['graphical_appearance_no']colour_group_code = row['colour_group_code']perceived_colour_value_id = row['perceived_colour_value_id']perceived_colour_master_id = row['perceived_colour_master_id']feature_dict = dict()feature_dict['product_code'] = product_codefeature_dict['product_type_no'] = product_type_nofeature_dict['graphical_appearance_no'] = graphical_appearance_nofeature_dict['colour_group_code'] = colour_group_codefeature_dict['perceived_colour_value_id'] = perceived_colour_value_idfeature_dict['perceived_colour_master_id'] = perceived_colour_master_idarticle_dict[article_id] = feature_dict
# print(article_dict)
np.save("../../output/hm/article_dict.npy", article_dict)
第二步是基于物品相关信息,为每个特征生成对应的倒排索引字典(key是对应的特征,value是具备该特征的所有物品集合),具体代码实现如下:
# 基于物品的特征,为每个特征生成对应的倒排索引,倒排索引可以存到Redis中。
# 需要生成倒排索引的特征包括如下几个:
# product_code, 产品code,7位数字字符,如 0108775,是 article_id 的前 7 位。
# prod_name, 产品名,如 Strap top(系带上衣)
# product_type_no, 产品类型no,2位或者3位数字,有 -1 值。
# product_type_name, 产品类型名。如 Vest top(背心)
# graphical_appearance_no, 图案外观no,如 1010016。
# graphical_appearance_name, 图案外观名,如 Solid(固体;立体图形)
# colour_group_code, 颜色组code,如 09,2位数字
# colour_group_name, 颜色组名称, 如 Black。
# perceived_colour_value_id, 感知颜色值id。-1,1,2,3,4,5,6,7,一共这几个值。
# perceived_colour_value_name, 感知颜色值名称。如 Dark(黑暗的),Dusty Light等
# perceived_colour_master_id, 感知颜色主id。1位或者2位数字。
# perceived_colour_master_name, 感知颜色主名称。如 Beige(浅褐色的)art = pd.read_csv("../../data/hm/articles.csv")
product_code_unique = np.unique(art[["product_code"]]) # 取某一列的所有唯一值,array([108775, 111565, ..., 959461])
product_type_no_unique = np.unique(art[["product_type_no"]])
graphical_appearance_no_unique = np.unique(art[["graphical_appearance_no"]])
colour_group_code_unique = np.unique(art[["colour_group_code"]])
perceived_colour_value_id_unique = np.unique(art[["perceived_colour_value_id"]])
perceived_colour_master_id_unique = np.unique(art[["perceived_colour_master_id"]])
product_code_portrait_dict = dict() # {12:{id1,id2,...,id_k}, 34:{id1,id2,...,id_k}}, 这里面每个物品对应的特征权重都一样
product_type_no_portrait_dict = dict()
graphical_appearance_no_portrait_dict = dict()
colour_group_code_portrait_dict = dict()
perceived_colour_value_id_portrait_dict = dict()
perceived_colour_master_id_portrait_dict = dict()
for _, row in art.iterrows():article_id = row['article_id']product_code = row['product_code']product_type_no = row['product_type_no']graphical_appearance_no = row['graphical_appearance_no']colour_group_code = row['colour_group_code']perceived_colour_value_id = row['perceived_colour_value_id']perceived_colour_master_id = row['perceived_colour_master_id']if product_code in product_code_portrait_dict:product_code_portrait_dict[product_code].add(article_id)else:product_code_portrait_dict[product_code] = set([article_id])if product_type_no in product_type_no_portrait_dict:product_type_no_portrait_dict[product_type_no].add(article_id)else:product_type_no_portrait_dict[product_type_no] = set([article_id])if graphical_appearance_no in graphical_appearance_no_portrait_dict:graphical_appearance_no_portrait_dict[graphical_appearance_no].add(article_id)else:graphical_appearance_no_portrait_dict[graphical_appearance_no] = set([article_id])if colour_group_code in colour_group_code_portrait_dict:colour_group_code_portrait_dict[colour_group_code].add(article_id)else:colour_group_code_portrait_dict[colour_group_code] = set([article_id])if perceived_colour_value_id in perceived_colour_value_id_portrait_dict:perceived_colour_value_id_portrait_dict[perceived_colour_value_id].add(article_id)else:perceived_colour_value_id_portrait_dict[perceived_colour_value_id] = set([article_id])if perceived_colour_master_id in perceived_colour_master_id_portrait_dict:perceived_colour_master_id_portrait_dict[perceived_colour_master_id].add(article_id)else:perceived_colour_master_id_portrait_dict[perceived_colour_master_id] = set([article_id])
# print(product_code_portrait_dict)
# print(product_type_no_portrait_dict)
# print(graphical_appearance_no_portrait_dict)
# print(colour_group_code_portrait_dict)
# print(perceived_colour_value_id_portrait_dict)
# print(perceived_colour_master_id_portrait_dict)
np.save("../../output/hm/product_code_portrait_dict.npy", product_code_portrait_dict)
np.save("../../output/hm/product_type_no_portrait_dict.npy", product_type_no_portrait_dict)
np.save("../../output/hm/graphical_appearance_no_portrait_dict.npy", graphical_appearance_no_portrait_dict)
np.save("../../output/hm/colour_group_code_portrait_dict.npy", colour_group_code_portrait_dict)
np.save("../../output/hm/perceived_colour_value_id_portrait_dict.npy", perceived_colour_value_id_portrait_dict)
np.save("../../output/hm/perceived_colour_master_id_portrait_dict.npy", perceived_colour_master_id_portrait_dict)
有了上面2步的准备工作,就可以基于用户的行为数据(即19.1节中的transactions_train.csv)构建用户的兴趣画像,具体的代码实现如下:
# 基于用户行为数据,为每个用户生成用户画像。
trans = pd.read_csv("../../data/hm/transactions_train.csv")
user_portrait = dict()
article_dict = np.load("../../output/hm/article_dict.npy", allow_pickle=True).item()
for _, row in trans.iterrows():customer_id = row['customer_id']article_id = row['article_id']feature_dict = article_dict[article_id]# article_dict[957375001]# {'product_code': 957375, 'product_type_no': 72,# 'graphical_appearance_no': 1010016, 'colour_group_code': 9,# 'perceived_colour_value_id': 4, 'perceived_colour_master_id': 5}product_code = feature_dict['product_code']product_type_no = feature_dict['product_type_no']graphical_appearance_no = feature_dict['graphical_appearance_no']colour_group_code = feature_dict['colour_group_code']perceived_colour_value_id = feature_dict['perceived_colour_value_id']perceived_colour_master_id = feature_dict['perceived_colour_master_id']if customer_id in user_portrait:portrait_dict = user_portrait[customer_id]# { 'product_code': set([108775, 116379])# 'product_type_no': set([253, 302, 304, 306])# 'graphical_appearance_no': set([1010016, 1010017])# 'colour_group_code': set([9, 11, 13])# 'perceived_colour_value_id': set([1, 3, 4, 2])# 'perceived_colour_master_id': set([11, 5 ,9])# }if 'product_code' in portrait_dict:portrait_dict['product_code'].add(product_code)else:portrait_dict['product_code'] = set([product_code])if 'product_type_no' in portrait_dict:portrait_dict['product_type_no'].add(product_type_no)else:portrait_dict['product_type_no'] = set([product_type_no])if 'graphical_appearance_no' in portrait_dict:portrait_dict['graphical_appearance_no'].add(graphical_appearance_no)else:portrait_dict['graphical_appearance_no'] = set([graphical_appearance_no])if 'colour_group_code' in portrait_dict:portrait_dict['colour_group_code'].add(colour_group_code)else:portrait_dict['colour_group_code'] = set([colour_group_code])if 'perceived_colour_value_id' in portrait_dict:portrait_dict['perceived_colour_value_id'].add(perceived_colour_value_id)else:portrait_dict['perceived_colour_value_id'] = set([perceived_colour_value_id])if 'perceived_colour_master_id' in portrait_dict:portrait_dict['perceived_colour_master_id'].add(perceived_colour_master_id)else:portrait_dict['perceived_colour_master_id'] = set([perceived_colour_master_id])user_portrait[customer_id] = portrait_dictelse:portrait_dict = dict()portrait_dict['product_code'] = set([product_code])portrait_dict['product_type_no'] = set([product_type_no])portrait_dict['graphical_appearance_no'] = set([graphical_appearance_no])portrait_dict['colour_group_code'] = set([colour_group_code])portrait_dict['perceived_colour_value_id'] = set([perceived_colour_value_id])portrait_dict['perceived_colour_master_id'] = set([perceived_colour_master_id])user_portrait[customer_id] = portrait_dict
np.save("../../output/hm/user_portrait.npy", user_portrait)
上面代码中我们将物品特征字典、特征倒排索引字典、用户画像存到了本地磁盘中了,在实际使用过程中,更优的做法是存到Redis等NoSQL中,这样有更高的稳定性,读取性能也会更好。在后续的代码优化中,我们会采用Redis来存储。
19.2.3 构建推荐算法的特征矩阵
后面我们的logistics回归、FM、GBDT、wide&deep等排序算法都需要构建模型的特征向量,方便模型进行训练。本节的目的就是构建这些特征向量,这里我们只讲解logistics回归的特征构建,其它算法的特征可以复用logistics回归的,或者可以基于logistics的实现进行简单调整,这里不再赘述。
logistics回归中的特征有数值特征,也有离散特征经过one-hot进行编码后形成的特征,具体的数据预处理及特征工程实现的代码如下:
r"""
利用scikit-learn 中的 logistics回归 算法来进行召回。模型的主要特征有如下3类:
1、用户相关特征:基于customers.csv表格中的数据。下面6个字段都作为特征。FN, 35%有值,值为1;65% 缺失。Active, 34% 1, 66% 无值。club_member_status, 俱乐部成员状态, 93% ACTIVE,7% PRE-CREATE(预先创建)。fashion_news_frequency, 时尚新闻频率, 64% NONE,35% Regularly(有规律地),1% 其它值。age, 年龄,有缺失值,1%缺失。postal_code, 邮政代码,很长的字符串。例如,52043ee2162cf5aa7ee79974281641c6f11a68d276429a91f8ca0d4b6efa8100。
2、物品相关特征:基于articles.csv表格中的数据。下面6个字段作为特征,还有很多字段没用到,也可能能用,读者可以自己探索。product_code, 产品code,7位数字字符,如 0108775,是 article_id 的前 7 位。product_type_no, 产品类型no,2位或者3位数字,有 -1 值。graphical_appearance_no, 图案外观no,如 1010016。colour_group_code, 颜色组code,如 09,2位数字perceived_colour_value_id, 感知颜色值id。-1,1,2,3,4,5,6,7,一共这几个值。perceived_colour_master_id, 感知颜色主id。1位或者2位数字。
3、用户行为相关特征:基于transactions_train.csv数据。下面2个特征需要使用。t_dat,时间,就是商品的购买时间。如2019-09-18,只精确到日。price, 价格sales_channel_id, 销售渠道id,值只有1、2两个,估计是线上、线下两个。另外再准备几个用户行为统计特征,具体如下:用户购买频次:总购买次数/用户最近购买和最远购买之间的星期数。用户客单价:该用户所有购买的平均价格。
"""art = pd.read_csv("../../data/hm/articles.csv")
cust = pd.read_csv("../../data/hm/customers.csv")
cust.loc[:, ['FN']] = cust.loc[:, ['FN']].fillna(0)
cust.loc[:, ['Active']] = cust.loc[:, ['Active']].fillna(0)
cust.loc[:, ['club_member_status']] = cust.loc[:, ['club_member_status']].fillna('other')
cust.loc[:, ['fashion_news_frequency']] = cust.loc[:, ['fashion_news_frequency']].fillna('other')
cust.loc[:, ['age']] = cust.loc[:, ['age']].fillna(int(cust['age'].mode()[0]))
cust['age'] = cust['age']/100.0
# len(pd.unique(art['article_id'])) # 某列唯一值的个数
trans = pd.read_csv("../../data/hm/transactions_train.csv") # 都是正样本。
# 到目前为止经历的年数。
trans['label'] = 1
positive_num = trans.shape[0]
# 数据中没有负样本,还需要人工构建一些负样本。
# 负样本中的price,用目前正样本的price的平均值。
price = trans[['article_id', 'price']].groupby('article_id').mean()
price_dict = price.to_dict()['price']
# 负样本的sales_channel_id用正样本的中位数。
channel = trans[['article_id', 'sales_channel_id']].groupby('article_id').median()
channel['sales_channel_id'] = channel['sales_channel_id'].apply(lambda x: int(x))
channel_dict = channel.to_dict()['sales_channel_id']
t = trans['t_dat']
date = t.mode()[0] # 用众数来表示负样本的时间。'2019-09-28'
# 采用将正样本的customer_id、article_id两列随机打散的思路(这样customer_id和article_id就可以
# 随机组合了)来构建负样本。
cust_id = shuffle(trans['customer_id']).to_list()
art_id = shuffle(trans['article_id']).to_list()
data = {'customer_id': cust_id, 'article_id': art_id}
negative_df = pd.DataFrame(data, index=list(range(positive_num, 2*positive_num, 1)))
negative_df['t_dat'] = date
negative_df['price'] = negative_df['article_id'].apply(lambda i: price_dict[i])
negative_df['sales_channel_id'] = negative_df['article_id'].apply(lambda i: channel_dict[i])
# 调整列的顺序,跟正样本保持一致
negative_df = negative_df[['t_dat', 'customer_id', 'article_id', 'price', 'sales_channel_id']]
negative_df['label'] = 0
df = pd.concat([trans, negative_df], ignore_index=True) # 重新进行索引
df['t_dat'] = pd.to_datetime(df['t_dat']).rsub(pd.Timestamp('now').floor('d')).dt.days/365.0
df = shuffle(df)
df.reset_index(drop=True, inplace=True)
df = df.merge(cust, on=['customer_id'],how='left').merge(art, on=['article_id'], how='left')
df = df[['customer_id', 'article_id', 't_dat', 'price', 'sales_channel_id','product_code', 'product_type_no', 'graphical_appearance_no', 'colour_group_code','perceived_colour_value_id', 'perceived_colour_master_id', 'FN','Active', 'club_member_status', 'fashion_news_frequency', 'age', 'postal_code', 'label']]
# product_code:取出现次数最多的前10个,后面的合并。
most_frequent_top10_product_code = np.array(Counter(df.product_code).most_common(10))[:, 0]
# 如果color不是最频繁的10个color,那么就给定一个默认值0,减少one-hot编码的维度
df['product_code'] = df['product_code'].apply(lambda x: x if x in most_frequent_top10_product_code else -1)
# product_type_no:取出现次数最多的前10个,后面的合并。
most_frequent_top10_product_type_no = np.array(Counter(df.product_type_no).most_common(10))[:, 0]
# 如果color不是最频繁的10个color,那么就给定一个默认值0,减少one-hot编码的维度
df['product_type_no'] = df['product_type_no'].apply(lambda x: x if x in most_frequent_top10_product_type_no else -1)
# postal_code:取出现次数最多的前10个,后面的合并。
most_frequent_top100_postal_code = np.array(Counter(df.postal_code).most_common(100))[:, 0]
# 如果color不是最频繁的10个color,那么就给定一个默认值0,减少one-hot编码的维度
df['postal_code'] = df['postal_code'].apply(lambda x: x if x in most_frequent_top100_postal_code else "other")
df.to_csv('../../output/hm/logistic_source_data.csv', index=0)
# df = pd.read_csv("../../output/hm/logistic_source_data.csv")
one_hot = OneHotEncoder(handle_unknown='ignore')
one_hot_data = df[['sales_channel_id', 'product_code', 'product_type_no', 'graphical_appearance_no','colour_group_code', 'perceived_colour_value_id', 'perceived_colour_master_id','FN', 'Active', 'club_member_status', 'fashion_news_frequency', 'postal_code']]
one_hot.fit(one_hot_data)
feature_array = one_hot.transform(np.array(one_hot_data)).toarray()
# 两个ndarray水平合并,跟data['id']合并,方便后面两个DataFrame合并
feature_array_add_id = np.hstack((np.asarray([df['customer_id'].values]).T,np.asarray([df['article_id'].values]).T, feature_array))
one_hot_df = DataFrame(feature_array_add_id,columns=np.hstack((np.asarray(['customer_id']),np.asarray(['article_id']),one_hot.get_feature_names_out())))
one_hot_df['customer_id'] = one_hot_df['customer_id'].apply(lambda x: int(x))
one_hot_df['article_id'] = one_hot_df['article_id'].apply(lambda x: int(x))
# 三类特征合并。
final_df = df.merge(one_hot_df, on=['customer_id', 'article_id'], how='left')
final_df = final_df.drop(columns=['sales_channel_id', 'product_code', 'product_type_no', 'graphical_appearance_no','colour_group_code', 'perceived_colour_value_id', 'perceived_colour_master_id','FN', 'Active', 'club_member_status', 'fashion_news_frequency', 'postal_code'])
# index = 0 写入时不保留索引列。
final_df.to_csv('../../output/hm/logistic_model_data.csv', index=0)
# read
# data_and_features_df = pd.read_csv(data_path + '/' + r'logistic_model_data.csv')
# 将数据集划分为训练集logistic_train_df和测试集logistic_test_df。训练集logistic_train_df
# 用于logistic回归模型的训练,而测试集logistic_test_df用于测试训练好的logistic回归模型的效果。
logistic_train_df, logistic_test_df = train_test_split(final_df,test_size=0.3, random_state=42)
logistic_train_df.to_csv('../../output/hm/logistic_train_data.csv', index=0)
logistic_test_df.to_csv('../../output/hm/logistic_test_data.csv', index=0)
19.3 推荐系统算法实现
讲完了数据预处理与特征工程,下面我们基于前面介绍的H&M数据集实现我们在第6-13章节实现的各种更复杂的召回、排序算法。
19.3.1 召回算法
H&M数据集我们一共实现了5类召回算法,分别是基于标签的物品关联物品召回、基于item2vec的物品关联物品召回、基于聚类的个性化召回、基于用户最新的兴趣物品的召回和基于标签的用户画像召回,下面我们分别介绍。
19.3.1.1 基于标签的物品关联召回
这个召回算法非常简单,本节的代码实现对应我们git仓库recall/hm目录下的item_tags_jaccard_similar.py。我们利用物品的标签,基于 jaccard 相似性计算物品相似度,为每个物品计算最相似的N个物品。这个算法的算法原理我们在第7章的7.2.1节中已经做了介绍,不过我们的实现方式跟7.2.1节的不一样,本节我们的实现方式更简单、更直观。
计算jaccard相似度,就是看两个集合中重复元素的个数除以两个集合一共有多少元素,具体代码如下:
def jaccard_similarity(set_1, set_2):"""计算两个集合的jaccard相似度。jaccard_similarity = || set_1 & set_2 || / || set_1 | set_2 ||:param set_1: 集合1:param set_2: 集合2:return: 相似度"""return len(set_1 & set_2)*1.0/len(set_1 | set_2)
对于H&M数据集,我们的物品有很多字段,我们只利用部分字段(下面代码中会提到)作为特征。那么任何一个字段就可以利用上面的jaccard相似度计算两个物品在该字段的相似度,所有字段的相似度加起来就是这两个物品的相似度了,具体代码实现如下:
def article_jaccard_similarity(article_1, article_2):"""计算两个article的jaccard相似性。:param article_1: 物品1的metadata,数据结构是一个dict,基于articles.csv的行构建的。:param article_2: 物品2的metadata,数据结构是一个dict,基于articles.csv的行构建的。:return: sim,返回 article_1 和 article_2 的相似性。"""sim = 0.0for key in article_1.keys():sim = sim + jaccard_similarity(set(article_1[key]), set(article_2[key]))return sim/len(article_1)
通过上面的讲解,我们能够计算任意两个物品的相似度了。那么,针对所有物品,我们可以利用两个嵌套循环(我们的实现比较简单,可能运行比较慢,读者可以用Spark进行分布式实现,实现方案也非常简单,7.2.1节有实现的原理介绍,我们这里就不提供Spark版本的分布式实现代码了)就可以实现为每个物品计算最相似的topN的物品了。具体代码实现如下:
art = pd.read_csv("../../data/hm/articles.csv")
# art = art[['prod_name', 'product_type_name', 'product_group_name', 'graphical_appearance_name',
# 'colour_group_name', 'perceived_colour_value_name', 'perceived_colour_master_name', 'department_name',
# 'index_name', 'index_group_name', 'section_name', 'garment_group_name']]
# art_1 = dict(art.loc[0]) # 取第一行
# art_2 = dict(art.loc[3]) # 取第二行
# print(article_jaccard_similarity(art_1, art_2))
jaccard_sim_rec_map = dict()
rec_num = 30
articles = art.iloc[:, 0].drop_duplicates().to_list() # 取第一列的值,然后去重,转为list
for a in articles:row_a = art[art['article_id'] == a] # 取 art中 'article_id' 列值为 a 的行tmp_a = row_a[['prod_name', 'product_type_name', 'product_group_name', 'graphical_appearance_name','colour_group_name', 'perceived_colour_value_name', 'perceived_colour_master_name','department_name', 'index_name', 'index_group_name', 'section_name', 'garment_group_name']]art_a = dict(tmp_a.loc[tmp_a.index[0]])sim_dict = dict()for b in articles:if a != b:row_b = art[art['article_id'] == b]tmp_b = row_b[['prod_name', 'product_type_name', 'product_group_name', 'graphical_appearance_name','colour_group_name', 'perceived_colour_value_name', 'perceived_colour_master_name','department_name', 'index_name', 'index_group_name', 'section_name', 'garment_group_name']]art_b = dict(tmp_b.loc[tmp_b.index[0]])sim_ = article_jaccard_similarity(art_a, art_b)sim_dict[b] = sim_sorted_list = sorted(sim_dict.items(), key=lambda item: item[1], reverse=True)res = sorted_list[:rec_num]jaccard_sim_rec_map[a] = res
jaccard_sim_rec_path = "../../output/netflix_prize/jaccard_sim_rec.npy"
np.save(jaccard_sim_rec_path, jaccard_sim_rec_map)
19.3.1.2 基于item2vec的物品关联召回
这个算法实现的功能跟上面的类似(对应我们github代码仓库recall/hm目录下的item2vec.py),主要是获取物品关联物品的召回,这个算法的原理我们在第9章的9.1节中已经做过介绍。本节的代码我们是通过利用gensim框架(见参考文献2、3)来实现item嵌入(采用item2vec算法实现)的,然后利用向量相似来计算最相关的物品。
由于使用了第三方框架,代码实现起来相对容易,需要读者熟悉相关的类和方法,具体代码参考下面的代码块。这里我们不对各种类和方法进行解释了,工作留给读者去熟悉。顺便说一句,gensim框架是一个非常不错的框架,有很多好的工具可以使用,读者可以多了解。item2vec嵌入效果也相当不错,作者之前就用了该方法来做过视频的关联推荐。
trans = pd.read_csv("../../data/hm/transactions_train.csv")
tmp_df = trans[['customer_id', 'article_id']]
grouped_df = tmp_df.groupby('customer_id')
groups = grouped_df.groups
train_data = []
for customer_id in groups.keys():customer_df = grouped_df.get_group(customer_id)tmp_lines = list(customer_df['article_id'].values)lines = []for word in tmp_lines:lines.append(str(word))train_data.append(lines)
model = Word2Vec(sentences=train_data, vector_size=100, window=5, min_count=3, workers=4)
model.save("../../output/hm/word2vec.model")
# model = Word2Vec.load("../../output/hm/word2vec.model")
# vector = model.wv['computer'] # get numpy vector of a word
sims = model.wv.most_similar('657395002', topn=10) # get other similar words
print(sims)
19.3.1.3 基于聚类的物品关联召回
这个召回算法我们利用scikit-learn中的kmeans算法对物品进行聚类(只利用了物品本身的特征信息),然后利用物品所在的类作为该物品的关联召回推荐,这个算法的原理我们在第8章的8.2节已经介绍过。具体的代码实现参考下面的代码块(对应我们github代码仓库recall/hm目录下的k_means.py):
n_clusters = 1000
# X = np.array([[1, 2], [1, 4], [1, 0], [10, 2], [10, 4], [10, 0]])
# k_means = KMeans(n_clusters=2, random_state=0).fit(X)
# n_clusters: 一共聚多少类,默认值8
# init:选择中心点的初始化方法,默认值k-means++
# n_init:算法基于不同的中心点运行多少次,最后的结果基于最好的一次迭代的结果,默认值10
# max_iter: 最大迭代次数,默认值300
k_means = KMeans(init='k-means++', n_clusters=n_clusters, n_init=10,max_iter=300).fit(df_train.drop(columns=['article_id']).values)
# 训练样本中每条记录所属的类别
print(k_means.labels_)
# 预测某个样本属于哪个聚类
# print(k_means.predict(np.random.rand(1, df_train.shape[1])))
print(k_means.predict(np.random.randint(20, size=(2, df_train.drop(columns=['article_id']).shape[1]))))
# 每个聚类的聚类中心
print(k_means.cluster_centers_)
result_array = np.hstack((np.asarray([df_train['article_id'].values]).T,np.asarray([k_means.labels_]).T))
# 将物品id和具体的类别转化为DataFrame。
cluster_result = DataFrame(result_array, columns=['article_id', 'cluster'])
# index = 0 写入时不保留索引列。
cluster_result.to_csv('../../output/hm/kmeans.csv', index=0)
# read
# cluster_result = pd.read_csv('../../output/hm/kmeans.csv')
# 给用户推荐的物品数量的数量
rec_num = 10
df_cluster = pd.read_csv('../../output/hm/kmeans.csv')
# 每个id对应的cluster的映射字典。
id_cluster_dict = dict(df_cluster.values)
tmp = df_cluster.values
cluster_ids_dict = {}
for i in range(tmp.shape[0]):[id_, cluster_] = tmp[i]if cluster_ in cluster_ids_dict.keys():cluster_ids_dict[cluster_] = cluster_ids_dict[cluster_] + [id_]else:cluster_ids_dict[cluster_] = [id_]
# 一共有多少个类
# cluster_num = len(cluster_ids_dict)
# 打印出每一个类有多少个元素,即每类有多少物品
for x, y in cluster_ids_dict.items():print("cluster " + str(x) + " : " + str(len(y)))
# source_df = pd.read_csv("../../data/hm/articles.csv")
# 基于聚类,为每个物品关联k个最相似的物品。
def article_similar_recall(art_id, k):rec = cluster_ids_dict.get(id_cluster_dict.get(art_id))if art_id in rec:rec.remove(art_id)return random.sample(rec, k)
article_id = 952937003
topn_sim = article_similar_recall(article_id, rec_num)
当然,我们也可以基于上面一小节提到的item2vec获得物品的嵌入向量,然后利用该向量进行kmeans聚类。从经验上来说,基于item2vec再kmeans聚类的效果应该会更好一些。
19.3.1.4 基于用户兴趣的种子物品召回
这个召回算法是利用用户最近有兴趣的几个物品(比如抖音上用户最近看完的短视频)作为种子,利用每个种子的相似关联物品作为待召回的物品,将所有种子进行这样关联,所有的待召回的物品的集合的并集就是最终的召回物品。这个算法的原理我们在第7章的7.2.2.1节已经做过介绍,具体代码参考如下(对应我们github代码仓库recall/hm目录下的seed_items_tags_jaccard.py):
def seeds_recall(seeds, rec_num):"""基于用户喜欢的种子物品,为用户召回关联物品。:param seeds: list,用户种子物品 ~ [item1,item2, ..., item_i]:param rec_num: 最终召回的物品数量:return: list ~ [(item1,score1),(item2,score2), ..., (item_k,score_k)]"""jaccard_sim_rec_path = "../../output/netflix_prize/jaccard_sim_rec.npy"sim = np.load(jaccard_sim_rec_path, allow_pickle=True).item()recalls = []for seed in seeds:recalls.extend(sim[seed])# 可能不同召回的物品有重叠,那么针对重叠的,可以将score累加,然后根据score降序排列。tmp_dict = dict()for (i, s) in recalls:if i in tmp_dict:tmp_dict[i] = tmp_dict[i] + selse:tmp_dict[i] = srec = sorted(tmp_dict.items(), key=lambda item: item[1], reverse=True)return rec[0:rec_num]
19.3.1.5 基于标签的用户画像召回
这个召回算法需要先基于用户行为构建用户的兴趣画像(这个我们在19.2.2节中已经讲解过,这里不赘述),然后基于用户兴趣画像,将与用户兴趣相关的物品作为召回物品集。这个算法的原理我们在第7章的7.2.2.2节中已经讲过,具体代码实现参考下面的代码块(对应我们github代码仓库recall/hm目录下的tags_user_portrait.py):
rec_num = 30
user_portrait = np.load("../../output/hm/user_portrait.npy", allow_pickle=True).item()
product_code_portrait_dict = np.load("../../output/hm/product_code_portrait_dict.npy", allow_pickle=True).item()
product_type_no_portrait_dict = np.load("../../output/hm/product_type_no_portrait_dict.npy", allow_pickle=True).item()
graphical_appearance_no_portrait_dict = np.load("../../output/hm/graphical_appearance_no_portrait_dict.npy", allow_pickle=True).item()
colour_group_code_portrait_dict = np.load("../../output/hm/colour_group_code_portrait_dict.npy", allow_pickle=True).item()
perceived_colour_value_id_portrait_dict = np.load("../../output/hm/perceived_colour_value_id_portrait_dict.npy", allow_pickle=True).item()
perceived_colour_master_id_portrait_dict = np.load("../../output/hm/perceived_colour_master_id_portrait_dict.npy", allow_pickle=True).item()
# {12:{id1,id2,...,id_k}, 34:{id1,id2,...,id_k}}, 这里面每个物品对应的特征权重都一样
customer_rec = dict()
for customer in user_portrait.keys():portrait_dict = user_portrait[customer]# { 'product_code': set([108775, 116379])# 'product_type_no': set([253, 302, 304, 306])# 'graphical_appearance_no': set([1010016, 1010017])# 'colour_group_code': set([9, 11, 13])# 'perceived_colour_value_id': set([1, 3, 4, 2])# 'perceived_colour_master_id': set([11, 5 ,9])# }product_code_rec = set()product_type_no_rec = set()graphical_appearance_no_rec = set()colour_group_code_rec = set()perceived_colour_value_id_rec = set()perceived_colour_master_id_rec = set()rec = []# 针对6类特征画像类型,用户在某个类型中都可能有兴趣点,针对每个兴趣点获得对应的物品id,将同一个画像类型# 中所有的兴趣点的物品推荐聚合到一起,最后对该兴趣画像类型,只取 rec_num 个推荐。# 最后,对6个兴趣画像类型的推荐,最终合并在一起,只取 rec_num 个作为最终的推荐。if 'product_code' in portrait_dict:product_code_set = portrait_dict['product_code']for product_code in product_code_set:product_code_rec = product_code_rec | product_code_portrait_dict[product_code]rec = rec.append(random.sample(product_code_rec, rec_num))if 'product_type_no' in portrait_dict:product_type_no_set = portrait_dict['product_type_no']for product_type_no in product_type_no_set:product_type_no_rec = product_type_no_rec | product_type_no_portrait_dict[product_type_no]rec = rec.append(random.sample(product_type_no_rec, rec_num))if 'graphical_appearance_no' in portrait_dict:graphical_appearance_no_set = portrait_dict['graphical_appearance_no']for graphical_appearance_no in graphical_appearance_no_set:graphical_appearance_no_rec = graphical_appearance_no_rec | graphical_appearance_no_portrait_dict[graphical_appearance_no]rec = rec.append(random.sample(graphical_appearance_no_rec, rec_num))if 'colour_group_code' in portrait_dict:colour_group_code_set = portrait_dict['colour_group_code']for colour_group_code in colour_group_code_set:colour_group_code_rec = colour_group_code_rec | colour_group_code_portrait_dict[colour_group_code]rec = rec.append(random.sample(colour_group_code_rec, rec_num))if 'perceived_colour_value_id' in portrait_dict:perceived_colour_value_id_set = portrait_dict['perceived_colour_value_id']for perceived_colour_value_id in perceived_colour_value_id_set:perceived_colour_value_id_rec = perceived_colour_value_id_rec | perceived_colour_value_id_portrait_dict[perceived_colour_value_id]rec = rec.append(random.sample(perceived_colour_value_id_rec, rec_num))if 'perceived_colour_master_id' in portrait_dict:perceived_colour_master_id_set = portrait_dict['perceived_colour_master_id']for perceived_colour_master_id in perceived_colour_master_id_set:perceived_colour_master_id_rec = perceived_colour_master_id_rec | perceived_colour_master_id_portrait_dict[perceived_colour_master_id]rec = rec.append(random.sample(perceived_colour_master_id_rec, rec_num))rec = random.sample(rec, rec_num)customer_rec[customer] = rec
np.save("../../output/hm/customer_rec.npy", customer_rec)
基于用户画像的召回是一种工程实现上非常简单的、并且效果也不错的召回算法,在工业界上是非常实用的方法。这个算法的最大特点是可解释性强,适合用于基于用户画像的各种运营策略中。该算法也可以非常方便地跟公司的用户画像体系打通。
19.3.2 排序算法
前面一小节我们讲解完了5种基于H&M数据集的召回算法,本节我们讲解5个排序算法,即logistics回归排序、FM排序、GBDT排序、匹配用户兴趣画像的排序和wide&deep排序。本节的排序算法比上一章的排序算法更加重要,也比上一章的要复杂,读者需要很好地理解和掌握。
19.3.2.1 基于logistics回归排序
logistics回归是最基础、最简单的一类线性模型,我们可以利用它来进行2元分类模型(即预测用户是否点击,1代表点击,0代表未点击)的构建(在第12章12.1节我们对logistics进行排序的原理已经做过详细介绍)。本节我们利用scikit-learn中的logistics回归函数来实现logistics回归排序。特征工程的工作我们在19.2.3节中已经讲解过,本节我们只讲解排序代码实现,具体的代码实现如下(对应我们github代码仓库ranking/hm目录下的logistics_regression.py):
"""
该脚本主要完成3件事情:
1. 训练logistic回归模型;
2. 针对测试集进行预测;
3. 评估训练好的模型在测试集上的效果;
这个脚本中的所有操作都可以借助scikit-learn中的函数来实现,非常简单。
这里为了简单起见,将模型训练、预测与评估都放在这个文件中了。
关于logistic回归模型各个参数的含义及例子可以参考,https://scikit-learn.org/stable/modules/generated/sklearn.linear_model.LogisticRegression.html#sklearn.linear_model.LogisticRegression
关于模型评估的案例可以参考:https://scikit-learn.org/stable/auto_examples/miscellaneous/plot_display_object_visualization.html#sphx-glr-auto-examples-miscellaneous-plot-display-object-visualization-py
"""
logistic_train_df = pd.read_csv('../../output/hm/logistic_train_data.csv')
"""
下面代码是训练logistic回归模型。
"""
clf = LogisticRegression(penalty='l2',solver='liblinear', tol=1e-6, max_iter=1000)
X_train = logistic_train_df.drop(columns=['customer_id', 'article_id', 'label', ])
y_train = logistic_train_df['label']
clf.fit(X_train, y_train)
# clf.coef_
# clf.intercept_
# clf.classes_
"""
下面的代码用上面训练好的logistic回归模型来对测试集进行预测。
"""
logistic_test_df = pd.read_csv('../../output/hm/logistic_test_data.csv')
X_test = logistic_test_df.drop(columns=['customer_id', 'article_id', 'label', ])
y_test = logistic_test_df['label']
# logistic回归模型预测出的结果为y_score
y_score = clf.predict(X_test)
# 包含概率值的预测
# y_score = clf.predict_proba(X_test)
# np.unique(Z)
# Counter(Z).most_common(2)
# logistic_test_df.label.value_counts()
"""
下面的代码对logistic回归模型进行效果评估,主要有3种常用的评估方法:
1. 混淆矩阵:confusion matrix
2. roc曲线:roc curve
3. 精准度和召回率:precision recall
"""
# confusion matrix
# 混淆矩阵参考百度词条介绍:https://baike.baidu.com/item/%E6%B7%B7%E6%B7%86%E7%9F%A9%E9%98%B5/10087822?fr=aladdin
y_score = clf.predict(X_test)
cm = confusion_matrix(y_test, y_score)
cm_display = ConfusionMatrixDisplay(cm).plot()
# roc curve
# ROC 和 AUC 的介绍见:
# 1. https://baijiahao.baidu.com/s?id=1671508719185457407&wfr=spider&for=pc
# 2. https://blog.csdn.net/yinyu19950811/article/details/81288287
fpr, tpr, _ = roc_curve(y_test, y_score, pos_label=clf.classes_[1])
roc_display = RocCurveDisplay(fpr=fpr, tpr=tpr).plot()
# precision recall
# 准确率和召回率的介绍参考:
# 1. https://www.zhihu.com/question/19645541/answer/91694636
pre, recall, _ = precision_recall_curve(y_test, y_score, pos_label=clf.classes_[1])
pr_display = PrecisionRecallDisplay(precision=pre, recall=recall).plot()
19.3.2.2 基于FM排序
本节讲解的FM算法的基本原理我们在第12章的12.2节中介绍过,这里不赘述。这里代码实现我们利用一个开源的FM实现(见参考文献4)。FM相关的特征工程跟logistics回归是基本一样的,logistics回归的特征可以简单处理后直接复用到FM中,我们这里不重复讲解。利用FM进行排序的代码实现如下(对应我们github代码仓库ranking/hm目录下的fm.py):
r"""基于 xlearn(pip3 install xlearn 或者直接从源码来安装) 包来实现 fm 算法。https://github.com/aksnzhy/xlearn输入数据格式:CSV format:y value_1 value_2 .. value_n0 0.1 0.2 0.2 ...1 0.2 0.3 0.1 ...0 0.1 0.2 0.4 ...example:# Load datasetiris_data = load_iris()X = iris_data['data']y = (iris_data['target'] == 2)X_train, X_val, y_train, y_val = train_test_split(X, y, test_size=0.3, random_state=0)# param:# 0. binary classification# 1. model scale: 0.1# 2. epoch number: 10 (auto early-stop)# 3. learning rate: 0.1# 4. regular lambda: 1.0# 5. use sgd optimization methodlinear_model = xl.LRModel(task='binary', init=0.1,epoch=10, lr=0.1,reg_lambda=1.0, opt='sgd')# Start to trainlinear_model.fit(X_train, y_train,eval_set=[X_val, y_val],is_lock_free=False)# Generate predictionsy_pred = linear_model.predict(X_val)
"""# Training task
fm_model = xl.create_fm() # Use factorization machine
fm_model.setTrain("../../output/hm/fm_train_data.csv") # Training dataparam = {"task": "binary","lr": 0.2,"lambda": 0.002,"metric": 'acc',"epoch": 20,"opt": 'sgd',"init": 0.1,"k": 15 # Dimensionality of the latent factors}# Use cross-validation
# fm_model.cv(param)# Start to train
# The trained model will be stored in model.out
fm_model.fit(param, '../../output/hm/fm_model.out')# Prediction task
fm_model.setTest("../../output/hm/predict_data.csv") # Test data
fm_model.setSigmoid() # Convert output to 0-1# Start to predict
# The output result will be stored in output.txt
fm_model.predict("../../output/hm/fm_model.out", "../../output/hm/fm_predict.csv")
19.3.2.3 基于GBDT排序
GBDT进行排序的算法原理我们在第12章的12.3节中讲过,本节的代码实现我们是基于开源的xgboost(见参考文献5)框架来实现的。特征部分可以复用logistics的特征(当然部分特征可以不用进行one-hot编码,读者可以尝试一下,我们在本章中没有详细讲解GBDT的特征部分,下面代码中xgb_X=full_preprocessing_feature[cols],xgb_Y = full_feature['target'] 部分就是处理好的特征),具体的GBDT模型训练及模型预测,可以参考下面的代码实现(对应我们github代码仓库ranking/hm目录下的gbdt.py)。另外,我们的代码中还包括超参数调优的实现,供读者参考。
r"""
利用xgboost包来进行 gbdt 模型的学习。
https://github.com/dmlc/xgboost
"""
import matplotlib.pyplot as plt
import xgboost as xgb
from sklearn.model_selection import train_test_split, GridSearchCV
from sklearn.metrics import accuracy_score, roc_auc_score, roc_curve
from sklearn import metrics
from tqdm import tqdm_notebook
tqdm_notebook().pandas()def cal_metrics(model, x_train, y_train, x_test, y_test):""" Calculate AUC and accuracy metricArgs:model: model that need to be evaluatedx_train: feature of training sety_train: target of training setx_test: feature of test sety_test: target of test set"""y_train_pred_label = model.predict(x_train)y_train_pred_proba = model.predict_proba(x_train)accuracy = accuracy_score(y_train, y_train_pred_label)auc = roc_auc_score(y_train, y_train_pred_proba[:, 1])print("Train set: accuracy: %.2f%%" % (accuracy*100.0))print("Train set: auc: %.2f%%" % (auc*100.0))y_pred = model.predict(x_test)accuracy = accuracy_score(y_test, y_pred)y_test_proba = model.predict_proba(x_test)auc = roc_auc_score(y_test, y_test_proba[:, 1])print("Test set: accuracy: %.2f%%" % (accuracy*100.0))print("Test set: auc: %.2f%%" % (auc*100.0))def model_iteration_analysis(alg, feature, predictors, use_train_cv=True,cv_folds=5, early_stopping_rounds=50):""" The optimal iteration times of the model are analyzedArgs:alg: modelfeature: feature of train setpredictors: target of train setuse_train_cv: whether to cross-validatecv_folds: the training set id divided into several partsearly_stopping_rounds: observation window size of iteration numberReturn:alg: optimal model"""if use_train_cv:xgb_param = alg.get_xgb_params()xgb_train = xgb.DMatrix(feature, label=predictors)# 'cv' function, can use cross validation on each iteration and return the desired number of decision trees.cv_result = xgb.cv(xgb_param, xgb_train, num_boost_round=alg.get_params()['n_estimators'], nfold=cv_folds,metrics='auc', early_stopping_rounds=early_stopping_rounds, verbose_eval=1)print("cv_result---", cv_result.shape[0])print(cv_result)alg.set_params(n_estimators=cv_result.shape[0])# Fit the algorithm on the dataalg.fit(feature, predictors, eval_metric='auc')# Predict training set:predictions = alg.predict(feature)pred_prob = alg.predict_proba(feature)[:, 1]# Print model report:print("\nModel Report")print("Accuracy : %.4g" % metrics.accuracy_score(predictors, predictions))print("AUC Score (Train): %f" % metrics.roc_auc_score(predictors, pred_prob))return algif __name__ == "__main__":# build Xgboost modelimport warningswarnings.filterwarnings('ignore')TEST_RATIO = 0.3RANDOM_STATE = 33xgb_X = full_preprocessing_feature[cols]xgb_Y = full_feature['target']X_full_train, X_full_test, y_full_train, y_full_test = \train_test_split(xgb_X, xgb_Y, test_size=TEST_RATIO, random_state=RANDOM_STATE)# build the base model using the initial valuesbase_model = xgb.XGBClassifier(learning_rate=0.1,n_estimators=200,booster='gbtree',colsample_bytree=1,gamma=0,max_depth=6,min_child_weight=1,reg_alpha=0,reg_lambda=1,scale_pos_weight=1,subsample=1,verbosity=0,objective='binary:logistic',seed=666)# train modelbase_model.fit(X_full_train, y_full_train)cal_metrics(base_model, X_full_train, y_full_train, X_full_test, y_full_test)# adjust tree structureparam_tree_struction = {'max_depth': range(3, 16, 2),'min_child_weight': range(1, 8, 2)}# grid searchfull_tree_struction_gsearch = GridSearchCV(estimator=base_model,param_grid=param_tree_struction, scoring='roc_auc',cv=5, verbose=0, iid=False)full_tree_struction_gsearch.fit(X_full_train, y_full_train)print(full_tree_struction_gsearch.best_params_, full_tree_struction_gsearch.best_score_,full_tree_struction_gsearch.best_estimator_)cal_metrics(full_tree_struction_gsearch.best_estimator_, X_full_train, y_full_train, X_full_test, y_full_test)# continue to adjust the tree structure more preciselyparam_tree_struction2 = {'max_depth': [6, 7, 8],'min_child_weight': [4, 5, 6]}tree_struction_gsearch2 = GridSearchCV(estimator=base_model,param_grid=param_tree_struction2,scoring='roc_auc', cv=5, verbose=0, iid=False)tree_struction_gsearch2.fit(X_full_train, y_full_train)print(tree_struction_gsearch2.best_params_, tree_struction_gsearch2.best_score_, tree_struction_gsearch2.best_estimator_)cal_metrics(tree_struction_gsearch2.best_estimator_, X_full_train, y_full_train, X_full_test, y_full_test)adjust_tree_struction_model = xgb.XGBClassifier(learning_rate=0.1,n_estimators=200,booster='gbtree',colsample_bytree=1,gamma=0,max_depth=6,min_child_weight=6,n_jobs=4,reg_alpha=0,reg_lambda=1,scale_pos_weight=1,subsample=1,verbosity=0,objective='binary:logistic',seed=666)# adjusting the Gamma parameterparam_gamma = {'gamma': [i / 10 for i in range(0, 10)]}gamma_gsearch = GridSearchCV(estimator=adjust_tree_struction_model, param_grid=param_gamma, scoring='roc_auc',cv=5, verbose=0, iid=False)gamma_gsearch.fit(X_full_train, y_full_train)print(gamma_gsearch.best_params_, gamma_gsearch.best_score_, gamma_gsearch.best_estimator_)# calculate AUC and accuracy metriccal_metrics(gamma_gsearch.best_estimator_, X_full_train, y_full_train, X_full_test, y_full_test)adjust_gamma_model = xgb.XGBClassifier(learning_rate=0.1,n_estimators=200,booster='gbtree',colsample_bytree=1,gamma=0,max_depth=6,min_child_weight=6,n_jobs=4,reg_alpha=0,reg_lambda=1,scale_pos_weight=1,subsample=1,verbosity=0,objective='binary:logistic',seed=666)# adjust sample ratio and column sampling ratio parametersparam_sample = {'subsample': [i / 10 for i in range(6, 11)],'colsample_bytree': [i / 10 for i in range(6, 11)],}sample_gsearch = GridSearchCV(estimator=adjust_gamma_model, param_grid=param_sample,scoring='roc_auc', cv=5, verbose=0, iid=False)sample_gsearch.fit(X_full_train, y_full_train)print(sample_gsearch.best_params_, sample_gsearch.best_score_, sample_gsearch.best_estimator_)cal_metrics(sample_gsearch.best_estimator_, X_full_train, y_full_train, X_full_test, y_full_test)adjust_sample_model = xgb.XGBClassifier(learning_rate=0.1,n_estimators=200,booster='gbtree',colsample_bytree=0.8,gamma=0,max_depth=6,min_child_weight=6,n_jobs=4,reg_alpha=0,reg_lambda=1,scale_pos_weight=1,subsample=0.8,verbosity=0,objective='binary:logistic',seed=666)# adjust regularization paramparam_L = {'reg_lambda': [1e-5, 1e-3, 1e-2, 1e-1, 1, 100],}L_gsearch = GridSearchCV(estimator=adjust_sample_model, param_grid=param_L, scoring='roc_auc', cv=5, verbose=0, iid=False)L_gsearch.fit(X_full_train, y_full_train)print(L_gsearch.best_params_, L_gsearch.best_score_, L_gsearch.best_estimator_)model_iteration_analysis(L_gsearch.best_estimator_, X_full_train, y_full_train, early_stopping_rounds=30)# adjusted learning rateparam_learning_rate = {'learning_rate': [0.005, 0.01, 0.02, 0.05, 0.08, 0.1, 0.15, 0.2],}learning_rate_gsearch = GridSearchCV(estimator=adjust_sample_model, param_grid=param_learning_rate, scoring='roc_auc', cv=5, verbose=0, iid=False)learning_rate_gsearch.fit(X_full_train, y_full_train)print(learning_rate_gsearch.best_params_, learning_rate_gsearch.best_score_, learning_rate_gsearch.best_estimator_)model_iteration_analysis(learning_rate_gsearch.best_estimator_,X_full_train, y_full_train, early_stopping_rounds=30)# optimal modelbest_model = xgb.XGBClassifier(learning_rate=0.1,n_estimators=200,booster='gbtree',colsample_bytree=0.7,gamma=0.6,max_depth=6,min_child_weight=2,reg_alpha=0,reg_lambda=1,scale_pos_weight=1,subsample=0.9,verbosity=0,objective='binary:logistic',seed=666)best_model.fit(X_full_train, y_full_train)print('--- the training set and test set metrics of Xgboost model ---\n')cal_metrics(best_model, X_full_train,y_full_train, X_full_test, y_full_test)print('\n')print(best_model.get_xgb_params())# according to XgBoost model, the importance of features was analyzedprint('\n')print('---according to xGBoost model, the importance of features was analyzed---\n')from xgboost import plot_importancefig, ax = plt.subplots(figsize=(10,15))plot_importance(best_model, height=0.5, max_num_features=100, ax=ax)plt.show()# Draw ROC curvey_pred_proba = best_model.predict_proba(X_full_test)fpr, tpr, thresholds = roc_curve(y_full_test, y_pred_proba[:, 1])print('---ROC curve of xgboost model ---\n')plt.title('roc_curve of xgboost (AUC=%.4f)' % (roc_auc_score(y_full_test, y_pred_proba[:, 1])))plt.xlabel('FPR')plt.ylabel('TPR')plt.plot(fpr, tpr)plt.show()
19.3.2.4 基于匹配用户画像兴趣的排序
该排序算法基于输入的几个召回算法提供的召回列表,基于用户的兴趣画像,利用召回列表中的物品来匹配用户兴趣画像,按照与用户兴趣画像的匹配度对召回物品进行降序排列,最终将topN作为最终的排序结果推荐给用户,算法原理我们在第11章的11.4节中已经做过介绍,不熟悉的读者可以参考一下,具体的代码实现如下(对应我们github代码仓库ranking/hm目录下的n_recalls_matching_user_portrait.py)。
r"""
基于用户兴趣画像,利用召回物品跟用户画像的匹配度来进行排序。
"""
import numpy as np
article_dict = np.load("../../output/hm/article_dict.npy", allow_pickle=True).item()
user_portrait = np.load("../../output/hm/user_portrait.npy", allow_pickle=True).item()def user_portrait_similarity(portrait, article_id):"""计算某个article与用户画像的相似度。:param portrait: 用户画像。{ 'product_code': set([108775, 116379])'product_type_no': set([253, 302, 304, 306])'graphical_appearance_no': set([1010016, 1010017])'colour_group_code': set([9, 11, 13])'perceived_colour_value_id': set([1, 3, 4, 2])'perceived_colour_master_id': set([11, 5 ,9])}:param article_id: 物品id。:return: sim,double,相似度。"""feature_dict = article_dict[article_id]# article_dict[957375001]# {'product_code': 957375, 'product_type_no': 72,# 'graphical_appearance_no': 1010016, 'colour_group_code': 9,# 'perceived_colour_value_id': 4, 'perceived_colour_master_id': 5}sim = 0.0features = {'product_code', 'product_type_no', 'graphical_appearance_no','colour_group_code', 'perceived_colour_value_id', 'perceived_colour_master_id'}for fea in features:fea_value = feature_dict[fea]if fea_value in portrait[fea]:sim = sim + 1.0 # 只要用户的某个画像特征中包含这个物品的该画像值,那么就认为跟用户的兴趣匹配return sim/6def user_portrait_ranking(portrait, recall_list, n):"""利用用户画像匹配度进行排序。:param portrait: 用户画像。{ 'product_code': set([108775, 116379])'product_type_no': set([253, 302, 304, 306])'graphical_appearance_no': set([1010016, 1010017])'colour_group_code': set([9, 11, 13])'perceived_colour_value_id': set([1, 3, 4, 2])'perceived_colour_master_id': set([11, 5 ,9])}:param recall_list: [recall_1, recall_2, ..., recall_k].每个recall的数据结构是 recall_i ~ [(v1,s1),(v2,s2),...,(v_t,s_t)]:param n: 推荐数量:return: rec ~ [(v1,s1),(v2,s2),...,(v_t,s_t)]"""rec_dict = dict()for recall in recall_list:for (article_id, _) in recall:sim = user_portrait_similarity(portrait, article_id)if article_id in rec_dict:rec_dict[article_id] = rec_dict[article_id] + sim# 如果多个召回列表,召回了相同的物品,那么相似性相加。else:rec_dict[article_id] = simrec = sorted(rec_dict.items(), key=lambda item: item[1], reverse=True)return rec[0:n]if __name__ == "__main__":rec_num = 5customer = "00083cda041544b2fbb0e0d2905ad17da7cf1007526fb4c73235dccbbc132280"customer_portrait = user_portrait[customer]recall_1 = [(111586001, 0.45), (112679048, 0.64), (158340001, 0.26)]recall_2 = [(176550016, 0.13), (189616038, 0.34), (212629035, 0.66)]recall_3 = [(233091021, 0.49), (244853032, 0.24), (265069020, 0.71)]recalls = [recall_1, recall_2, recall_3]rec = user_portrait_ranking(customer_portrait, recalls, rec_num)print(rec)
19.3.2.5 基于wide&deep模型的排序
我们最后要讲解的排序算法是wide&deep排序,该算法的原理我们在第13章的13.1节中做过介绍,这个算法是非常经典的深度学习排序算法,读者需要好好掌握。本节的代码实现我们利用开源的pytorch-widedeep框架(见参考文献6、7),这个框架是我能够找到的最好的wide&deep的框架,实现比较简洁,抽象合理,并且还对wide&deep进行了拓展,可以整合文本、图像特征,读可以自行学习一下。
pytorch-widedeep框架下的wide&deep的特征跟logistics类似,这里不展开了,下面贴出具体的模型训练和预测的代码(对应我们github代码仓库ranking/hm目录下的wide_and_deep.py),读者可以基于该代码实现和pytorch-widedeep的官网进行学习,搞清楚具体每一步的实现逻辑。
r"""利用 pytorch 实现wide & deep模型,我们用开源的pytorch-widedeep来实现。代码仓库:https://github.com/jrzaurin/pytorch-widedeep参考文档:https://pytorch-widedeep.readthedocs.io/en/latest/index.html
"""# Define the 'column set up'
wide_cols = ["sales_channel_id","Active","club_member_status","fashion_news_frequency",
]
crossed_cols = [("product_code", "product_type_no"), ("product_code", "FN"),("graphical_appearance_no", "FN"), ("colour_group_code", "FN"),("perceived_colour_value_id", "FN"), ("perceived_colour_master_id", "FN")]cat_embed_cols = ["sales_channel_id","product_code","product_type_no","graphical_appearance_no","colour_group_code","perceived_colour_value_id","perceived_colour_master_id","FN","Active","club_member_status","fashion_news_frequency","postal_code"
]
continuous_cols = ["t_dat", "price", "age"]
target = "label"
target = df_train[target].values# prepare the data
wide_preprocessor = WidePreprocessor(wide_cols=wide_cols, crossed_cols=crossed_cols)
X_wide = wide_preprocessor.fit_transform(df_train)tab_preprocessor = TabPreprocessor(cat_embed_cols=cat_embed_cols, continuous_cols=continuous_cols # type: ignore[arg-type]
)
X_tab = tab_preprocessor.fit_transform(df_train)# build the model
wide = Wide(input_dim=np.unique(X_wide).shape[0], pred_dim=1)
tab_mlp = TabMlp(column_idx=tab_preprocessor.column_idx,cat_embed_input=tab_preprocessor.cat_embed_input,cat_embed_dropout=0.1,continuous_cols=continuous_cols,mlp_hidden_dims=[400, 200],mlp_dropout=0.5,mlp_activation="leaky_relu",
)
model = WideDeep(wide=wide, deeptabular=tab_mlp)# train and validate
accuracy = Accuracy(top_k=2)
precision = Precision(average=True)
recall = Recall(average=True)
f1 = F1Score()
early_stopping = EarlyStopping()
model_checkpoint = ModelCheckpoint(filepath="../../output/hm/tmp_dir/wide_deep_model",save_best_only=True,verbose=1,max_save=1,
)
trainer = Trainer(model, objective="binary",optimizers=torch.optim.AdamW(model.parameters(), lr=0.001),callbacks=[early_stopping, model_checkpoint],metrics=[accuracy, precision, recall, f1])
trainer.fit(X_wide=X_wide,X_tab=X_tab,target=target,n_epochs=30,batch_size=256,val_split=0.2
)# predict on test
X_wide_te = wide_preprocessor.transform(df_test)
X_tab_te = tab_preprocessor.transform(df_test)
pred = trainer.predict(X_wide=X_wide_te, X_tab=X_tab_te)
# pred_prob = trainer.predict_proba(X_wide=X_wide_te, X_tab=X_tab_te) # 预测概率
y_test = df_test['label']
print(accuracy_score(y_test, pred))# Save and load
trainer.save(path="../../output/hm/model_weights",model_filename="wd_model.pt",save_state_dict=True,
)# prepared the data and defined the new model components:
# 1. Build the model
model_new = WideDeep(wide=wide, deeptabular=tab_mlp)
model_new.load_state_dict(torch.load("../../output/hm/model_weights/wd_model.pt"))# 2. Instantiate the trainer
trainer_new = Trainer(model_new, objective="binary",optimizers=torch.optim.AdamW(model.parameters(), lr=0.001),callbacks=[early_stopping, model_checkpoint],metrics=[accuracy, precision, recall, f1])# 3. Either start the fit or directly predict
pred = trainer_new.predict(X_wide=X_wide_te, X_tab=X_tab_te)
总结
本章我们讲解了所有基于H&M数据集的召回和排序算法,本章的算法比上一章更复杂、更有挑战、也更重要,因此,本章的内容读者要非常熟悉,特别是代码实现细节,需要读者能够完全理解并掌握,最好自己能够跟着本章提供的代码初稿,自己重新跑一下,如果能够对我们提供的代码进行优化完善,那再好不过了。我们也希望读者可以贡献更多、更好的代码实现。
到目前为止,我们介绍完了本书所有的召回、排序算法的代码实战案例,这些算法也覆盖了我们在第6-第13章的绝大多数召回、排序算法,唯一的例外是基于YouTube的召回、排序算法(YouTube召回参考第9章9.2节,YouTube排序算法参考第13章13.2节)。主要是YouTube算法利用的数据集比较特殊,需要用户的搜索、观看记录,预测的是用户的播放时长,比较适合视频类场景,不过github上还是有一个不错的、基于构造的数据集给出了YouTube_DNN的实现(见参考文献8),后面我们也会基于该代码实现进行适当微调整合到我们的github代码仓库中。
前面的章节,我们已经介绍完了本书关于推荐系统算法、工程、代码实现的最核心的部分了。后面我们会增加一些行业应用案例,最后一章会对推荐系统的未来发展进行梳理和预测。
参考文献
https://www.kaggle.com/competitions/h-and-m-personalized-fashion-recommendations/overview
https://github.com/RaRe-Technologies/gensim
https://radimrehurek.com/gensim/
https://github.com/aksnzhy/xlearn
https://github.com/dmlc/xgboost
https://github.com/jrzaurin/pytorch-widedeep
https://pytorch-widedeep.readthedocs.io/en/latest/index.html
https://github.com/hyez/Deep-Youtube-Recommendations
大家如果对推荐系统感兴趣,可以点击下面链接购买我出版的图书《构建企业级推荐系统》,全面深入地学习企业级推荐系统的方法论。