作者 | HCY崇远
01 前言
本文源自于前阵子连续更新的推荐系统系列,前段时间给朋友整理一个关于推荐系统相关的知识教学体系,刚好自身业务中,预计明年初随着业务规模增长,估摸着又要启动推荐相关的项目了,所以也是趁机把相关的知识结构梳理了一遍。这这里重新做整理,并额外做了一些增减,让整体逻辑会更通顺一点。
整个文章的结构逻辑,先从推荐系统的基础知识结构讲起,然后由浅入深过渡到几个推荐策略算法上,并且为每个推荐策略算法提供一些简单的入门Spark案例代码,再从策略过渡到系统层级,包括数据架构、策略组合、效果评估等,最终再从上层产品设计的角度去补充整个系统知识结构。
整体来看,通篇并没有涉及到特别高深的推荐算法(部分专门讲这部分的文章,只有有一定基础的朋友才更容易接受,本文章的逻辑略有不同),大多都是常规的策略模型,核心在于对整个推荐系统的知识结构进行解构,让那些对于推荐系统感兴趣的朋友能快速的建立起对于推荐系统的知识体系结构,甚至能够通过文章中的算法案例,做简单的实践,从而达到快速入门的目的。
友情提示:据不完全统计,包含代码片,通篇大约接近1万字,预计阅读时间?我也不知道,哈哈,可以当成短篇小说来读了。
02 推荐系统场景需求
本章节从场景的需求出发,试图来分析推荐系统需求的必须性,以及很多平台言必谈个性化推荐的现状。
0.2.1 先从人工智能话题出发
说推荐系统之前先掰掰人工智能,这个词估计大家能能听得懂,毕竟是风口上的名词,想没听过也难。那么问题来了,你觉得推荐系统与人工智能有什么关系?
或许大半的人会认为没有半毛钱的关系,这让我想到了前几天周末在知乎上怼的一个问题,问题的核心就是:“现在大数据都很low了,大家都是去搞人智能了”。
这典型就是对于人工智能定义认知的问题,个人认为人工智能就是一个偏业务的定义,多维度多学科交叉的概念,压根儿就不好以技术维度去对比去评判。其核心的三要素就是:算法、计算能力以及数据。
围绕大量的基础数据,对基础数据进行特征处理,然后构建有用的业务算法模型,然后基于分布式的基础架构计算能力,将算法模型的用于实际的生产环境,以机器替代人工的作业,以提升效果与效率,达到机器智能化的目标。
那再回到推荐系统的话题,在过去传统的门户网站或者其他领域,也是有推荐场景的,不过大部分都是基于编辑或者运营手动进行配置推送,随着对数据、对算法模型的进一步应用,才逐渐有算法机器替代人工进行推荐,并且达到诸如“千人千面”、“个性化”推荐的效果。
所以,追究其本质,其实也是算法模型+计算过程+基础数据的流程,并且最终达到了机器自动化、智能化的效果,从广义的角度来说,或许复杂一些的推荐系统或许也能纳入人工智能的范畴了(真心怕那种一说到人工智能=神经网络的选手)。
0.2.2 推荐与检索两种信息获取的方式
说到推荐系统,就不得不说一下搜索引擎。不管是搜索也好、推荐也好,他们都是信息获取的一种机制,核心区别在于主动与被动。
搜索引擎是典型的主动触发的形态,即用户已经有明确的信息获取意图,渴望得到自身既定的目标信息,让后通过搜索规则进行最终信息的获取。
比如,你好奇什么是人工智能,那么你就会用诸如谷歌、或者国产大百度去搜索,然后获取到相关网页,去点击查看,最终完成你了解人工智能这个信息获取的目的。这就是检索的机制,你先要告诉系统你的意图,然后在给你筛选你要的信息。
而推荐系统则大大的不同,它是一种系统主动的行为,对于用户来说是一个被动的行为,被动的接受系统推送过来的信息。那这样强扭是不是很尴尬呢?怎么有这么SHA叉的机制?
其实不是的,尴尬的是推的不对,东西推对了就尴尬了,比如你正在浏览一个信息,正在愁这个信息还没解决你的问题的时候,系统啪丢给你几个新增的信息,说这个几个信息可能能解决的问题,你一看我凑,这正是我要的,感谢万能的推荐系统!
所以,推荐核心解决的还是用户额外信息获取的问题,以及提升用户的进一步转化,停留时间的延长(只要停留时间延长,商业转化机会就会加大,也是粘性提升的体现),而问题的核心就是要推的准,推的恰到好处,不然就是反作用。
因为推荐要解决的就是海量信息冗余,用户在目的不算很明确的情况,进一步帮助其确定目标,主动筛选信息的过程,推的不好那对于用户来说就更冗余了。
关于信息的获取,其实还有一种常见的形态,那就是结构化导航,比如电商平台的分门别类罗列,门户网站的结构化频道信息。它是通过把信息进行结构化了,构建脉络结构,帮助你去获取你要的信息。不过,这个就不在我们的讨论范围内了。
0.2.3 推荐系统的场景
说了这么多篇逻辑理论的东西,或许很多朋友依然对推荐系统没有一个很场景化的认知,比如具体什么场景?具体什么形态?
这是我在腾讯视频上截的图,这就是典型的视频推荐场景,我不是鹅厂腾讯视频业务的算法工程师,所以我无法回答你他们的推荐机制,但我可以告诉你,当时我的观看主体是“地球脉动”,结合推荐列表,大伙儿可以揣摩一下他的推荐机制。当前观看的属性相关?导演关联性?我的观看记录偏好?从我个人的评估来看,这些因素应该都有。
顺带说一下的就是,一个完整的好的推荐系统,一定不会单纯的依赖于某个推荐算法,虽然这个系列的后面文章中,我会讲一些推荐机制或者算法逻辑,甚至附上简单的案例代码,但还是要提前说一下这个问题。
我们再来看几个同样是腾讯系的产品推荐场景:
QQ音乐平台的推荐,分析来看应该跟我当前主页音乐的风格、以及我的历史浏览相关。
这是阅文网站的小说小说推荐,即当你浏览一本小说时,下面会给这个推荐列表,从其描述以及个人分析来看,好像与个人的行为相关性会小一些,应该是基于大盘用户的浏览轨迹做的关联分析,进而进行关联推荐。
最后是电商平台的典型案例,即你在浏览商品时,一般都有猜你喜欢模块,并且推荐系统得以大放光彩,成为应用领域里典型的应用场景,还是得益于亚马逊。当年亚马逊使用推荐算法帮助其提升了XX(具体多少忘了)的年度利润,从此一炮而红,基本上电商平台中的推荐系统就成了标配。
0.2.4 推荐系统的一些坑
看了这么多例子,再结合自己身边实际的体验,确实不难发现,各色各样的产品、平台,都在打造自己的推荐产品,恨不得用户一直点下去,永不跳出。鉴于这种情况,那些尚未为自己产品或者平台开发推荐逻辑的,是不是感觉自己少了个推荐系统,哈哈。
其实核心还是那句话,推荐本身就是个双刃剑,用的不好只会让用户徒增烦恼,这里所说的好不好,不单纯是说准不准的问题,准是前提,即推荐给用户切身所需肯定是好的,但这还不够,你还需要在他需要的时候给他推,时机不对、场景不对,即使你推的东西再准,那也是瞎比推。
所以,即使你觉得你少了个推荐系统,也是需要慎重,或许跟完这个系列会好点?正如上面说的,一些坑还是需要注意的。上面所说的推荐时机以及场景就不再重复了。
第一,好的推荐系统一般情况下很依赖于用户的行为数据,因为从用户行为中自然能一窥用户的一些偏好所在,但实际情况是,用户的行为数据并不是这么容易的,当用户行为数据不够的时候,基于用户行为的分析结论就是个伪命题,甚至会把你带向错误的方向。
第二,用户的偏好一定是会时间偏移进行转变的,所以用户行为的有效性又会是一个问题。
第三,假设这个是新用户呢?完全没有轨迹信息,怎么破。
第四,实际影响用户的选择的因素太多,我们容易陷入主观臆断的误区,综合性考虑是一个完善推荐系统的必须思考的地方。
第五,产品层面的逻辑有时候比底层算法更有效,典型如上面阅文的截图例子,“喜欢这本书的人也喜欢”,这就是一种策略,也是一种推荐解释,可解释性会提升推荐的可信度,诸如还有一些交互方式、产品形态都是对推荐转化有影响的。
03 推荐系统的基础知识
基于上面章节的内容,我们对于推荐系统的常见场景有了一个大概的认知,这个章节,我们从推荐系统本身的基础知识进行拆解,帮我们从理论上掌握更多关于推荐系统相关的知识。
0.3.1 推荐系统概述
在上个章节,我们也大致的提到过,需要先明确的一点就是推荐算法或者推荐机制并不严格等同推荐系统,推荐系统是一个相对复杂的业务系统,里头涉及到数据的处理、架构的构成、推荐的逻辑机制,反馈数据的回收、效果的跟踪、AB测试等等。
并且,很多我们耳熟能详的推荐算法,他只是解决的某种特定情况下的推荐机制问题,而整个系统很多时候是复合了多种算法结果,综合呈现的一种结果。但可以肯定的是,各种理论逻辑、算法机制是构建推荐系统的核心支撑,所以,学习推荐系统,首先学习各种推荐算法并没有毛病。
推荐算法概述-基于内容属性相似的推荐
从原始数据依赖的层面来说,常见的有基于内容属性的推荐机制,这种推荐逻辑很简单,只是单纯的依赖物品之间的属性相似来构建推荐关系,容易理解,有时间还是有一定效果的,但实际上很多时候会存在这几种情况,导致了这种原始推荐失效。
如果用户浏览当前的物品本身就不是用户的菜,甚至是一个非优质信息(当前主体不可控),再基于当前物品进行推荐就是个伪命题。
基于上面这条,即使当前主体是用户的目标,但再推类似主体会造成信息冗余,即当前主体信息已经解决了用户的问题。
所以,由于用户行为的不可控,基于内容属性相似的推荐,风险还是挺高的,这是导致了这种原始直接的机制并不会得到广泛的推广。但与乱推荐相比,还是有一定正向作用的,毕竟用户浏览的主体是自身选择的结果,本身用户对于其选择的信息主体是有一定偏好性的。
推荐算法概述-基于用户画像的推荐
基于物品本身属性的推荐,其实与个性化是没有半毛钱的关系的,毕竟推荐候选集只跟物品主体有关,与用户无关,就谈不上什么个性化了。
而基于用户画像(更多人喜欢用基于用户标签)的推荐,则更大程度上依赖于用户的画像属性来推荐,这就体现了用户偏好信息,根据偏好信息来选择候选集。
这是一种很通用的做法,并且在大规模数据集情况下,很多实际的产生过程中喜欢使用这种机制。而用户的画像,或者更具体点用户的兴趣标签如何构建呢?其实就是依赖用户累积的行为数据了,通过行为数据生成用户的兴趣标签。
这看似是一种相对靠谱的做法,毕竟如果把用户的爱好都分析清楚了,主动给用户做推荐不就显得很个性化了吗?在实际的场景中,首先,并不是所有用户的行为都足够用来表征其兴趣偏好的,即我们会高估用户的行为集合,从而产生有偏差的画像属性,更甚者,如果用户完全没有行为怎么办呢?
其次,通常来说,用户的兴趣爱好是会随时间迁移而改变的,所以,把我用户的兴趣程度以及其变化并不是一个容易的事情,更何况用户实际的选择还会受很多因素影响,比如,我当前查找的一个信息并不是我之前掌握的信息,那意味着这些信息偏好在我的历史轨迹中都体现不出来,那单纯的通过我的兴趣去推荐就显得不靠谱了。
但不管怎么说,根据用户的偏好来做推荐,大方向肯定是没有问题的。
推荐算法概述-基于协同过滤的推荐
协同过滤,或许了解过推荐系统的的朋友,多多少少都能听过一些,并且协同推荐可是作为推荐领域典型案例的存在。
协同过滤同样不会去研究物品的本身属性,甚至也没有空去构建用户的画像标签,正如他的名字描述的一样,他严重依靠于用户的行为以及其周边用户的协同行为。举个例子,为一个用户推荐信息,那么我只需要参考其周边用户在看什么信息,就给他推荐什么信息就好了。
重点在于,如何限定周边这个范围,比如根据两个用户的行为,去构建相关关系,从而判断用户之间的相似程度,把相似用户的行为推荐给当前用户,这就是协同中典型的基于用户推荐。
而如果以物品为维度,以用户的购买或者观看记录为向量,则可以构建物品的相似度量,针对于每一个待推荐选项,用户的历史轨迹就是其向量构成,就可以判断该用户历史的轨迹信息与当前的待选物品的向量相关度了,从而判断是否要推荐,这就是基于物品的协同逻辑。
与基于用户画像的推荐对比,这种推荐有一定几率可以发现新物品,即并不严格依赖用户的兴趣。举个例子,假设几个信息的层级是ABC,并且ABC是层级递进关系,并不是同一个东西,对于一个用户来说,他掌握的是A,则意味着他的兴趣偏好大多偏向于A,根据兴趣标签,其实是很难推荐这种递进相关的信息。
但是,如果其他用户的学习轨迹都是A->B->C这种轨迹,这意味着ABC三者之间本身就有前后潜在逻辑关系存在的,基于协同,即可为该用户在掌握A的基础上,推荐BC的内容,这也是基于兴趣所做不到的地方。
当前,基于协同行为的推荐,除了基于物品还有基于用户,还有其他诸如基于模型的协同,典型如最近邻模型、基于矩阵分解、以及基于图关系模型的构建的推荐机制。
推荐算法概述-其他
其实在我们实际的操作过程中,并不会严格的依赖于这种条条框框、只要合理即可行,比如我们完全可以把推荐问题转化为分类问题,针对于每个待选项,他都是YES OR NO的问题,即一个二值分类。
又比如在上一篇我们学习的一个场景,即阅文网站的小说推荐,还记得他的推荐标题吗?“喜欢这本书的人还喜欢”,这就是典型的“啤酒与尿布”的解法,即货架思维,关联销售,也是可以作为一种推荐机制而存在的。
在比如微信朋友圈,微信一定是会研究用户的朋友圈关系的,比如你对哪类朋友点赞、互动行为最多,它是不是会考虑推荐你欣赏的朋友偏好内容给你?毕竟微信是一个典型的熟人社交模型。
所以,推荐算法看似重要,但其实想想又没有这么重要,很多时候并不是一成不变的,都要我们根据场景去考虑,最最最重要的是,需要我们用实际效果来选择机制,也或许是多种机制共同生效的结果。
0.3.2 相似度量
在我们上面的推荐算法机制中,有个不得不提的操作处理就是各种相似相关度的计算,我们来简单分享一下几种相似或者相关度量的方式。
欧几里得距离(Euclidean Distance)
最常见的距离度量方式,衡量多维空间中两点之间的绝对距离,要求维度的统一。
明可夫斯基距离(Minkowski Distance)
明氏距离是欧氏距离的扩展,是对多个距离度量公式的概括性的表述(可以看到,当p=2时,其实就是欧式距离)。
曼哈顿距离(Manhattan Distance)
曼哈顿距离来源于城市区块距离,是将多个维度上的距离进行求和后的结果,即当上面的明氏距离中p=1时得到的距离度量。
//还有其他的一些距离度量,但是都不太常用,最常用的依然是欧式距离度量。
向量空间余弦相似度(Cosine Similarity)
余弦相似度用向量空间中两个向量夹角的余弦值作为衡量两个个体间差异的大小。相比距离度量,余弦相似度更加注重两个向量在方向上的差异,而非距离或长度上。
皮尔森相关系数(Pearson Correlation Coefficient)
即相关分析中的相关系数r,分别对X和Y基于自身总体标准化后计算空间向量的余弦夹角。基于内容的推荐,还有一点需要注意的就是,对于物品自身属性,如果属性值过少,我们需要适当进行扩大维度,如果维度过多,则需要进行降维。
关于降维和升维,都是一个很大的研究方向,大体上可以说一下几种常见的方式。例如降维,我们可以进行维度聚类、主题抽取,进一步把相关维度进行合并,进一步减少维度;而对于升维,我们可以把维度进行矩阵化,例如假设物品X有A和B两个维度属性,那么我们通过生成AB矩阵的方式,把维度扩充到AB个维度。
0.3.3 冷启动问题的解决
所谓冷启动,即在推荐系统初期时,没有任何用户与物品的交集信息,即无用户的行为轨迹,无法通过类似协同或者用户偏好等方式进行推荐,这种时候,我们就称推荐系统处于冷启动状态。
这种情况,我们需要尽快的累积起第一批用户行为轨迹。我们可以通过基于内容的推荐,或者做一些其他类似的操作,快速有效的进行物品推荐。一段时间后,累积到一定的用户行为时,整个系统就能够正常使用协同过滤等方式进行推荐了。
但是,针对于新加入的用户,或者新加入的物品,同样也是出于冷启动状态的,这个时候,我们通过需要对这种物品或者用户做特殊的处理。
除了基于内容属性的推荐,我们还有其他的一些策略用于弥补这种行为数据不足的情况,比如典型的热度模型,推荐热点信息这种行为虽然low,但是从整体的反馈来看,还是有一定效果的,此外,还可以根据一些统计学上的结论,进行基于统计分析结论的推荐。
除此之外,我们也可以通过其他渠道收集用户的数据,比如用户注册的时候所填写的个人资料,这些都是可以作为推荐的原始依赖数据。
0.3.4 马太效应
马太效应或者说长尾效应,即热者愈热,实际举例来说就是,在实际的购买场景中,由于你推荐的次数越多,部分优质的商品购买或者点击的次数就越多,形成的用户购买轨迹就越多,所以得到的推荐机会就越多,进而产生的推荐也越多,变得越热。
随着不断迭代,子子孙孙无穷尽也,这样得到推荐的商品就会集中在少部分商品中,而大部分长尾商品是沉寂的,一个推荐系统如果长时间处于长尾效应中,造成推荐疲劳,其推荐效果就会减弱。
所以,一个好的推荐系统,要考虑到适当的挖掘长尾商品,通过真的个性化,把适当的长尾商品送到真正需要他们的人手里,在实际的操作过程中,我们可以适当的进行热度降权,从而让一些中下层的商品得到更多的曝光机会,当然前提是保证点击率的情况下。
另外一个场景会形成马太效应的是热度模型,即我们的热度榜单,长时间的高居榜首,一定会获得更多的点击,而点击越多其热度越高,但我们的信息是需要保持新鲜度的,不然点击率迟早会下架的。
所以,我们使用一些机制让处于头部的商品或者信息降权,时间衰减是一个比较通用的做法,即随着时间的迁移,其整体热度会不断的下降,至于说下降的方式,速率就看模型的设计了。
0.3.5 AB测试
关于推荐的效果,之前我们说过其核心的考核标准就是点击率,点击的越多说明推荐的越准确,用户的停留时长也会越长,只要把用户留在平台中,机会总是会有的。其实就是一层漏斗嘛?这一层的基数越大,下一层转换的量就会越高,这也是推荐系统的核心存在意义。
并且之前也说到过,一个不好的推荐系统有时间反而会形成反向作用,所以,一个推荐系统的迭代更新至关重要。离线的效果评估一定是要做的,最起码在离线实验的阶段需要保证当前的效果优于线上效果,才能进行迭代。
但是,实际情况是复杂的,对于推荐的模型来说,离线的实验其实并没有想象中靠谱,那么,就丢到线上去真多真枪的实验一把,就知道效果了。但是,实际的生产环境中,任何一点转化波动的影响都是极其严重的,谁也不敢拿实际生产开玩笑。
于是,就有了AB测试机制的产生,所谓AB测试机制,即将流量分为AB两类,A流量走原始的旧模型,B流量走新模型,同步测试同步对比,效果一目了然。
当然,在实际的AB测试流程中,首先流量是可以自由分配的,一般情况下新模型在最终确认之前流量一定是少量的,随着模型逐渐被验证,流量比重会逐渐加大,最终确认后流量全部导向新模型,完成新模型的正式上线。
并且,通常,在实际的环境中,或许我们会同时有十多个甚至是几十个新模型在同时实验,每个模型调整的因子都不一样,最终选择最适合的因素进行调整,达到效果最优,这也就是AB测试机制的魅力所在。
所以,打造一个好的AB测试系统,首先流量是需要可控的,其次模型的迭代上线是需要高度灵活的,最后,肯定是需要有完整的数据回收、数据分析对比机制存在的。
04 基于内容属性的推荐策略算法
从这个章节开始,我们将从理论进一步过渡到具体的推荐策略算法,我们先从最简单的基于内容属性相关的策略算法着手。
0.4.1 最简单的推荐机制
如标题,既然是“最最最简单”的推荐系统,其实也不能说是推荐系统,之前也说了,系统是一个复合的完整系统,所以这里说推荐机制可能会更恰当些。结合之前大致陈述的一些推荐机制,最最最简单的推荐机制,无疑是基于主体属性相似或者相关推荐了,连个性化都说不上,铁定最最最简单了。
说到这,说不定有些人不愿意干了,既然如此简陋的推荐机制,不看也罢。BUT,真的不要小看基于内容相似的推荐,存在即合理。我们在进行信息获取时,其实本身就是具有一定识别能力的,这意味着我们最终选择查看的信息都是经过我们大脑大致思考的结论,这意味着这些信息是有参考价值的。
并且,在很多时候,我们是需要同类信息进行我们获取到的信息进行补全的,完善我们对目标信息的获取程度。基于这个考量,基于内容属性的推荐其实是说的过去的。
别不服,我们来上例子,还是以前面文章那个腾讯视频的推荐场景图为例。
图是在使用腾讯视频观看视频时,我亲手截推荐栏位的内容,补充一下背景(很重要,请注意):
1 我当时观看的是应该是《蓝色星球第二季》纪录片
2 我经常在腾讯视频上看的一般是大片,并且一般是国外的
3 由于是VIP账号,梓尘兄也经常用这个账号看动画片,诸如《小猪佩奇》之类的
4 在腾讯视频上,纪录片中,我只看过《地球脉动》和《蓝色星球》,并且,我真不是纪录片的爱好者,只是喜欢这两部而已
基于上面我提供的个人行为数据,再结合看这批推荐列表,不难发现,上面有很多的纪录片,你觉得跟我们当时正在浏览的内容有没有关系?或者你认为我行为记录中很多纪录片的记录?又或者是我是纪录片的狂热者,导致了腾讯视频给我猛推纪录片。
所以,连腾讯视频都会考虑基于当前浏览内容的属性进行推荐(并且是大范围),你还觉得这种做法十分之LOW吗?当然你也可以认为腾讯视频推的不准,瞎J吧推,也是可以的,我也认同,不是非常准(哈哈,《地球脉动》所有我都看过了,还给我瞎推,上面给推的没几个有欲望去点的,给腾讯视频推荐的开发兄弟们打脸了,不好意思)。
我只想表达的是,这种简单的推荐机制,在整个推荐系统中真的是不可缺少的部分,很多时候简单并不代表无效,类似上面这种情况,我可以举出太多有名有姓的实际案例来,说多了没意义,所以,咱继续。
0.4.2 过程并没有这么简单
从直接的推荐机制来看,整个实现流程看着真的很简单,但是在实际的操作过程中,还是有一些东西值得探讨以及注意的。
第一、首先是,相似计算的过程
之前文章有大致提到过,相似或者相关计算还是有很多可以选择的,他们每一种都有各自的特点以及适应性。以相似计算中使用最多的欧式距离与余弦相似为例,专业点的说法就是余弦夹角可以有效规避个体相同认知中不同程度的差异表现,更注重维度之间的差异,而不注重数值上的差异,而欧式距离则是对个体异常数值会比较敏感。
这意味着,在我们需要区分异常样本时,使用距离计算会更恰当,聚个栗子,比如电商领域中高价值与低价值用户的区分,其实我们核心是想把他们的差异性拉大的,得以体现出对比,这个时候使用余弦就是不合理的。
在回归到距离上说,市面上除了欧式距离,还有好几种距离度量,诸如马氏、曼哈顿距离等等,其实其度量侧重都是不一样的,我们需要结合实际的场景去使用。还有更偏向于相关度量的皮尔森相关系数等。
第二、需要解决相似计算中,计算矩阵过大的问题
按照标准流程,假设有1万个物品,则对于每个物品来说,需要与其他物品计算与其的相似度或者相关度,然后再排个序,取TopN形成自身的待推荐列表。那么,简单的数学题来了10000*10000=10000万次计算,这显然是不合理的。
所以,优化这个过程是必然的,关键是如何优化。核心思想其实就是初筛嘛!把那些完全没啥多大鸟关系的直接干掉,省掉计算相似的过程,节省资源。如何筛选?一个比较常见的做法是,寻找核心关键影响因素,保证关键因素的相关性。
比如,在实际的生产操作过程中,很多时候会通过关键属性是否基本匹配作为判断依据,或者直接通过搜索构建进行检索初筛,把大致相关的先过滤一遍,通过这种简单而粗暴的方式,其实已经能把大部分相关度很低的候选集给过滤掉,对于整体计算量级来说,计算复杂度直接下降。
第三、多个因子如何权衡影响权重
基于属性计算相似,从整体上来看,其实一般主体都不止一个属性,那么计算相关的时候到底看那个属性呢?或者说哪些属性应该占有更高的权重,哪些因素是次要因素。
还是以上面的腾讯视频的推荐为例,从结果上来反推相似推荐的部分(当然,实际情况不详哈,只是推断而已),显然当前视频的类别占了很大的权重(记录片),除此之外包括导演啊,一些其他特征属性应该也会参考在内的。
回到常规问题,如何确定影响权重是个操作难题。最简单并且实际上还挺有效的一种方式就是专家评判法,即通过权威经验来划定影响因子的权重,还有就是通过标注的样本进行反向拟合每种因素的占比权重。除此之外还有一些其他学术上的方法,包括什么主成分分析法,层次分析法,还有什么熵权法,其实都是找因子影响能力的主次关系。
最终确定好了影响因素,在实际上线回收到数据之后,依然是需要逐步的进行权重影响调整的,我们可以通过结果的样本数据,进行LR的回归拟合,寻找最合适的权重配比。
0.4.3 最简单的推荐策略算法实践
说了这么多理论,不能光说不练,标题上写着“附Spark案例”,很多人都是冲着这来的呢,前面BB了这么多屁话,还不见代码。来,我们这就上正文。
不过不用期待过多,毕竟这只是一个简单的相似计算的过程而已,所以权当属性实验数据以及Spark开发了,高手可以略过了。
一、实验数据简介
其实看到这三部分数据的简介,一些老手估计已经知道是什么数据了,是的,就是那份有名的电影数据集(MovieLens开放数据),并且取的是完全版的那份,简直成了推荐系统的标配实验数据了。
三个文件,其中电影数据集共1万多个电影基础数据,评分数据集最大共100万条评分数据,以及10万条的用户对电影的打标签数据,总大小约为几百兆,不大,但是用来做实验玩玩那是相当足够了。
二、推荐机制逻辑
我们的核心计算逻辑还是内容属性上的相似嘛,所以核心是看看围绕电影,有哪些属性是可以抽取出来的,并且参与计算的。
第一,电影的类别,基于上面腾讯视频的考虑,其实这个显然很重要,而电影的类别信息存储于电影数据集中,并且是一对多的关系,即一个电影可以对应多个类目,我们需要进行切割,由于计算这个维度相似的时候,是多对多的关系,天然的计算相似或者相关的特征。
第二、电影的播放年份,电影的年份其实有种潜在的关联关系,举个例子可以说明,比如说零几年的电影与现状的电影风格是不同的,当时间跨度有一定差距时,这个还是蛮明显的。关于电影的年份数据,从数据样本可以知道,它隐藏在电影的名字中,写个正则过滤出来即可。至于说如何计算这个维度的相关,我们可以用两者差距来算相关,比如年份绝对值越远,这个值越小,越近甚至是重叠就越大。
第三,电影的标签,电影本身是没有标签属性的,但它有用户对他打的标签信息,所以我们需要进一步处理,把它变成电影的属性,需要清洗、规整以及处理。标签本身也是多对多的关系,同样可以计算相似度,比如欧式或者余弦。
第四、电影的名称,名称上进行寻找关联性,听上去很扯,但其实有一定的逻辑在里头,比如我在视频网站搜索“三国”,显然我期望从名称上寻找三国相关题材的视频,他们就是在名称上建立起关联关系的,所以,名称从某种程度上来说,可以体现相关性。在计算相似或者相关方式上,我们可以进行分词,去除停词,然后再以词维度进行余弦计算。
第五、候选集电影的评分,对于做推荐来说,首先需要保证的推荐的候选集一定是优质的,从这个维度上说,抛开其他因素,那么就是整体评分高的电影是相对优质的电影。在处理的过程中,由于一个电影对应多个评分,所以,我们需要进行进行归一计算,最简单的做法就是计算整体评分的平均值,作为电影的评分数据,评分过低的数据直接从候选集中干掉,又大大的降低了计算次数。
三、代码逻辑
Spark2.0之后,不用再构建sparkcontext了,以创建一个复合多功能的SparkSession替代,可以正常的从HDFS读取文件,也可以从Hive中获取DataFrame等等。
<pre style="margin: 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; word-wrap: break-word !important; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial; widows: 1; color: rgb(169, 183, 198); font-family: 宋体; font-size: 10.5pt; background-color: rgb(43, 43, 43);">val sparkSession = SparkSession
.builder()
.appName("base-content-Recommand") //spark任务名称
.enableHiveSupport()
.getOrCreate()</pre>
那三个表可以先load到Hive中,然后spark直接从Hive中读取,形成DataFrame。
//从hive中,获取rating评分数据集,最终形成如下格式数据(movie,avg_rate)
val movieAvgRate = sparkSession.sql("select movieid,round(avg(rate),1) as avg_rate from tx.tx_ratings group by movieid").rdd.map{
f=>
(f.get(0),f.get(1))
}
//获取电影的基本属性数据,包括电影id,名称,以及genre类别
val moviesData = sparkSession.sql("select movieid,title,genre from tx.tx_movies").rdd
//获取电影tags数据,这里取到所有的电影tag
val tagsData = sparkSession.sql("select movieid,tag from tx.tx_tags").rdd
对tag进行处理,很多tag其实说的是同一个东西,我们需要进行一定程度上的合并,这样才能让tag更加的合理(有朋友有意见了,就一个实验案例而已,搞这么复杂),举个简单例子,blood、bloods、bloody其实都是想说这个电影很血腥暴力,但是不同的人使用的词不同的(这点大伙儿可以自由查看实验数据),所以我们需要进行一定程度上的合并。
<pre style="margin: 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; word-wrap: break-word !important; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial; widows: 1; color: rgb(169, 183, 198); font-family: 宋体; font-size: 10.5pt; background-color: rgb(43, 43, 43);">val tagsStandardizeTmp = tagsStandardize.collect()
val tagsSimi = tagsStandardize.map{
f=>
var retTag = f._2
if (f.2.toString.split(" ").size == 1) {
var simiTmp = ""
val tagsTmpStand = tagsStandardizeTmp
.filter(._2.toString.split(" ").size != 1 )
.filter(f._2.toString.size < _.2.toString.size)
.sortBy(._2.toString.size)
var x = 0
val loop = new Breaks
tagsTmpStand.map{
tagTmp=>
val flag = getEditSize(f._2.toString,tagTmp._2.toString)
if (flag == 1){
retTag = tagTmp._2
loop.break()
}
}
((f._1,retTag),1)
} else {
((f._1,f._2),1)
}
}</pre>
其中getEditSize是求取,两个词的编辑距离的,编辑距离在一定时候,进行合并,具体逻辑见代码了,不复杂。
<pre style="margin: 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; word-wrap: break-word !important; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial; widows: 1; color: rgb(169, 183, 198); font-family: 宋体; font-size: 10.5pt; background-color: rgb(43, 43, 43);">def getEditSize(str1:String,str2:String): Int ={
if (str2.size > str1.size){
0
} else {
//计数器
var count = 0
val loop = new Breaks
//以较短的str2为中心,进行遍历,并逐个比较字符
val lengthStr2 = str2.getBytes().length
var i = 0
for ( i <- 1 to lengthStr2 ){
if (str2.getBytes()(i) == str1.getBytes()(i)) {
//逐个匹配字节,相等则计数器+1
count += 1
} else {
//一旦出现前缀不一致则中断循环,开始计算重叠度
loop.break()
}
}
//计算重叠度,当前缀重叠度大于等于2/7时,进行字符串合并,从长的往短的合并
if (count.asInstanceOf[Double]/str1.getBytes().size.asInstanceOf[Double] >= (1-0.286)){
1
}else{
0
}
}
}</pre>
继续对tag进行处理,统计tag频度,取TopN个作为电影对应的tag属性。
<pre style="margin: 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; word-wrap: break-word !important; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial; widows: 1; color: rgb(169, 183, 198); font-family: 宋体; font-size: 10.5pt; background-color: rgb(43, 43, 43);">val movieTag = tagsSimi.reduceByKey(+).groupBy(k=>k._1._1).map{
f=>
(f._1,f._2.map{
ff=>
(ff._1._2,ff.2)
}.toList.sortBy(._2).reverse.take(10).toMap)
}</pre>
接下来处理年龄、年份和名称,这个会简单点,进行分词处理的话,怎么简单怎么来了,直接使用第三方的HanLP进行关键词抽取作为分词结果,直接屏蔽了停用词。
<pre style="margin: 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; word-wrap: break-word !important; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial; widows: 1; color: rgb(169, 183, 198); font-family: 宋体; font-size: 10.5pt; background-color: rgb(43, 43, 43);">val moviesGenresTitleYear = moviesData.map{
f=>
val movieid = f.get(0)
val title = f.get(1)
val genres = f.get(2).toString.split("|").toList.take(10)
val titleWorlds = HanLP.extractKeyword(title.toString, 10).toList
val year = movieYearRegex.movieYearReg(title.toString)
(movieid,(genres,titleWorlds,year))
}</pre>
取年份的正则函数如下,是个Java写的精通工具类(Scala和Java混写,简直无比美妙)。
<pre style="margin: 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; word-wrap: break-word !important; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial; widows: 1; color: rgb(169, 183, 198); font-family: 宋体; font-size: 10.5pt; background-color: rgb(43, 43, 43);">package utils;
import java.util.regex.Matcher;
import java.util.regex.Pattern; /**
- Desc: 抽取年份公式
/ public class movieYearRegex {
private static String moduleType = ". \(([1-9][0-9][0-9][0-9])\).*";
public static void main(String[] args){
System.out.println(movieYearReg("GoldenEye (1995)"));
}
public static int movieYearReg(String str){
int retYear = 1994;
Pattern patternType = Pattern.compile(moduleType);
Matcher matcherType = patternType.matcher(str);
while (matcherType.find()) {
retYear = Integer.parseInt(matcherType.group(1));
}
return retYear;
}
}</pre>
通过join进行数据合并,生成一个以电影id为核心的属性集合。
<pre style="margin: 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; word-wrap: break-word !important; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial; widows: 1; color: rgb(169, 183, 198); font-family: 宋体; font-size: 10.5pt; background-color: rgb(43, 43, 43);">val movieContent = movieTag.join(movieAvgRate).join(moviesGenresTitleYear).map{
f=>
//(movie,tagList,titleList,year,genreList,rate)
(f._1,f._2._1._1,f._2._2._2,f._2._2._3,f._2._2._1,f._2._1._2)
}</pre>
相似计算开始之前,还记得我们之前说的吗,可以进行候选集阉割,我们先根据一些规则裁剪一下候选集。
val movieConetentTmp = movieContent.filter(f=>f._6.asInstanceOf[java.math.BigDecimal].doubleValue() < 3.5).collect()
然后真正的开始计算相似,使用余弦相似度计算,取排序之后的Top20作为推荐列表。
val movieContentBase = movieContent.map{
f=>
val currentMoiveId = f._1 val currentTagList = f._2 //[(tag,score)] val currentTitleWorldList = f._3 val currentYear = f._4 val currentGenreList = f._5 val currentRate = f._6.asInstanceOf[java.math.BigDecimal].doubleValue() val recommandMovies = movieConetentTmp.map{ ff=> val tagSimi = getCosTags(currentTagList,ff._2) val titleSimi = getCosList(currentTitleWorldList,ff._3) val genreSimi = getCosList(currentGenreList,ff._5) val yearSimi = getYearSimi(currentYear,ff._4) val rateSimi = getRateSimi(ff._6.asInstanceOf[java.math.BigDecimal].doubleValue()) val score = 0.4*genreSimi + 0.25*tagSimi + 0.1*yearSimi + 0.05*titleSimi + 0.2*rateSimi (ff._1,score) }.toList.sortBy(k=>k._2).reverse.take(20) (currentMoiveId,recommandMovies)
}.flatMap(f=>f._2.map(k=>(f._1,k._1,k._2))).map(f=>Row(f._1,f._2,f._3))
最后,将结果存入Hive中,Hive中提前建好结果表。
//我们先进行DataFrame格式化申明
val schemaString2 = "movieid movieid_recommand score"
val schemaContentBase = StructType(schemaString2.split(" ")
.map(fieldName=>StructField(fieldName,if (fieldName.equals("score")) DoubleType else StringType,true)))
val movieContentBaseDataFrame = sparkSession.createDataFrame(movieContentBase,schemaContentBase)
//将结果存入hive,需要先进行临时表创建
val userTagTmpTableName = "mite_content_base_tmp"
val userTagTableName = "mite8.mite_content_base_reco"
movieContentBaseDataFrame.registerTempTable(userTagTmpTableName)
sparkSession.sql("insert into table " + userTagTableName + " select * from " + userTagTmpTableName)
到这里,基本大的代码逻辑就完了,可能还有一些边边角角的代码遗漏了,但不妨碍主干了。
05 融合用户兴趣的推荐才个性
接上个章节,我们给了一个最最最简单的推荐系统机制,即基于内容属性的相似或者相关推荐,我们知道这种推荐机制基本只基于内容本身的属性进行推荐,与用户没有半毛钱关系,所以,当然也就说不上个性化了。
0.5.1 个性化与用户画像
在说具体的情况之前,我们先来思考一个问题,什么是个性化?个性化一定是与人相关的,只有人才有个性,每个人可能都有自己的个性,推送的信息如果能满足用户的个性,才是一个好的推荐系统,才具有足够的智能。
而今天,我们要讨论的就是,如何让推荐从非智能的过程演变为知晓用户个性,基于用户偏好进行推荐,从而变得更“聪明”点,也就是智能化。
要实现推荐个性化,那么先需要对用户进行分析,分析用户的偏好,然后根据偏好来做推荐,就顺其自然了。而要分析用户的偏好,那么自然就少不了对用户行为的分析。
所以,核心还是用户画像的分析,然后我们再基于用户画像属性进行推荐,由于用户画像体现的是每个用户的偏好数据,所以,不管怎么样,这种推荐机制或多或少都是能体现一些个性化的东西的。
沿着这个路径,我们依然是结合实际数据以及代码案例来分解这个个性化推荐的过程。
0.5.2 基于用户画像的个性化推荐策略
整个案例代码的逻辑是,我们先根据行为数据,进行用户的画像描述抽取,然后再结合用户的画像数据为用户进行信息推荐,注意,这里与之前的实例不同的是,我们是基于用户进行推荐的,而上个实例是在浏览某个内容的时候,进行相关内容推荐,这里以及进化到了根据人进行推荐了。
实践数据源
关于数据源,依然使用的是上个案例中的实验数据,不清楚的见上一个章节(最简单的推荐系统)的原始数据说明,从上次的数据源说明情况看,实际上打标行为数据有10万条,评分数据有100万条,相对于电影内容数据实体来说,其实已经算不少了,所以,不用担心,针对于有行为记录的用户,或多或少还是能描述出他们各自的一些行为偏好的。
用户兴趣标签提取
基于上个小节的流程图,所以,在实践中,我们首先需要做的就是用户兴趣标签的提取。我们核心拥有的就是用户对电影的打标签数据,以及用户对电影的评分数据。
所以,从上面两个行为数据集中,我们可以尝试提取以下几个维度的用户偏好数据:
用户对电影类目偏好数据,即用户对那些类目偏好。
用户的偏好兴趣标签,即通过用户对电影的偏好标签打标行为,进一步可以提取用户的兴趣标签。
用户的偏好年份,通过打分数据,描述用户偏好哪个年代的电影。
我们先解决用户的偏好标签问题,我们已有的是用户对电影的打标行为数据,实际上这是电影层级的标签,所以我们需要在这个基础上,最终直接为用户映射上这些特征标签。
所以,我们需要对单个用户的所有打标数据进行合并(标签会做预处理),然后如果用户对刚好打标的电影有评分的话,还需要带上评分权重,最终合并这些标签,形成用户带权重(本身的频度、对应电影的评分)的标签集,这就是用户的一些兴趣点。
对于类目偏好,说起来就简单了,比如通过评分表,我们把对应所有的电影都取出来,然后分析其类目,将评分作为对应类目的权重,然后将类目进行合并,最终求取用户的类目偏好合集。
对于电影年份,过程与上述取类目的过程类似,最终获取到年份偏好。
电影数据的处理
假设在上面的基础上,我们已经获取了用户层级的画像属性信息,比如偏好的电影类别,偏好的特征标签,偏好的某些年份的电影(同个时代电影具有一些相同的电影,比如10年前的电影风格与现在的俨然不同,年份在某种程度上说还是有影响的,虽然很弱)。
接下来,我们需要绘制候选集电影的属性(取之前,做一些初筛过滤操作,减少计算量),对应用户的属性,同样是三个,其中年份、类目都是直接存放于电影表中,唯一需要额外处理的就是特征Tag了,由于不同人对不同的电影进行Tag标记,上面进行用户画像绘制的时候,是以人为维度的,现在已电影为维度,进行标签合并,最终同样可以形成电影维度的标签集。
关联推荐计算
每个维度分别进行计算相似度或者相关度,然后不同维度进行合并归一计算最终的用户与电影的相关度。最外层我们依然以权重模型去做,以经验来看,类目是最重要的,其次是Tag,最后才是年份属性,至于最终怎么调整还是需要根据实际反馈数据来做微调,现在就拍脑袋吧。
我们来看代码逻辑
至于说取原始数据的过程,就不多说了,具体的可以看上面那个案例,这里就不贴代码片了,这里所有表的数据都会用到,所以都要获取。
第一步,先进行movie候选集的处理,包括Tag预处理,合并,以及类目年份的获取
我们进行相似tag合并操作,返回的数据形态是(mvieid,tag)集合,但tag会做提前进行预处理,过程依然跟上次一样,进行编辑距离相近的词合并。
<pre style="margin: 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; word-wrap: break-word !important; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial; widows: 1; color: rgb(169, 183, 198); font-family: 宋体; font-size: 10.5pt; background-color: rgb(43, 43, 43);">val tagsStandardizeTmp = tagsStandardize.collect()
val tagsSimi = tagsStandardize.map{
f=>
var retTag = f._2
if (f._2.toString.split(" ").size == 1) {
var simiTmp = ""
val tagsTmpStand = tagsStandardizeTmp
.filter(_._2.toString.split(" ").size != 1 )
.filter(f._2.toString.size < _._2.toString.size)
.sortBy(_._2.toString.size)
var x = 0 val loop = new Breaks
tagsTmpStand.map{
tagTmp=>
val flag = getEditSize(f._2.toString,tagTmp._2.toString)
if (flag == 1){
retTag = tagTmp._2
loop.break()
}
}
(f._1,retTag)
} else {
f
}
}</pre>
我们先将预处理之后的movie-tag数据进行统计频度,直接作为tag权重,形成(movie,tagList(tag,score))这种数据集形态。
val movieTagList = tagsSimi.map(f=>((f.1,f.2),1)).reduceByKey(+).groupBy(k=>k._1._1).map{
f=>
(f._1,f._2.map{ ff=> (ff._1._2,ff._2) }.toList.sortBy(_._2).reverse.take(10).toMap)
}
接着进行genre类别以及抽取电影属性的年份属性,其中涉及的正则方法见上一个实例,这里就不重复给出了。
<pre style="margin: 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; word-wrap: break-word !important; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial; widows: 1; color: rgb(169, 183, 198); font-family: 宋体; font-size: 10.5pt; background-color: rgb(43, 43, 43);">val moviesGenresYear = moviesData.rdd.map{
f=>
val movieid = f.get(0)
val genres = f.get(2)
val year = movieYearRegex.movieYearReg(f.get(1).toString)
val rate = f.get(3).asInstanceOf[java.math.BigDecimal].doubleValue()
(movieid,(genres,year,rate))
}</pre>
最终将三种不同的属性进行合并,形成电影的处理过的候选集,当然还有电影的平均评分rate属性,这是判断电影基本水平的标志。
val movieContent = movieTagList.join(moviesGenresYear).filter(f=>f._2._2._3 < 2.5).sortBy(f=>f._2._2._3,false).map{
f=>
//userid,taglist,genre,year,rate (f._1,f._2._1,f._2._2._1,f._2._2._2,f._2._2._3)
}.collect()
第二步,我们进行用户画像属性的获取
先通过rating评分表与tags表进行关联join,获取用户直接与tag的关联关系,这样评分数据就可以当成单个tag的权重进行计算了,并且通过DataFrame的API操作会更方便,所以可以先将之前处理的tagsSimi转换成DF,然后直接可以使用类似SQL的逻辑关系了。
val schemaString = "movieid tag"
val schema = StructType(schemaString.split(" ").map(fieldName=>StructField(fieldName,StringType,true)))
val tagsSimiDataFrame = sparkSession.createDataFrame(tagsSimi.map(f=>Row(f._1,f._2.toString.trim)),schema)
//对rating(userid,movieid,rate),tags(movieid,tag)进行join,以movieid关联
//join步骤,将(userId, movieId, rate)与(movieId, tag)按照movieId字段进行连接
val tagRateDataFrame = ratingData.join(tagsSimiDataFrame,ratingData("movieid")===tagsSimiDataFrame("movieid"),"inner").select("userid","tag","rate")
接着进行类似reduce操作,在SQL中就是分组合并,将(userId, tag, rate)中(userId, tag)相同的分数rate相加。
<pre style="margin: 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; word-wrap: break-word !important; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial; widows: 1; color: rgb(169, 183, 198); font-family: 宋体; font-size: 10.5pt; background-color: rgb(43, 43, 43);">val userPortraitTag = tagRateDataFrame.groupBy("userid","tag").sum("rate").rdd.map{
f=>
(f.get(0),f.get(1),f.get(2).asInstanceOf[java.math.BigDecimal].doubleValue())
}.groupBy(f=>f._1).map{
f=>
val userid = f._1
val tagList = f.2.toList.sortBy(._3)
.reverse.map(k=>(k._2,k._3)).take(20)
(userid,tagList.toMap)
}</pre>
在处理完用户的兴趣Tag之后,处理其他属性,Year属性。
val userPortraitYear = userYear.rdd.map(f=>(f.get(0),f.get(1),f.get(2))).groupBy(f=>f._1).map{
f=>
val userid = f._1 val yearList = f._2.map(f=>(f._2,f._3.asInstanceOf[java.math.BigDecimal].doubleValue())).toList.take(10) (userid,yearList)
}
进行用户的genre偏好处理。
val userPortraitGenre = userGenre.rdd.map(f=>(f.get(0),f.get(1),f.get(2))).groupBy(f=>f._1).map{
f=>
val userid = f._1 val genreList = f._2.map(f=>(f._2,f._3.asInstanceOf[java.math.BigDecimal].doubleValue())).toList.take(10) (userid,genreList)
}
对于每一个用户来说,在计算待推荐列表时,都需要移除自身已经看过的电影,先获取用户的观看列表。
<pre style="margin: 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; word-wrap: break-word !important; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial; widows: 1; color: rgb(169, 183, 198); font-family: 宋体; font-size: 10.5pt; background-color: rgb(43, 43, 43);">val userMovieGet = ratingData.rdd.map(f=>(f.get(0),f.get(1))).groupByKey()</pre>
第三步,进行电影画像与用户画像的匹配计算
在实际的计算过程中,每个同纬度的属性进行相似计算,最终外层通过权重模型进行打分,然后重新排序,获取每个用户的对应的待推荐电影TopN,记得要移除自身已看过的电影列表。
val portraitBaseReData = userPortraitTag.join(userPortraitYear).join(userPortraitGenre).join(userMovieGet).map{
f=>
val userid = f._1 val userTag = f._2._1._1._1 val userYear = f._2._1._1._2 val userGenre = f._2._1._2 //用于做差集计算,移除已经看过的电影 val userMovieList = f._2._2.toList val movieRe = movieContent.map{ ff=> val movieid = ff._1 val movieTag = ff._2 val movieGenre = ff._3 val movieYear = ff._4 val movieRate = ff._5 val simiScore = getSimiScore(userTag ,movieTag,userGenre,movieGenre,userYear,movieYear,movieRate) (movieid,simiScore) }.diff(userMovieList).sortBy(k=>k._2).reverse.take(20) (userid,movieRe)
}.flatMap(f=>f._2.map(ff=>(f._1,ff._1,ff._2)))
其中函数getSimiScore相关的计算逻辑如下。
<pre style="margin: 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; word-wrap: break-word !important; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial; widows: 1; color: rgb(169, 183, 198); font-family: 宋体; font-size: 10.5pt; background-color: rgb(43, 43, 43);">def getSimiScore(userTag:Map[Any,Double],movieTag:Map[Any,Int], userGenre:List[(Any,Double)],movieGenre:Any, userYear:List[(Any,Double)],movieYear:Any, movieRate:Double): Double ={
val tagSimi = getCosTags(userTag,movieTag)
val genreSimi = getGenreOrYear(userGenre,movieGenre)
val yearSimi = getGenreOrYear(userYear,movieYear)
val rateSimi = getRateSimi(movieRate)
val score = 0.4genreSimi + 0.3tagSimi + 0.1yearSimi + 0.2rateSimi
score
}</pre>
至于每个维度的计算过程,这里就不列了,大同小异,只要逻辑走的通,具体可见源代码。
第四步,对结果进行存储。
最后,将计算的结果保存下来,同样,需要先进行表结构定义。
val schemaPortraitStr = "userid movieid score"
val schemaPortrait = StructType(schemaPortraitStr.split(" ").map(fieldName=>StructField(fieldName,if (fieldName.equals("score")) DoubleType else StringType,true)))
val portraitBaseReDataFrame = sparkSession.createDataFrame(portraitBaseReData.map(f=>Row(f._1,f._2,f._3)),schemaPortrait)
//将结果存入hive
val portraitBaseReTmpTableName = "mite_portraitbasetmp"
val portraitBaseReTableName = "mite8.mite_portrait_base_re"
portraitBaseReDataFrame.registerTempTable(portraitBaseReTmpTableName)
sparkSession.sql("insert into table " + portraitBaseReTableName + " select * from " + portraitBaseReTmpTableName)
至此,所有代码主体逻辑已经清晰了,其实说白了就是一个计算用户画像的过程,然后画像与待推荐主体之间的关联性。
0.5.3 实操中的注意事项
如上,基于用户画像的推荐机制在实际操作中,其实还有很多需要考虑的地方,并没有想象中简单。
比如,用户的行为并没有我们想象中靠谱。
所谓没想象中靠谱是说,一方面用户的行为数据,有时候并不是其兴趣特点所表现,这点很显然,比如如果系统把一些信息故意放在很显眼的位置,那么对于一般用户来说,不点也得点了,所以就会造成这种用户数据其实是不那么靠谱的。
另一方面是如果用户产生了行为数据,但是行为数据并不足够多,那么这个时候其实这些行为数据是有置信度的考量的,行为数据不够产生的描述是有可能形成偏差的,再根据有偏差的数据去做推荐,那结果只能是更离谱了。
用户兴趣时效性问题。
在上面的实验逻辑中,我们知道我们并没有对用户的行为数据做更多的过滤,而实际的操作中,用户的兴趣是有一定时效性的。举个例子,我在一年前看电影的记录,还适合放到现在做我的画像分析吗?不一定的,因为我的兴趣可能已经随时间偏移了,过去我所喜欢的东西,现在我已经不喜欢了。
所以,在一般实际操作的过程中,一定需要分辨用户的兴趣数据的有效性,一般情况下,我们会进行长期兴趣和短期兴趣的区分,人在一定时间内其兴趣是固定的,并且在一些很短暂的时间段内,比如一两天、甚至是一天内,其关注点是有一定意义的,这个时候其短期兴趣就生效了。
所以,我们在实际操作的时候,长期兴趣、短期兴趣的具体的应用就需要结合实际的场景的区分了,已经我们需要注意原始数据是否适合做兴趣描述的来源数据,是否已经失效。
冷启动的问题。
所有涉及到行为数据的推荐算法,都绕不开冷启动的问题,即一个用户是个新手,没有任何行为记录留下,这意味着我们就无法分析其画像了,这个时候就称之为该用户的冷启动。
在上上个章节中,我们有提到过一些解决冷启动的机制,比如基于内容推荐(见上个章节),进行热点内容推荐(比如把最热门的一些电影推给该用户),还比如根据整体数据做关联推荐(这个后面再讲),方式很多,效果不一,需要根据具体情况来看了,再不行就想办法在用户注册的时候尽可能的收集用户的静态数据,再根据用户的静态画像数据来推荐,总比乱推的好。
匹配计算的问题。
在上面的例子中,我们其实并没有做过多匹配计算逻辑的讲解,只是简单描述同纬度的进行相似计算,然后上层做权重模型,其实就是一种很普通的匹配计算的过程。准不准,难在于外层权重的合理性,具体过程见第二篇文章,这里就不过多阐述。
其实这算是我们有意为之了,如果有些时候没法让不同主体(用户&内容)形成同一个维度矩阵的时候,这个时候其实就要有比较合理的映射机制了,能让内容与用户的属性做关联计算。
0.5.4 信息补充
写到这里,结合实际的数据,Spak工程代码,我们成功的从呆板的属性推荐过渡到基于用户画像的推荐,并为推荐附上了个性化的能力,但实际上基于用户画像的个性化推荐依然是有缺陷的,比如他不会做用户兴趣的升级,而实际上一些知识本身就是具有一定的阶梯性的。
举个例子就很容易理解了,比如,你对大数据的东西很感兴趣,于是系统根据你的兴趣偏好天天给你推Hadoop、大数据各种技术框架等信息,在某个时间段可能是合理,比如我对大数据领域已经熟知了呢?你还给我天天推送大数据相关的信息。
而我实际上是需要寻求大数据关联的信息,甚至是升级的信息,比如基于大数据的机器学习、数据挖掘相关的东西,这个机制是无法做到这一层的。所以,学完了这个,还没完事,下个章节,我们将学习另一个推荐机制,这种推荐机制可以为你推送一些基于你兴趣之外的东西。
06 经典的协同推荐
接上一个章节,我们大致Get到了一个点,那就是如果要达到推荐个性化的目的,核心还是用户的行为数据,只有用户各自的行为数据才能反馈其与其他人所不一样的特性,从而有针对性的进行推荐。按上个章节的原话,大致就是这样的:
实际上基于用户画像的个性化推荐依然是有缺陷的,比如他不会做用户兴趣的升级,而实际上一些知识本身就是具有一定的阶梯性的。
举个例子就很容易理解了,比如,你对大数据的东西很感兴趣,于是系统根据你的兴趣偏好天天给你推Hadoop、大数据各种技术框架等信息,在某个时间段可能是合理,比如我对大数据领域已经熟知了呢?你还给我天天推送大数据相关的信息。
而我实际上是需要寻求大数据关联的信息,甚至是升级的信息,比如基于大数据的机器学习、数据挖掘相关的东西,这个机制是无法做到这一层的。
说白了其实就是基于用户画像的推荐,他无法发现新知识,所谓新知识就是,与你之前的兴趣爱好相对比,推荐的候选集永远圈定在你的兴趣标签维度内,做不到认知的升级,而实际上认知是会进行升级的,特别是随着你捕获的知识信息越多的情况下,你就越会对更上层的其他知识感兴趣,不断的深入下去。
而基于协同过滤的推荐,或多或少能解决一点这类问题,最起码能够结合本身用户的行为,让你触达新的知识信息,并且这种递进是通过协同关系得到的,意味着是大部分人的共同选择,所以还是具有一定合理性的。
0.6.1 协同的原理拆解
对于基于协同过滤的推荐,可谓是推荐系统中的经典推荐算法了,记得好像就是亚马逊推广出来的,然后大放光彩。协同过滤又分为基于用户的协同(UserCF)、基于物品的协同(ItemCF),以及基于模型的协同(ModelCF)。
基于用户的协同过滤推荐(UserCF)。
基于用户的协同过滤,即我们希望通过用户之间的关系来达到推荐物品的目的,于是,给某用户推荐物品,即转换为寻找为这个用户寻找他的相似用户,然后相似用户喜欢的物品,那么也可能是这个用户喜欢的物品(当然会去重)。
来看一个表格:
|
用户/****物品
|
物品A
|
物品B
|
物品C
|
物品D
|
|
用户A
|
Y
|
?
|
Y
|
?
|
|
用户B
|
|
Y
|
|
|
|
用户C
|
Y
|
|
Y
|
Y
|
//其中Y表示对应用户喜欢对应物品,-表示无交集,?表示需不需要推荐。
这是一个最简单的例子,其实目的很简单,我们需要给用户A推荐物品,而且可以看到,用户已经喜欢了物品A和物品C,其实剩下也就B和D了,要么是B,要么是D。那么根据UserCF算法,我们先计算用户A与用户BC之间的相似度,计算相似,我们前文说了,要么距离,要么余弦夹角。
假如我们选择计算夹角(四维):cosAB=0(90度的夹角),cosAC=0.8199(角度自己算吧)。所以相比来说,我们会发现用户A与用户C的相似度是稍微大一些的。于是,我们观察用户C都喜欢了哪些物品,然后与用户的去重,然后会发现该给用户A推荐物品D。
简单来讲,UserCF就是如上过程,但在实际的过程中,数据量肯定不止这么点,于是我们需要做的是为用户计算出相似用户列表,然后在相似用户中经过去重之后,计算一个推荐的物品列表(在计算推荐物品的时候,可以叠加用户的相似程度进一步叠加物品的权重)。
然后在喜欢物品的表达形式上,可以是如上的这种二值分类,即Yes Or No,也可以是带有评分的程度描述,比如对于某个物品打多少分的这种表现形式。这样的话,针对于后一种情况,我们就需要在求在计算相似度时,加入程度的权重考量。
基于物品的协同推荐(ItemCF)
不同于基于用户的协同,这里,我们计算的是物品之间的相似度,但是,请注意,我们计算物品相似度的时候,与直接基于物品相似度推荐不同是,我们所用的特征并不是物品的自身属性,而依然是用户行为。
|
用户/****物品
|
物品A
|
物品B
|
物品C
|
|
用户A
|
Y
|
|
Y
|
|
用户B
|
Y
|
Y
|
Y
|
|
用户C
|
Y
|
?
|
?
|
//其中Y表示对应用户喜欢对应物品,-表示无交集,?表示需不需要推荐。
同样,这是一个简单实例。目的也明确,我们在知道用户AB喜欢某些物品情况,以及在用户C已经喜欢物品C的前提下,为用户C推荐一个物品。看表格很简单嘛。只有两个选项,要么物品B,要么物品C。那么到底是物品B还是物品C呢?
我们来计算物品A与其他两种物品的相似度,计算向量夹角。对于用户A,物品A与物品B,则对于AB向量为(1,0),(1,1),对于AC向量为(1,1),(1,1),分别计算夹角cosAB=0.7,cosAC=1。或者用类似关联规则的方法,计算两者之间的共现,例如AB共现1次,AC共现2次。通过类似这种方式,我们就知道物品A与物品C在某种程度上是更相似的。
我要说的就是类似共现类做计算的这种方式,在大规模数据的情况下是很有效的一种方式,基于统计的方法在数据量足够的时候,更能体现问题的本质。
基于模型的协同推荐(ModelCF)。
除了我们熟悉的基于用户以及基于物品的协同,还有一类,基于模型的协同过滤。基于模型的协同过滤推荐,基于样本的用户偏好信息,训练一个模型,然后根据实时的用户喜好信息进行预测推荐。常见的基于模型推荐又有三种:最近邻模型,典型如K最近邻;SVD模型,即矩阵分解;图模型,又称为社会网络图模型。
最近邻模型
最近邻模型,即使用用户的偏好信息,我们计算当前被推荐用户与其他用户的距离,然后根据近邻进行当前用户对于物品的评分预测。
典型如K最近邻模型,假如我们使用皮尔森相关系数,计算当前用户与其他所有用户的相似度sim,然后在K个近邻中,通过这些相似用户,预测当前用户对于每一个物品的评分,然后重新排序,最终推出M个评分最高的物品推荐出去。需要注意的是,基于近邻的协同推荐,较依赖当前被推荐用户的历史数据,这样计算出来的相关度才更准确。
SVD矩阵分解
我们把用户和物品的对应关系可以看做是一个矩阵X,然后矩阵X可以分解为X=A*B。而满足这种分解,并且每个用户对应于物品都有评分,必定存在与某组隐含的因子,使得用户对于物品的评分逼近真实值,而我们的目标就是通过分解矩阵得到这些隐性因子,并且通过这些因子来预测还未评分的物品。
有两种方式来学习隐性因子,一为交叉最小二乘法,即ALS;而为随机梯度下降法。首先对于ALS来说,首先随机化矩阵A,然后通过目标函数求得B,然后对B进行归一化处理,反过来求A,不断迭代,直到A*B满足一定的收敛条件即停止。
对于随机梯度下降法来说,首先我们的目标函数是凹函数或者是凸函数,我们通过调整因子矩阵使得我们的目标沿着凹函数的最小值,或者凸函数的最大值移动,最终到达移动阈值或者两个函数变化绝对值小于阈值时,停止因子矩阵的变化,得到的函数即为隐性因子。
使用分解矩阵的方式进行协同推荐,可解释性较差,但是使用RMSE(均方根误差)作为评判标准,较容易评判。
并且,我们使用这种方法时,需要尽可能的让用户覆盖物品,即用户对于物品的历史评分记录需要足够的多,模型才更准确。
社会网络图模型
所谓社会网络图模型,即我们认为每个人之间都是有联系的,任何两个用户都可以通过某种或者多个物品的购买行为而联系起来,即如果一端的节点是被推荐用户,而另一端是其他用户,他们之间通过若干个物品,最终能联系到一起。
而我们基于社会网络图模型,即研究用户对于物品的评分行为,获取用户与用户之间的图关系,最终依据图关系的距离,为用户推荐相关的物品。
目前这种协同推荐使用的较少。
0.6.2 基于Spark的协同过滤实践
老规矩,大致过完了理论,我们来走一遭代码实践,数据源的解释不就多说了,依然还是那份电影数据,不清楚的见上上上一章节的的数据说明,这次我们只用到涉及到评分的数据,共100万条,我们通过评分行为来做协同过滤。
截止Spark2.X系列,Spark的MlLib只实现了基于矩阵分解的协同(也就是经典的基于ALS协同过滤),没有实现更常规的基于物品或者基于用户的协同过滤,但从上面的原理我们知道,其实基于物品基于用户的协同核心就在于构建基础向量矩阵以及计算相似的两个方面,我这边也是实现了,但基于篇幅这里,就只介绍基于ALS的实践过程了,其他两个案例,需要的话请联系我。
由于MlLib实现了算法模型,所以从敲代码的维度上来说,代码量反而会远远低于基于用户、基于物品的协同,甚至会少于之前的基于物品相似或者基于用户画像的推荐了,顺带说一句,基于ALS的推荐代码,其实网上很容易找,算法MlLib中的经典算法了,很多人都实现了,不过万变不离其宗(变个毛线,API接口就那几个,参数也就那几个,能怎么变)。
先Hive数据表中,将rating评分数据取出来(当然,如果你的机子跑不动,就limit一下简单取些数,跑通模型就得啦)。
val ratingDataOrc = sparkSession.sql("select userid,movieid,rate,timestame from mite8.mite_ratings limit 50000")
将取出的评分数据,以时间构建Key-value键值对,形成(Int,Ratings)格式的数据,其实这是一个中间处理过程,方便后续的数据输入。
<pre style="margin: 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; word-wrap: break-word !important; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial; widows: 1; color: rgb(169, 183, 198); font-family: 宋体; font-size: 10.5pt; background-color: rgb(43, 43, 43);">val ratings = ratingDataOrc.rdd.map(f =>
(java.lang.Long.parseLong(f.get(3).toString)%10,
Rating(java.lang.Integer.parseInt(f.get(0).toString),
java.lang.Integer.parseInt(f.get(1).toString),
f.get(2).asInstanceOf[java.math.BigDecimal].doubleValue())))</pre>
这里,鉴于计算能力,我就不进行全局用户的候选集推荐计算了,只拿ID=1的用户当成实验,获取ID=1的用户候选推荐列表,先取该用户的行为数据。
<pre style="margin: 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; word-wrap: break-word !important; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial; widows: 1; color: rgb(169, 183, 198); font-family: 宋体; font-size: 10.5pt; background-color: rgb(43, 43, 43);">val personalRatingsData = ratingDataOrc.where("userid = 1").rdd.map{
f=>
Rating(java.lang.Integer.parseInt(f.get(0).toString),
java.lang.Integer.parseInt(f.get(1).toString),
f.get(2).asInstanceOf[java.math.BigDecimal].doubleValue())
}</pre>
基于上上面的K-V中间数据,我们以取余的方式,将数据分成6:2:2,三个比例,分别进行模型训练,数据校验,以及结果测试。
<pre style="margin: 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; word-wrap: break-word !important; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial; widows: 1; color: rgb(169, 183, 198); font-family: 宋体; font-size: 10.5pt; background-color: rgb(43, 43, 43);">val training = ratings.filter(x => x._1 < 6).values
.union(personalRatingsData).repartition(numPartions).persist()
val validation = ratings.filter(x => x._1 >=6 && x._1 < 8).values
.repartition(numPartions).persist()
val test = ratings.filter(x => x._1 > 8).values.persist()</pre>
ALS的推荐效果评估,一般我们是以均方根差来离线衡量推荐的准确度,所以,这里涉及到了ALS参数调优的问题,我们通过数据来最终确定参数,并确定最终的Model,分别取ranks、lambdas、numIters作为调优对象。
<pre style="margin: 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; word-wrap: break-word !important; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial; widows: 1; color: rgb(169, 183, 198); font-family: 宋体; font-size: 10.5pt; background-color: rgb(43, 43, 43);">var count = 0 //进行三层循环遍历,找最佳的Rmse值,对应的model for (rank <- ranks; lambda <- lambdas; numIter <- numIters) {
val model = ALS.train(training, rank, numIter, lambda)
//计算均根方差值,传入的是model以及校验数据
val validationRmse = computeRmse(model, validation, numValidation)
count += 1
//选取最佳值,均方根误差越小越OK
if (validationRmse < bestValidationRmse) {
bestModel = Some(model)
bestValidationRmse = validationRmse
bestLambda = lambda
bestRank = rank
bestNumIter = numIter
}
}</pre>
基于上面最终选择的参数,输出Model,我们基于这个模型,去做最后的推荐,注意需要去除ID=1的用户已经观看过的电影。
<pre style="margin: 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; word-wrap: break-word !important; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial; widows: 1; color: rgb(169, 183, 198); font-family: 宋体; font-size: 10.5pt; background-color: rgb(43, 43, 43);">//推荐前十部最感兴趣的电影,注意需要剔除该用户(userid=1)已经评分的电影,即去重 val myRatedMovieIds = personalRatingsData.map(f=>f.product).collect().toSet
val candidates = movies.keys.filter(!myRatedMovieIds.contains())
//为用户1推荐十部movies,我们只做用户ID=1的推荐 val candRDD: RDD[(Int, Int)] = candidates.map((1, ))
val recommendations:RDD[Rating] = bestModel.get.predict(candRDD)
val recommendations = recommendations.collect().sortBy(-.rating).take(20)</pre>
存储推荐的结果,主要Row需要先进行格式化。
//结果存储用户1的推荐结果
val alsBaseReDataFrame = sparkSession.sparkContext
.parallelize(recommendations_.map(f=> (f.user,f.product,f.rating)))
.map(f=>Row(f._1,f._2,f._3))
//DataFrame格式化申明
val schemaString = "userid movieid score"
val schemaAlsBase = StructType(schemaString.split(" ")
.map(fieldName=>StructField(fieldName,if (fieldName.equals("score")) DoubleType else IntegerType,true)))
val movieAlsBaseDataFrame = sparkSession.createDataFrame(alsBaseReDataFrame,schemaAlsBase)
//将结果存入hive
val itemBaseReTmpTableName = "mite_alsbasetmp"
val itemBaseReTableName = "mite8.mite_als_base_re"
movieAlsBaseDataFrame.registerTempTable(itemBaseReTmpTableName)
sparkSession.sql("insert into table " + itemBaseReTableName + " select * from " + itemBaseReTmpTableName)
最后再补上求均方根差的函数。
def computeRmse(model:MatrixFactorizationModel,data:RDD[Rating],n:Long):Double = {
//调用model的predict预测方法,把预测数据初始化model中,并且生成预测rating
val predictions:RDD[Rating] = model.predict((data.map(x => (x.user, x.product))))
val dataTmp = data.map(x => ((x.user, x.product), x.rating))
//通过join操作,把相同user-product的value合并成一个(double,double)元组,前者为预测值,后者为实际值
val predictionsAndRatings = predictions.map{
x => ((x.user, x.product), x.rating)
}.join(dataTmp).values
//均方根误差能够很好的反应出测量的精密度,对于偏离过大或者过小的测量值较为敏感
//计算过程为观测值与真实值偏差的平方,除于观测次数n,然后再取平方根
//reduce方法,执行的是值累加操作
math.sqrt(predictionsAndRatings.map(x => (x._1 - x._2) * (x._1 - x._2)).reduce( _ + _ )/n)
}
至此,整个代码逻辑就结束了,其实我们不难发现,被框架封装的算法,其实使用起来更加的简单,如果抛开校验以及优化模型的过程,总共代码都没有几行。
最后再补充一个点。
这里大家可能对为什么协同能够发现新物品,而基于用户兴趣的画像推荐不能,原则上说基于画像会将思维局限于画像兴趣的偏好内,但兴趣本身就会升级的,这是通过历史的单个用户的行为所不能推测的。
而基于协同不一样,他一方面考虑的用户的历史行为,另一方面他参考了该用户的周围协同的行为,而对于大部分人来说,共有的行为轨迹其实很多时候能够一定程度上体现用户的自我认知,以及认知升级的过程,这意味着物品之间的关联性本身就通过共有的用户行为天然关联,而协同就是要挖掘这种潜在的关联性,这无关物品之间的属性差异。
所以,从这个维度上说,协同是容易产生惊喜推荐的一种机制。
07 从推荐策略算法到系统,到数据架构,再到产品思维设计
接推荐系统系列的上一个章节,开篇2个章节我们从概念,从应用场景出发,大概的把推荐系统基础知识给大伙儿普及了一遍,接下来的三个章节,分别由浅到深,从理论到代码案例讲解几种不同推荐系统的策略或者推荐算法,看着整个系列从理论到案例,该有的都有了,其实不然,之前就有说过,推荐策略或者算法与推荐系统是有本质的不同的,而这一篇就是要把前面的东西进行收拢,从整体上进行收官。
虽然不再从策略维度再进一步深入,即这里不会再从理论到代码案例再深入讲策略,但是实际上推荐的策略是远不止如此的,并且从应用以及系统的角度来说,并没有说固定的策略可言。
** 0.7.1 推荐策略以及算法的百家齐放**
承上,我们讲了最基础的基于内容属性本身的相似关系进行针对物品的推荐,再到基于用户的兴趣属性进行推荐,再过渡到基于协同关系进行推荐,其实这些都算是推荐的策略,说的更技术点就是推荐的算法。
而推荐策略的想象力其实无限的,并不局限于某种固定的策略,只要从业务的角度走的通,其实都是可以的,当然具体的选择以及搭配问题,后面我会讲到。
我们来看已经归属于大腾讯的“起点中文网”的推荐。
PS:截图是我随意点击的一本小说《飞剑问道》,不重要,可以不care。
从他的推荐理由“喜欢这本书的人还喜欢”来看,显然是通过用户之间的阅读关联性来做的这次推荐,说的更通俗易懂点就是购物篮分析:买这个商品的人还经常一起买其他商品。
是不是逻辑关系很像?当然实际上到底是不是这种推荐策略,就需要起点的算法工程师出来讲话了,我个人只是从业务层往下进行追溯从而得出的结论。你看,我说的对不对,策略一层是没有定式,购物篮分析的逻辑依然是可以用在看文学站的推荐逻辑上,没毛病。
我们再来看一个策略,依然是腾讯的,腾讯社交广告一直对外宣称的技术“Lookalike”,翻译成技术语言就是“人群扩散算法”。简单的逻辑是,先找种子用户,然后基于用户画像和关系链(这是腾讯强项)挖掘相似用户,然后再将受众进行扩大。
具体示意图如下:
你觉得这是推荐?看着更像是广告投放,但广告的投放谁说不是一次信息主体的推荐呢?本质上应该是没有这么大的差距的,只是一个从业务的角度去描述,一个是更偏技术的角度的说法。
随着阿法狗诸如此类的人工智能应用的推广(造势),以及近几年计算能力的大幅提升,使得依赖于大计算能力的神经网络的相关算法得以大放光彩,基于神经网络的一些推荐算法或者策略也是一个大的研究方向。
综上,不必举过多的推荐策略或者算法例子了,核心想说的就是,其实策略层本身就是百花齐放的状态,甚至你随意光顾一些平台网站,都能遇到不同的策略和算法,甚至是搭配组合,没有限定的策略和算法层,只有不同不适应的应用场景,以及从策略到推荐系统层,其实还是有很多东西的。
0.7.2 从推荐策略算法到推荐系统
接上面的话题,从策略算法层到系统层,差的有哪些东西:
1.首先是策略并不等于系统,这是明确的,推荐的整体逻辑也未必是一种逻辑在里头,也可能是多种的策略组合。
2.其次,如何选择策略,如何组合策略,如何去评判,如何追踪效果,这点尤其重要。
3.对于整个系统级的支撑,在工程化,以及数据架构上如何去实现,才能支持上层的算法逻辑层。
4.产品层的策略对整个推荐系统的影响有多大。
如上四个问题都是从推荐系统的角度出发进行分析的,从这里我们知道,光知道策略或者推荐算法,是不是离推荐系统的构建还差那么好几个量级,02这个小节,我们先从1/2两个小点进行分析。
策略!=系统,这点是明确的,并且选择哪些策略去做推荐,本身就有严格的选择机制以及评判机制在里头的。
这张图片很有意思,是别人在脉脉上调侃各大大厂的推荐系统的话,挺有意思,另一方面也是可以侧面验证各大长的一些侧重点(不过有点为鹅厂说好话的嫌疑),不管,我们随拿一些实例来看看。
首先是豆瓣(当前主页是魔戒1的主页):
从直观的的角度讲,同类推荐的因素一定的在,比如《指环王》其他,比如哈利波特,加勒比海盗,但诸如大鱼、角斗士、勇敢的心、与狼共舞这些的逻辑就不得而知了。
从用户的角度上看,个人使用豆瓣电影也不算少,但几乎没有账号(但如果说没有账号就体系就推不准,那这个公司可以死球去了,有很多可以做类似唯一用户的判别的方式,诸如浏览器、电脑硬件地址、cookie等等),但从我的个人感知来看,推荐的效果一般般。
再换一个,这是某宝的(当前是一个iphoneX的购买主页):
诚如调侃所言,我吃完两馒头,再问我要不要两馒头,我搜索iphoneX,他问我要不要iphone从6到6s到8s,挨个问,也够累的,反正我是不喜欢这种格调。
再回到大腾讯,这是之前文章就涉及到的,腾讯视频的推荐:
当前页面为《海上牧云记》的播放页面,个人是腾讯视频的中长期会员,再看看他给我推荐的是什么?大部分都是类似的奇幻古装剧,而老实讲,我对这种剧着实不喜欢,拍的tmd的假,而我只是好奇点击进去的,So...
再说到腾讯的朋友圈广告的投放推荐,前段时间一直给我这种孩子都快打酱油的人推婚纱摄影,这是几个意思?
再多的例子这里就不举了,很多看其推荐的列表大致能猜测一些其推荐的策略重点,其实或多或少还真是与调侃的有一些相似之处,那从表面看起来大部分的推荐系统都不像那么高大上,问题出在哪,是他们的策略就是这么Low?是他们的算法工程师太菜?
其实核心问题在于推荐系统的评价机制,在实际的场景中,一切以效果评价为导向,将策略,甚至是组合推荐的策略往评价得分高的方向进行调整,对于整个系统来说才是有意义的,并不是说算法高深就一定好。
那么,具体什么是合理的评价方式呢,大部分来说都是为了让用户的挺溜时间加长,最直接的表现就是点击转化,但并不是完全绝对的。以百度的调侃为例,你要的是馒头,人家给你推荐的是馒头制造机。
这跟其商业模式是有一定的关联的,百度之前的核心就是关键词竞价广告,那么,他必然要衡量几个方面的东西,第一是关键词与搜索词的相关性,离太远太扯淡的不行;第二相关广告的竞价。
于是,他就需要衡量准与商业价值之间的关系了,所以,并不是一味地追求准确,而是追求在其中最佳的平衡点,能给百度带来最佳的广告收益转化。
再说其他的之前我所体验的推荐系统,他们就一定不准吗?或许以我个人的角度讲,他们推荐的并不是很符合我的口味,但是如果他们是从整体转化进行评判呢?这种机制是他们所有目前方案中的最佳转化方案,那为什么不能用?少数的个体badcase并不会影响整体的策略,也不用管low还是不low,抓住核心问题。
当然,不可否认的是,如果能满足所有的人的意愿认为它很准,又能让整体系统的转化做到最大化,那当然最好了。
所以,从推荐策略算法到推荐系统,最核心的一个东西就是评估机制,构建起完善并且合理的评估机制,有利于整体推荐系统的优化和改进。说到评价,那不得不说的就是AB测试了,一个好的推荐系统,一定是会带AB测试的,能够很好的进行策略对比,进行流量分配,效果展示等等。
0.7.2 推荐系统数据架构
前几天记得分享过朋友的一篇文章,核心就是讲推荐系统架构的。对于整个推荐系统来说,系统的架构设计会严重影响到整个系统的效果与上层应用的体验。
在第05个章节中,记得大致提到过基于用户画像推荐的短期兴趣与长期兴趣,其实长期兴趣的挖掘还好,基本基于离线的计算结果依然还是可行的,但是对于很多推荐机制来说,就是短期兴趣,更切确的说是你当前行为的兴趣表现。
这意味着,我需要实时的对你的浏览行为进行兴趣分析,然后实时的反馈给你推荐列表,这种机制看似简单脑残,但往往很有效,因为他足够实时,而在段时间内,人的注意力一般只会放在很垂直的某个点上,所以往往就很有效。
但是,看似简单的机制,对于这种需要支持实时分析,实时反馈的机制来说,架构的设计就是一个挑战。此外,在整体的系统构建过程中,你需要考虑算法逻辑层可迭代性的问题,即通过反馈数据能够不断的自动调整你的算法策略,从而让效果更佳,这些都是需要数据架构进行支撑的。
此外,就是上面说的AB测试,效果反馈机制,都是需要集成至整个推荐系统中,再有承接上层应用,你需要考虑好计算的效率与结果输出的效率问题,所以缓存的设计与缓存更新的机制又是个问题。
从整个架构层来说,其实是相对繁杂的,与我们之前所说的策略算法层,这是另外一个维度上的东西,需要我们从整体系统级别去考量。
归纳来说,其实从推荐系统架构设计的角度来说,需要考虑以下几个因素或者说有以下几个重要组成部分:
数据的输入层,承接特征处理层,作为算法策略层的输入。
推荐策略以及算法实现层,这就是我们说的百家齐放的那层。
基于推荐算法的结果候选集,进行策略组合、排序以及召回,最终系统层吐出去的是这部分的结果,而非算法策略层的直接结果。
推荐系统的数据反馈回收机制,不管是隐性的还是显性反馈。
AB测试分流机制。
基于反馈数据以及AB测试结果的算法层动态迭代部分,包括基础的推荐算法,以及排序召回等部分。
部分系统需要考虑实时数据的反馈流通应用问题。
至于说具体的推荐系统架构,相信大家随便一搜都能搜到很多架构图,可能略有偏差,但是个人感觉只要不违背如上的一些基本原则,大体上结合自身的场景去调整,是没有大毛病的,所以,这里就不给具体的架构逻辑图了。
0.7.3 从系统到产品策略层
说完推荐策略,再到推荐系统,再到系统架构,这些看似都是与数据、与算法严密相关的东西,单纯的以产品思维角度出发,你觉得在设计或者做一个推荐系统时,有什么需要考虑的吗?这个层面是很多技术人员很容易忽略的一部分。
其实只要用点心,就算不太care算法策略,也是大有可为的,以上面贴过的一张图为例。
我们来看他的左上角标题“喜欢这本书的人还喜欢”,其实这就是一种推荐解释,同理,我们可看亚马逊的推荐解释“买过这个商品的人还购买了”。
这就是所谓的推荐结果的可解释性,人往往信任一些可以解释、容易理解的东西,这也就是为何很多推荐系统都愿意去给出这种类似的推荐解释,因为这种行为可以提升可信度,而可信度可以增加用户的点击转化,所以,可信度也是推荐系统设计中的一个重要参考因素。
从上面这么多推荐案例中,我们不难发现一个共同特征,那就是右上角的“换一批”按钮,我们来思考一下这个按钮存在的意义。
任何一次用户点击这个换一批按钮,这就意味着我们收集到了用户的反馈行为,至于说这个反馈行为到底是正向的还是负向的,就得看具体分析了。比如一个用户一个推荐项都没有点,不断的切换推荐列表,直至放弃,这显然你的推荐列表不如人意,但该用户又是一个迫切需要推荐场景的用户。如果某个用户,在不断点击推荐项的同时,又在不断的切换列表,这意味着这个用户很乐意使用推荐的场景,并且推荐的列表还是可以的。怕就怕那种不点击,也不切换的用户,我们无法获取到更多的主动反馈了。
说到主动反馈,另外一个纯产品层的设计思路就是推荐项的主动反馈了,这点也是亚马逊首创,每个推荐项用户都可以打分,或者直接评判说喜欢与不喜欢。通过这种方式,不断的收集用户的喜好,然后融入策略算法层,才能让你的推荐系统更加的合理,体验更好。
所以,单纯的从产品层,也是有很多东西可以去研究的,对于整体推荐系统而言,他就是一个应用,无非是更偏向于数据、算法等维度的一个产品而已,我们可以从算法层去着手,也可以试图从产品层去优化。
07 总结补充
到这里,整个推荐长文基本就结束了,从整个文章的的逻辑我们知道,如果你要架设一个推荐系统,那么首先对于推荐系统的一些基本概念需要熟悉,然后了解不同的推荐策略,然后结合场景分析最佳的一些推荐策略算法,然后基于架构的考虑(不同层级的分离),搭建整个推荐系统,然后从产品的思维角度去优化,最终产出符合你业务特征的推荐系统。看着容易,其实操作起来还是有一定困难的。
推荐系统在一般业务规模小的时候,其实鸟用不大的,只有在业务有了一定规模之后,那么就到了锱铢必较的阶段了,使用推荐哪怕增加了5个点的增长转化,也是极好的,更何况可能远远不止呢。
目前很多主流推荐系统都是基于用户的画像、兴趣爱好推荐的(这是一种相对靠谱,又容易在大规模用户场景中使用的策略),你越是被他推荐的东西牵着走,你后续就会越陷入其中,最终导致了你所获取的信息一直都是圈定在某个范围内的,这就是所谓的“信息茧房”。
其实要形成信息茧房一方面是由于推荐机制导致的,另一方面跟场景也是有很大关系的,比如如果用户被你所推荐的东西所推动,那么就容易陷入这种状态,如果用户获取信息的渠道有多种(比如导航、搜索等等),那么就不那么容易。
典型如今日头条,如果在前期你不小心点击了一些比较low的内容,然后它就越给你推类似的文章,结果你越看,它就越推,于是你所看到的东西都是一大坨类似离谱八卦了。从直观的角度看,今日头条重度依赖于用户的阅读行为,而头条又是一个重推荐场景的产品,所以会相对容易陷入“信息茧房”的这种情况。
从目前看,头条解决这个问题的途径是,给出热度频道,这个逻辑一定程度上降低用户的兴趣偏爱分析,这样用户能够接触到信息面就会更广,进而促使用户能够调整其兴趣,不断的更新其兴趣。
单纯从转化的角度看来,短期内可能对于系统侧来说是正向的,因为他才不会关注到底是不是“信息茧房”,他只关注转化有没有提升,但长期来说,对于用户就是一种损害。所以,我们在考虑设计推荐策略算法的时候,多多少少都会考虑推荐的新颖性。
但新颖性这东西就是一个双刃剑,新的东西意味着不确定,不确定意味着可能低的转化,所以好的推荐系统一定是在确保你兴趣的同时,又会考虑新颖,并且这是一种顺其自然的推荐信息主体的过渡,构建起你偏好信息与新信息之间的关联性,让你同样有欲望去点击那些新的东西,不过这就很难是了。