代码详解:创建一个百分百懂你的产品推荐系统
全文共10848字,预计学习时长21分钟或更长

图片来源:Unsplash/Susan Yin
你也许每天都会逛一逛电子商务网站,或者从博客、新闻和媒体出版物上阅读大量文章。
浏览这些东西的时候,最令读者或者用户烦恼的事情是什么呢?
——有太多的东西可以看,反而会经常看不到自己正在搜索的东西。
是的,网上有太多的信息和文章,用户需要一种方式来简化他们的发现之旅。
如果你在经营一家电子商务网站或博客,你也许会问:有这个必要吗?
嗯……你听过漏斗吗?
用户所用的漏斗越小,产品的转换就越大。这是用户体验的基本原则。所以,如果减少步骤的数量可以增加网站页面的浏览量甚至是收入,为什么不这么做呢?

推荐系统如何提供帮助?
简单来说,推荐系统就是一个发现系统,该系统可通过分析数据向用户提供推荐。不需要用户去专门搜索,系统自动带来推荐商品。
这听起来像是魔法。
亚马逊和Netflix几十年前就开始使用这种魔法了。
一打开Spotify,它就已经为用户提供了一个推荐歌单(这种深度个性化推荐服务叫作Discover Weekly)。

图片来源:Unsplash/Samuel Zeller

深入了解推荐系统
一般来说,我们所知的推荐系统有两种——当然并不是所有的人都知道。
1. 基于内容的推荐系统
这类推荐系统很容易被我们的大脑消化,而且不会出现短路或爆炸的迹象。
例如,你是一个狂热的小说迷,喜欢阿加莎·克里斯蒂的《无人生还》,并从网上书店买了这本书。
那么,当你下次再打开网站时,网上书店就会给你推荐《ABC谋杀案》。
为什么呢?
因为它们都是阿加莎·克里斯蒂的作品。
因此,基于内容的推荐模型会向你推荐这本书。
就是这么简单!那就来用一用吧!
等等……
虽然这种基于内容的推荐很容易被我们的大脑消化,看起来也很简单,但它无法预测用户的真实行为。
例如,你不喜欢侦探赫丘里·波罗,但喜欢阿加莎·克里斯蒂小说中的其他侦探。在这种情况下,网站就不应该向你推荐《ABC谋杀案》。
2. 协同过滤推荐系统
这种类型的推荐系统克服了上面的问题。本质上,该系统记录了用户在网站上的所有交互,并基于这些记录提出建议。
它是什么原理呢?
请看下面的场景:
这里有两个用户,用户A和用户B。
用户A购买了商品1
用户A购买了商品2
用户A购买了商品3
用户B购买了商品1
用户B购买了商品3
那么协同过滤系统将会向用户B推荐商品2,因为有另外一个用户也购买了商品1和商品3,同时还购买了商品2。
你也许会说,得了吧,他们可能是偶然才一起买了那些巧合的商品。
但是,如果有100个用户都与用户A有相同的购买行为呢?
这就是所谓的群众的力量。
那么,你还在等什么呢?让我们开始在你的生产环境中创建协同过滤推荐系统吧!
等等,先别着急!
虽然这个系统性能极佳,但在尝试创建可用于生产的系统时,它还存在几个严重问题。

协同过滤推荐系统的不足
1. 它不知道用户的购物习惯。基于内容的推荐系统会根据用户的购物记录推荐相似商品,与此相反,协同过滤推荐系统的推荐并不是基于相似性。如果你关心这一问题的话,解决方案就是将两种方法混合起来,结合使用。
2. 因为需要存储用户项矩阵,所以系统需要大量的硬件资源。假设你的电子商务网站有10万用户;与此同时,你的网站提供1万种产品。在这种情况下,你将需要10000 x 100000的矩阵,每个元素包含4个字节的整数。是的,光是存储矩阵,不做其他事,你就需要4GB的内存。
3. “冷启动”(冰冷的开始),该系统并不会为新用户带来好处,因为系统并不了解新用户。
4. 不变性。如果用户没有在网站上进行搜索或购物,系统的推荐将一成不变。于是用户就会认为网站上没有什么新鲜东西,从而退出网站。
通过混合使用两种推荐系统可以轻易解决第1个问题,然而,其他问题仍然令人头痛。
本文的目的就是解决第2、第3和第4个问题。
让我们开始吧!

使推荐系统可用于生产的终极指南
如何解决这些问题?机器本身存在限制,而且就算是根据常识,也不可能仅为小小的需求就部署一个巨大的服务器。
推荐下面这本书:

Ted Dunning 和Ellen Friedman的《实用性机器学习》
这本书告诉我们,对于一个可用于生产的系统,你不需要指望它在任何方面都具备最高精度。
在实际的用例中,一个有些不准确但又可以接受的方法,通常是最有效的。
关于如何做到这一点,最有趣的部分是:
1. 对通用推荐指标进行批量计算。
2. 实时查询,不使用用户-商品矩阵,而是获取用户的最新交互并向系统查询。
下面我们边构建系统边解释。

Python的推荐系统
为什么选择python? 因为python的语言简单易学,只需要几个小时就能理解它的语法。
通过上面代码,你可以打印包里的所有项。
请访问Python官网(https://www.python.org/downloads/),根据操作系统下载并安装相应安装包。
本教程需要用到以下几个安装包。
pip install numpy
pip install scipy
pip install pandas
pip install jupyter
pip install requests
Numpy和Scipy是处理数学计算的python包,建构矩阵时需要用到它们。Pandas 用于数据处理。Requests用于http调用。Jupyter是一个可以交互运行python代码的网络应用程序。
输入Jupyter Notebook,你会看到如下界面

在提供的单元格上编写代码,代码将以交互方式运行。
开始之前需要几个工具。
1. Elasticsearch(弹性搜索)。这是一个开源搜索引擎,可以帮助快速搜索到文档。这个工具可用于保存计算指标,以便实时查询。
2. Postman。这是一个API开发工具,可用来模拟弹性搜索中的查询,因为弹性搜索可以通过http访问。
下载并安装这两个工具,接着就可以开始了。
数据
先来看看Kaggle中的数据集:电子商务网站行为数据集(http://www.baidu.com/link?url=-uZgHHgYJmRlBX5WL_ufkLSb0S5eXU0j43iPMLh3XNtXbLq5uNoqe3Oje7AUt0PK)。下载并提取Jupyter 笔记本目录中的数据。

在这些文件中,本教程只需要用到events.csv。
该文件由用户对电子商务网站上的商品进行的数百万次操作组成。
开始探索数据吧!
import pandas as pdimport numpy as np
将输入写在Jupyter Notebook上,就可以开始了。
它会输出(2756101,5),这意味着你有270万行和5列。
让我们来看看
df.head()

它有5栏。
1. 时间戳(Timestamp),事件的时间戳
2. 访问者ID(Visitorid),用户的身份
3. 商品ID(Itemid), 商品的名称
4. 事件(Event), 事件
5. 交易ID(Transactionid),如果事件是交易,则为交易ID
下面检查一下,哪些事件是可用的
df.event.unique()
你将获得三个事件:浏览、添加到购物车和交易。
你可能嫌麻烦,不想处理所有事件,所以本教程中只需处理交易。
所以,我们只过滤交易。
trans = df[df['event'] == 'transaction']trans.shape
它将输出(22457, 5)
也就是说你将有22457个交易数据可以处理。这对新手来说已经足够了。
下面来进一步看看数据
你将获得11719个独立访问者和12025个独立商品。
创建一个简单而有效的推荐系统,经验之谈是在不损失质量的情况下对数据进行抽样。这意味着,对于每个用户,你只需获取50个最新交易数据,却仍然可以获得想要的质量,因为顾客行为会随着时间的推移而改变。
trans2 = trans.groupby(['visitorid']).head(50)trans2.shape
现在你只有19939笔交易。这意味着2000笔左右的交易已经过时。
由于访问者ID和商品ID是一长串的数字,你很难记住每个ID。
你需要其他基于0的索引列。如以下界面所示:

这样更加清晰。接下来的所有步骤只需使用访问者和商品栏。

图片来源:Unsplash/Andrei Lazarev
下一步:创建用户-商品矩阵
噩梦来了……
一共有11719个独立访问者和12025个商品,所以需要大约500MB的内存来存储矩阵。
稀疏矩阵(Sparse matrix)这时候就派上用场了。
稀疏矩阵是大多数元素为零的矩阵。这是有意义的,因为不可能所有的用户都购买所有的商品,很多连接都将为零。
from scipy.sparse import csr_matrix
Scipy很有用。
occurences = csr_matrix((visitors.shape[0], items.shape[0]), dtype='int8')def set_occurences(visitor, item): occurences[visitor, item] += 1trans2.apply(lambda row: set_occurences(row['visitors'], row['items']), axis=1)occurences
对数据中的每一行应用set_occurences函数。
会输出如下结果:
<11719x12025 sparse matrix of type '<class 'numpy.int8'>'
with 18905 stored elements in Compressed Sparse Row format>
在矩阵的1.4亿个单元格中,只有18905个单元格是用非零数据填充的。
所以,实际上只需要把这18905个值存储到内存中,效率就能提高99.99%。
但稀疏矩阵有一个缺点,想要实时检索数据的话,需要很大的计算量。所以,到这里还没有结束。
共现矩阵
下面建构一个商品-商品矩阵,其中每个元素表示用户同时购买两个商品的次数,我们称之为共现矩阵。
要创建共现矩阵,你需要将共现矩阵的转置与自身做点积。
有人试过在没有稀疏矩阵的情况下这样做,结果电脑死机了。所以,千万不要重蹈覆辙。
cooc = occurences.transpose().dot(occurences)
cooc.setdiag(0)
电脑立马输出了一个稀疏矩阵。
setdiag函数将对角线设置为0,这意味着你不想计算商品1的值,而商品1的位置都在一起,因为它们是相同的项目。
异常行为
共现矩阵包含同时购买两种商品的次数。
但也可能会存在一种商品,购买这种商品本身和用户的购物习惯没有任何关系,可能是限时抢购之类的商品。
在现实中,你想要捕捉的是真正的用户行为,而非像限时抢购那样非常规行为。
为了消除这些影响,你需要对共现矩阵的得分进行扣除。
Ted Dunnings在前一本书中提出了一种算法,叫做对数似然比(Log-Likelihood Ratio, LLR)。
LLR函数计算的是A和B两个事件同时出现的可能性。
参数有
1.k11, 两个事件同时发生的次数
2.k12, 事件B 单独发生的次数
3.k21, 事件A单独发生的次数
4.k22, 事件A和事件B都没有发生的次数
现在计算LLR函数并将其保存到pp_score矩阵中。
row_sum = np.sum(cooc, axis=0).A.flatten()column_sum = np.sum(cooc, axis=1).A.flatten()total = np.sum(row_sum, axis=0)pp_score = csr_matrix((cooc.shape[0], cooc.shape[1]), dtype='double')cx = cooc.tocoo()for i,j,v in zip(cx.row, cx.col, cx.data): if v != 0: k11 = v k12 = row_sum[i] - k11 k21 = column_sum[j] - k11 k22 = total - k11 - k12 - k21 pp_score[i,j] = rootLLR(k11, k12, k21, k22)
对结果进行排序,使每种商品LLR得分最高的位于每行的第一列。
result = np.flip(np.sort(pp_score.A, axis=1), axis=1)
result_indices = np.flip(np.argsort(pp_score.A, axis=1), axis=1)
推荐系统的指标
结果矩阵中的第一项指标如果足够高的话,可以被视为该项的指标。
来看一下其中的一个结果
result[8456]
你会得到
array([15.33511076, 14.60017668, 3.62091635, ..., 0. ,
0. , 0. ])
再看看指标
result_indices[8456]
你会得到
array([8682, 380, 8501, ..., 8010, 8009, 0], dtype=int64)
可以有把握地说,商品8682和商品380的LLR分数很高,可以作为商品8456的指标。而商品8501分数不是那么高,可能不能作为商品8456的指标。
这意味着,如果有用户购买了商品8682和商品380,你可以向他推荐商品8456。
这很简单。
但是,根据经验,你可能想给LLR分数施加一些限制,这样可以删除无关紧要的指标。
现在,已经准备好将它们组合到弹性搜索中了,这样就可以实时查询推荐。
import requests
import json
好了,现在可以把之前准备好的东西放到弹性搜索中了。
但是,请注意。如果你想用 /_create/<id> API一个个地添加数据,将会花费很长时间。你当然可以这么做,但是可能需要花费半个小时到一个小时才能把12025个商品转移到弹性搜索中。
那怎么解决这个问题呢?
批量更新
幸运的是,弹性搜索拥有批量API,可以轻松地同时发送多个文档。
因此,创建一个新索引(items2),让我们来尝试一下:
actions = []
for i in range(indicators.shape[0]):
length = indicators[i].nonzero()[0].shape[0]
real_indicators = items[indicators_indices[i, :length]].astype('int').tolist()
id = items[i]
action = { 'index' : { '_index' : 'items2', '_id' : str(id) } }
data = {
'id': int(id),
'indicators': real_indicators
}
actions.append(json.dumps(action))
actions.append(json.dumps(data))
if len(actions) == 200:
actions_string = '\n'.join(actions) + '\n'
actions = []
url = 'http://127.0.0.1:9200/_bulk/'
headers = {
'Content-Type' : 'application/x-ndjson'
}
requests.post(url, headers=headers, data=actions_string)if len(actions) > 0:
actions_string = '\n'.join(actions) + '\n'
actions = [] url = 'http://127.0.0.1:9200/_bulk/'
headers = {
'Content-Type' : 'application/x-ndjson'
}
requests.post(url, headers=headers, data=actions_string)
瞧,只需要几秒钟就能完成。
在Postman中点击这个API
127.0.0.1:9200/items2/_count
你就存储了数据
{ 'count': 12025, '_shards': { 'total': 1, 'successful': 1, 'skipped': 0, 'failed': 0 }}
用/items2/240708检查一下商品数据
Id是商品的Id,而指标则是成为该商品推荐指标的其他商品。
实时查询
创建的最棒的部分就是实时查询
{ 'query': { 'bool': { 'should': [ { 'terms': {'indicators' : [240708], 'boost': 2}} ] } }}
发送请求到127.0.0.1:9200/items2/_search
你会得到三个结果。商品312728, 商品305675和 商品346067。正是会与商品240708一起购买的三件商品。
太棒了!现在大量的资源需求已经不是问题了。那么,另外两个问题呢?

图片来源:Unsplash/Sean O.

“冷启动”问题:我不认识你
创建推荐系统时,最常见的就是冷启动问题,因为系统中不会有新用户的任何行为记录。
那么,系统应该向他们推荐什么呢?
请看我们最近构建的推荐系统。你觉得这个结果有什么异常吗?
是的,结果只返回3个推荐项——只有3个。你打算如何向客户展示这三个可怜的推荐项呢?
为了更好的用户体验,让我们将未受推荐的商品放在列表末尾。
你可以使用常数分数来返回所有其他项。
但是,你也需要对所有未受推荐的项目进行排序,这样即使没有再用户的行为中捕捉到,也有可能是用户会喜欢的商品。
多数情况下,受欢迎的商品非常好用。
如何确定一个商品是否受欢迎呢?
popular = np.zeros(items.shape[0])def inc_popular(index): popular[index] += 1trans2.apply(lambda row: inc_popular(row['items']), axis=1)
这很简单,逐个数商品的出现次数,出现次数最多的就最流行。让我们创建另一个索引items3。批量插入
这个索引阶段中也包括流行字段。所以数据会是这样的
{ 'id': 240708, 'indicators': [ 305675, 346067, 312728 ], 'popular': 3.0}
你将会有三个字段。ID,指标(与前面类似),以及流行字段(也就是用户购买的商品数量)。
在前面的查询中加入popular。
函数得分:组合得分的方法
所以,现在有多个得分来源,即指标分数和流行分数,那么如何将分数组合起来呢?
可以用弹性搜索的功能评分。
修改查询,并添加一个功能评分,将流行值的0.1倍添加到上面的常量分数中。不必执着于0.1,也可以使用其他函数,甚至自然对数。像这样:
Math.log(doc['popular'].value)
现在,可以看到最受欢迎的商品461686排在第四位,仅低于推荐商品。

下面依次是其它受欢迎的商品。

不变的、静态的推荐
如你所见,每次实时查询时,推荐结果都保持不变。一方面这很好,因为我们的技术是可复制的;但另一方面,用户可能对此并不满意。
Ted Dunnings在他的书中说,在推荐的第20个商品后,点击率将会非常低。这意味着在那之后我们推荐的任何商品都不会被用户知道。
怎么解决这个问题呢?
有一种技术叫做抖动。它会在查询时产生一种随机干扰,使最不受推荐的商品的排名提前,但同时又保证受到强烈推荐的商品仍然在推荐列表的前几位。
{ 'query': { 'function_score':{ 'query': { 'bool': { 'should': [ { 'terms': {'indicators' : [240708], 'boost': 2}}, { 'constant_score': {'filter' : {'match_all': {}}, 'boost' : 0.000001}} ] } }, 'functions':[ { 'filter': {'range': {'popular': {'gt': 1}}}, 'script_score' : { 'script' : { 'source': '0.1 * Math.log(doc['popular'].value)' } } }, { 'filter': {'match_all': {}}, 'random_score': {} } ], 'score_mode': 'sum', 'min_score' : 0 } }}
随机分数会给出使所有商品均匀分布的随机干扰。得分很小,这样最受欢迎的推荐商品排名就不会下降。
好处在于用户将浏览时不必滚动到第二或第三页,只需要点击浏览器上的刷新按钮,就会得到新的内容。
这很神奇。

图片来源:Unsplash/Marvin Meyer

