This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,171 @@
<audio id="audio" title="09 | 线上服务:如何在线上提供高并发的推荐服务?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4e/e8/4e21a14b43df0cacb624e01e839426e8.mp3"></audio>
你好,我是王喆。今天开始,我们进入线上服务篇的学习。
很多同学提起推荐系统,首先想到的是那些结构“华丽”,发展迅速的推荐模型。但事实上,在一个实际的工业级推荐系统中,训练和实现推荐模型的工作量往往连一半都没有。大量的工作都发生在搭建并维护推荐服务器、模型服务模块,以及特征和模型参数数据库等线上服务部分。
同时,由于线上服务模块是直接服务用户,产生推荐结果的模块,如果一旦发生延迟增加甚至服务宕机的情况,就会产生公司级别的事故。因此毫不夸张地说,线上服务实际上是推荐系统中最关键的一个模块。
线上服务如果写得不好,不仅杂乱无章,而且难以升级维护。因此,为了让你掌握搭建起一个支持深度学习的、稳定可扩展的推荐服务的方法,在这一模块中,我们会依次来讲线上服务器、特征存储、模型服务等模块的知识。
今天我们先聚焦线上服务器一起搭建直接产生推荐结果的服务接口。在这个过程中我们按照先了解、后思考、再实践的顺序依次解决这3个关键问题
1. 一个工业级的推荐服务器内部究竟都做了哪些事情?
1. 像阿里、字节、腾讯这样级别的公司,它们的推荐系统是怎么承接住每秒百万甚至上千万的推荐请求的?
1. 我们自己该如何搭建一个工业级推荐服务器的雏形呢?
## 工业级推荐服务器的功能
首先我们来解决第一个问题一个工业级的推荐服务器内部究竟做了哪些事情要回答这个问题我们要先回到专栏的出发点在推荐系统的技术架构图上找到推荐系统线上服务模块的位置。只有我们心中有全局学习才能有重点。图1中红色的部分就是我们要详细来讲的线上服务模块。
<img src="https://static001.geekbang.org/resource/image/c1/ed/c16ef5cbebc41008647425083b7b38ed.jpeg" alt="" title="图1 推荐系统技术架构图">
可以看到线上服务模块的功能非常繁杂它不仅需要跟离线训练好的模型打交道把离线模型进行上线在线进行模型服务Model Serving还需要跟数据库打交道把候选物品和离线处理好的特征载入到服务器。
而且线上服务器内部的逻辑也十分地复杂不仅包括了一些经典的过程比如召回层和排序层还包括一些业务逻辑比如照顾推荐结果多样性流行度的一些硬性的混合规则甚至还包括了一些AB测试相关的测试代码。
## 高并发推荐服务的整体架构
我刚才说的就是线上服务的技术框架了可以说想要把线上服务写好难度并不小更何况在面对高QPS的压力下事情还会变得更复杂。接下来我们就来看第二个问题说一说阿里、字节、腾讯这样级别的公司使用了哪些策略来承接住每秒百万甚至是上千万推荐请求的。
说实话,想彻底讲清楚这个问题并不容易,因为大厂关于甚高并发具体的解决方案是集整个集团的技术精英打造的,而且维护一个高可用的服务集群的工作也不是一个算法工程师的主要工作方向。但这里,我还是希望你能够从宏观的角度了解高并发的主要解决方案,因为它是一个工业级推荐系统的重要组成部分,也是我们在与架构组配合工作时应有的知识储备。
宏观来讲,高并发推荐服务的整体架构主要由三个重要机制支撑,它们分别是**负载均衡、缓存、推荐服务降级机制。**下面,我们一一来看。
首先是负载均衡。它是整个推荐服务能够实现高可用、可扩展的基础。当推荐服务支持的业务量达到一定规模的时候单独依靠一台服务器是不可行的无论这台服务器的性能有多强大都不可能独立支撑起高QPSQueries Per Second每秒查询次数的需求。这时候我们就需要增加服务器来分担独立节点的压力。既然有多个劳动力在干活那我们还需要一个“工头”来分配任务以达到按能力分配和高效率分配的目的这个“工头”就是所谓的“负载均衡服务器”。
下图就很好地展示了负载均衡的原理。我们可以看到负载均衡服务器Load Balancer处在一个非常重要的位置。因此在实际工程中负载均衡服务器也经常采用非常高效的nginx技术选型甚至采用专门的硬件级负载均衡设备作为解决方案。
[<img src="https://static001.geekbang.org/resource/image/a2/e1/a2daf129556bc3b9fd7dcde4230db8e1.jpeg" alt="" title="图2 高并发情况下的负载均衡服务器来源GitHub">](https://github.com/dmytrostriletskyi/heroku-load-balancer)
这个时候,有的同学可能会问,“负载均衡”解决高并发的思路是“增加劳动力”,那我们能否从“减少劳动量”的角度来解决高并发带来的负载压力呢?这是一个非常好的角度。要知道,推荐过程特别是基于深度学习的推荐过程往往是比较复杂的,进一步来说,当候选物品规模比较大的时候,产生推荐列表的过程其实非常消耗计算资源,服务器的“劳动量”非常大。这个时候,我们就可以通过减少“硬算”推荐结果的次数来给推荐服务器减负,那具体怎么做呢?
比如说当同一个用户多次请求同样的推荐服务时我们就可以在第一次请求时把TA的推荐结果缓存起来在后续请求时直接返回缓存中的结果就可以了不用再通过复杂的推荐逻辑重新算一遍。再比如说对于新用户来说因为他们几乎没有行为历史的记录所以我们可以先按照一些规则预先缓存好几类新用户的推荐列表等遇到新用户的时候就直接返回。
因此在一个成熟的工业级推荐系统中合理的缓存策略甚至能够阻挡掉90%以上的推荐请求,大大减小推荐服务器的计算压力。
但不管再强大的服务集群,再有效的缓存方案,也都有可能遭遇特殊时刻的流量洪峰或者软硬件故障。在这种特殊情况下,为了防止推荐服务彻底熔断崩溃,甚至造成相关微服务依次崩溃的“雪崩效应”,我们就要在第一时间将问题控制在推荐服务内部,而应对的最好机制就是“服务降级”。
所谓“服务降级”就是抛弃原本的复杂逻辑采用最保险、最简单、最不消耗资源的降级服务来渡过特殊时期。比如对于推荐服务来说我们可以抛弃原本的复杂推荐模型采用基于规则的推荐方法来生成推荐列表甚至直接在缓存或者内存中提前准备好应对故障时的默认推荐列表做到“0”计算产出服务结果这些都是服务降级的可行策略。
**总之,“负载均衡”提升服务能力,“缓存”降低服务压力,“服务降级”机制保证故障时刻的服务不崩溃,压力不传导**,这三点可以看成是一个成熟稳定的高并发推荐服务的基石。
## 搭建一个工业级推荐服务器的雏形
那说了这么多,这对我们搭建一个工业级推荐服务器有什么实际帮助呢?
相信你肯定听说过一句话,算法工程师是“面试造火箭,工作拧螺丝”。说实话,这确实反映了算法岗面试的一些不合理之处,但也不是说造火箭的知识不应该掌握。要给一个火箭拧螺丝,真不是说会拧螺丝就可以了,还真是得清楚火箭的构造是什么样的,否则螺丝你是拧上了,但地方拧错了,照样会让火箭出事故。
我们刚才讲的大厂处理高并发服务的方法就是“造火箭”理解了这些方法我们再来学学实际工作中“拧螺丝”的技巧就能做到有的放矢。下面我们就一起在Sparrow Recsys里面实践一下搭建推荐服务器的过程看看如何一步步拧螺丝搭建起一个可用的推荐服务器。当然它肯定无法直接具备负载均衡这些企业级服务的能力但我可以保证它可以作为一个工业级推荐服务器的雏形。让你以此为起点逐渐把它扩展成为一个成熟的推荐服务。
首先我们要做的就是选择服务器框架。这里我们选择的服务器框架是Java嵌入式服务器Jetty。为什么我们不选择其他的服务器呢原因有三个。
第一相比于Python服务器的效率问题以及C++服务器的开发维护难度Java服务器在效率和开发难度上做到了一个权衡而且互联网上有大量开源Java项目可以供我们直接融合调用所以Java服务器开发的扩展性比较好。第二相比Tomcat等其他Java服务器Jetty是嵌入式的它更轻量级没有过多J2EE的冗余功能可以专注于建立高效的API推荐服务。而Tomcat更适用于搭建一整套的J2EE项目。第三相比于基于Node.js、Go这样的服务器Java社区更成熟和主流一些应用范围更广。
当然每一种技术选择都有它的优势C++的效率更高Python更便捷Go的上升势头也愈发明显我们只要清楚Jetty是企业级服务的选择之一就够了我们接下来的服务器端实践也是基于Jetty开展的。
作为一款嵌入式服务器框架Jetty的最大优势是除了Java环境外你不用配置任何其他环境也不用安装额外的软件依赖你可以直接在Java程序中创建对外服务的HTTP API之后在IDE中运行或者打Jar包运行就可以了。下面就是我们Sparrow Recsys中创建推荐服务器的代码我已经在所有关键的地方添加了注释你可以逐句解读一下。
```
public class RecSysServer {
//主函数,创建推荐服务器并运行
public static void main(String[] args) throws Exception {
new RecSysServer().run();
}
//推荐服务器的默认服务端口6010
private static final int DEFAULT_PORT = 6010;
//运行推荐服务器的函数
public void run() throws Exception{
int port = DEFAULT_PORT;
//绑定IP地址和端口0.0.0.0代表本地运行
InetSocketAddress inetAddress = new InetSocketAddress(&quot;0.0.0.0&quot;, port);
//创建Jetty服务器
Server server = new Server(inetAddress);
//创建Jetty服务器的环境handler
ServletContextHandler context = new ServletContextHandler();
context.setContextPath(&quot;/&quot;);
context.setWelcomeFiles(new String[] { &quot;index.html&quot; });
//添加APIgetMovie获取电影相关数据
context.addServlet(new ServletHolder(new MovieService()), &quot;/getmovie&quot;);
//添加APIgetuser获取用户相关数据
context.addServlet(new ServletHolder(new UserService()), &quot;/getuser&quot;);
//添加APIgetsimilarmovie获取相似电影推荐
context.addServlet(new ServletHolder(new SimilarMovieService()), &quot;/getsimilarmovie&quot;);
//添加APIgetrecommendation获取各类电影推荐
context.addServlet(new ServletHolder(new RecommendationService()), &quot;/getrecommendation&quot;);
//设置Jetty的环境handler
server.setHandler(context);
//启动Jetty服务器
server.start();
server.join();
}
```
你可以看到创建Jetty服务的过程非常简单直观十几行代码就可以搭建起一套推荐服务。当然推荐服务的主要业务逻辑并不在这里而是在每个注册到Jetty Context中的Servlet服务中。这里我们用其中最简单的Servlet服务MovieService来看一看Jetty中的Servlet服务是怎么写的。
```
//MovieService需要继承Jetty的HttpServlet
public class MovieService extends HttpServlet {
//实现servlet中的get method
protected void doGet(HttpServletRequest request,
HttpServletResponse response) throws IOException {
try {
//该接口返回json对象所以设置json类型
response.setContentType(&quot;application/json&quot;);
response.setStatus(HttpServletResponse.SC_OK);
response.setCharacterEncoding(&quot;UTF-8&quot;);
response.setHeader(&quot;Access-Control-Allow-Origin&quot;, &quot;*&quot;);
//获得请求中的id参数转换为movie id
String movieId = request.getParameter(&quot;id&quot;);
//从数据库中获取该movie的数据对象
Movie movie = DataManager.getInstance().getMovieById(Integer.parseInt(movieId));
if (null != movie) {
//使用fasterxml.jackson库把movie对象转换成json对象
ObjectMapper mapper = new ObjectMapper();
String jsonMovie = mapper.writeValueAsString(movie);
//返回json对象
response.getWriter().println(jsonMovie);
}else {
response.getWriter().println(&quot;&quot;);
}
} catch (Exception e) {
e.printStackTrace();
response.getWriter().println(&quot;&quot;);
}
}
```
熟悉了这个Servlet服务其他服务就依葫芦画瓢就可以啦。唯一的不同就是其中的业务逻辑。如果你已经从GitHub上下载了Sparrow Recsys项目把它运行起来并且在浏览器中输入http://localhost:6010/getmovie?id=1就可以看到getMovie接口的返回对象了。
## 小结
这节课我们既学习了怎么“造火箭”,又实践了怎么“拧螺丝”。对于一个合格的算法工程师来说,这两方面缺一不可。
“造火箭”的知识包括工业级推荐服务器的具体功能,以及实现工业级高并发推荐服务的主要机制。其中,推荐服务器的具体功能主要有:模型服务、数据库接口、推荐模块逻辑、补充业务逻辑等等,而工业级高并发推荐服务的主要机制有负载均衡、缓存和服务降级。
“拧螺丝”的技能我们也掌握了不少我们利用Jetty实践并搭建起了我们SparrowRecSys的推荐服务接口。这个过程中我们需要重点关注的是每个注册到Jetty Context的Servlet服务中的主要业务逻辑只要掌握了一个在实际工作中我们就能举一反三了。
老规矩,我今天继续用表格的形式帮你整理了这节课的主要知识点,你可以看看。
<img src="https://static001.geekbang.org/resource/image/9f/df/9f756f358d1806dc9b3463538567d7df.jpeg" alt="">
好了,推荐服务器的相关内容我就先讲到这里,下节课我会继续讲解线上服务的另一个主要的组成部分,存储模块。
## 课后思考
在一个高并发的推荐服务集群中,负载均衡服务器的作用至关重要,如果你是负载均衡服务器的策略设计师的话,你会怎么实现这个“工头”的调度策略,让它能够公平又高效的完成调度任务呢?(比如是按每个节点的能力分配?还是按照请求本身的什么特点来分配?如何知道什么时候应该扩展节点,什么时候应该关闭节点?)
欢迎把你的思考和答案写在留言区,也欢迎你把这节课分享给你的朋友,我们下节课见!

View File

@@ -0,0 +1,151 @@
<audio id="audio" title="10 | 存储模块如何用Redis解决推荐系统特征的存储问题" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5c/53/5ce2aee6c8770ea6e48d0a19e76b1e53.mp3"></audio>
你好,我是王喆。今天,我们来解决系统特征的存储问题。
在特征工程篇我们说过,在推荐系统这个大饭馆中,特征工程就是负责配料和食材的厨师,那我们上堂课搭建的推荐服务器就是准备做菜的大厨。配料和食材准备好了,做菜的大厨也已经开火热锅了,这时候我们得把食材及时传到大厨那啊。这个传菜的过程就是推荐系统特征的存储和获取过程。
可是我们知道类似Embedding这样的特征是在离线环境下生成的而推荐服务器是在线上环境中运行的那这些**离线的特征数据是如何导入到线上让推荐服务器使用的呢?**
今天我们先以Netflix的推荐系统架构为例来讲一讲存储模块在整个系统中的位置再详细来讲推荐系统存储方案的设计原则最后以Redis为核心搭建起Sparrow Recsys的存储模块。
## 推荐系统存储模块的设计原则
你还记得,我曾在[第1讲的课后题](https://time.geekbang.org/column/article/288917)中贴出过Netflix推荐系统的架构图如图1Netflix采用了非常经典的Offline、Nearline、Online三层推荐系统架构。架构图中最核心的位置就是我在图中用红框标出的部分它们是三个数据库Cassandra、MySQL和EVcache这三个数据库就是Netflix解决特征和模型参数存储问题的钥匙。
<img src="https://static001.geekbang.org/resource/image/bc/ca/bc6d770cb20dfc90cc07168d626fd7ca.jpg" alt="" title="图1 Netflix推荐系统架构中的特征与模型数据库">
你可能会觉得存储推荐特征和模型这件事情一点儿都不难啊。不就是找一个数据库把离线的特征存起来然后再给推荐服务器写几个SQL让它取出来用不就行了吗为什么还要像Netflix这样兴师动众地搞三个数据库呢
想要搞明白这个问题我们就得搞清楚设计推荐系统存储模块的原则。对于推荐服务器来说由于线上的QPS压力巨大每次有推荐请求到来推荐服务器都需要把相关的特征取出。这就要求推荐服务器一定要“快”。
不仅如此对于一个成熟的互联网应用来说它的用户数和物品数一定是巨大的几千万上亿的规模是十分常见的。所以对于存储模块来说这么多用户和物品特征所需的存储量会特别大。这个时候事情就很难办了又要存储量大又要查询快还要面对高QPS的压力。很不幸没有一个独立的数据库能**经济又高效**地单独完成这样复杂的任务。
因此,几乎所有的工业级推荐系统都会做一件事情,就是把特征的存储做成分级存储,把越频繁访问的数据放到越快的数据库甚至缓存中,把海量的全量数据放到便宜但是查询速度较慢的数据库中。
举个不恰当的例子如果你把特征数据放到基于HDFS的HBase中虽然你可以轻松放下所有的特征数据但要让你的推荐服务器直接访问HBase进行特征查询等到查询完成这边用户的请求早就超时中断了而Netflix的三个数据库正好满足了这样分级存储的需求。
<img src="https://static001.geekbang.org/resource/image/03/78/0310b59276fde9eeec5d9cd946fef078.jpeg" alt="" title="图2 分级存储的设计">
比如说Netflix使用的Cassandra它作为流行的NoSQL数据库具备大数据存储的能力但为支持推荐服务器高QPS的需求我们还需要把最常用的特征和模型参数存入EVcache这类内存数据库。而对于更常用的数据我们可以把它们存储在Guava Cache等服务器内部缓存甚至是服务器的内存中。总之对于一个工程师来说我们经常需要做出技术上的权衡达成一个在花销和效果上平衡最优的技术方案。
而对于MySQL来说由于它是一个强一致性的关系型数据库一般存储的是比较关键的要求强一致性的信息比如物品是否可以被推荐这种控制类的信息物品分类的层级关系用户的注册信息等等。这类信息一般是由推荐服务器进行阶段性的拉取或者利用分级缓存进行阶段性的更新避免因为过于频繁的访问压垮MySQL。
总的来说,推荐系统存储模块的设计原则就是“**分级存储,把越频繁访问的数据放到越快的数据库甚至缓存中,把海量的全量数据放到廉价但是查询速度较慢的数据库中**”。
## SparrowRecsys的存储系统方案
那在我们要实现的SparrowRecsys中存储模块的设计原则又是怎么应用的呢
在SparrowRecsys中我们把存储模块的设计问题进行了一些简化避免由于系统设计得过于复杂导致你不易上手。
我们使用基础的文件系统保存全量的离线特征和模型数据用Redis保存线上所需特征和模型数据使用服务器内存缓存频繁访问的特征。
在实现技术方案之前,对于问题的整体分析永远都是重要的。我们需要先确定具体的存储方案,这个方案必须精确到哪级存储对应哪些具体特征和模型数据。
存储的工具已经知道了那特征和模型数据分别是什么呢这里我们直接应用特征工程篇为SparrowRecsys准备好的一些特征就可以了。我把它们的具体含义和数据量级整理成了表格如下
<img src="https://static001.geekbang.org/resource/image/d9/2a/d9cf4b8899ff4442bc7cd87f502a9c2a.jpeg" alt="" title="图3 特征和模型数据">
根据上面的特征数据我们一起做一个初步的分析。首先用户特征的总数比较大它们很难全部载入到服务器内存中所以我们把用户特征载入到Redis之类的内存数据库中是合理的。其次物品特征的总数比较小而且每次用户请求一般只会用到一个用户的特征但为了物品排序推荐服务器需要访问几乎所有候选物品的特征。针对这个特点我们完全可以把所有物品特征阶段性地载入到服务器内存中大大减少Redis的线上压力。
最后我们还要找一个地方去存储特征历史数据、样本数据等体量比较大但不要求实时获取的数据。这个时候分布式文件系统单机环境下以本机文件系统为例往往是最好的选择由于类似HDFS之类的分布式文件系统具有近乎无限的存储空间我们可以把每次处理的全量特征每次训练的Embedding全部保存到分布式文件系统中方便离线评估时使用。
经过上面的分析,我们就得到了具体的存储方案,如下表:
<img src="https://static001.geekbang.org/resource/image/34/63/34958066e8704ea2780d7f8007e18463.jpeg" alt="" title="图4 SparrowRecsys的存储方案">
此外文件系统的存储操作非常简单在SparrowRecsys中就是利用Spark的输出功能实现的我们就不再重点介绍了。而服务器内部的存储操作主要是跟Redis进行交互所以接下来我们重点介绍Redis的特性以及写入和读取方法。
## 你需要知道的Redis基础知识
Redis是当今业界最主流的内存数据库那在使用它之前我们应该清楚Redis的两个主要特点。
**一是所有的数据都以Key-value的形式存储。** 其中Key只能是字符串value可支持的数据结构包括string(字符串)、list(链表)、set(集合)、zset(有序集合)和hash(哈希)。这个特点决定了Redis的使用方式无论是存储还是获取都应该以键值对的形式进行并且根据你的数据特点设计值的数据结构。
**二是所有的数据都存储在内存中,磁盘只在持久化备份或恢复数据时起作用**。这个特点决定了Redis的特性一是QPS峰值可以很高二是数据易丢失所以我们在维护Redis时要充分考虑数据的备份问题或者说不应该把关键的业务数据唯一地放到Redis中。但对于可恢复不关乎关键业务逻辑的推荐特征数据就非常适合利用Redis提供高效的存储和查询服务。
在实际的Sparrow Recsys的Redis部分中我们用到了Redis最基本的操作set、get和keysvalue的数据类型用到了string。
## Sparrow Recsys中的Redis部分的实践流程
Redis的实践流程还是符合我们“把大象装冰箱”的三部曲只不过这三步变成了安装Redis把数据写进去把数据读出来。下面我们来逐一来讲。
**首先是安装Redis。** Redis的安装过程在linux/Unix环境下非常简单你参照[官方网站的步骤](http://www.redis.cn/download.html)依次执行就好。Windows环境下的安装过程稍复杂一些你可以参考[这篇文章](https://www.cnblogs.com/liuqingzheng/p/9831331.html)进行安装。
在启动Redis之后如果没有特殊的设置Redis服务会默认运行在6379端口没有特殊情况保留这个默认的设置就可以了因为我们的Sparrow RecSys也是默认从6379端口存储和读取Redis数据的。
**然后是运行离线程序通过jedis客户端写入Redis。** 在Redis运行起来之后我们就可以在离线Spark环境下把特征数据写入Redis。这里我们以[第8讲([https://time.geekbang.org/column/article/296932](https://time.geekbang.org/column/article/296932))中生成的Embedding数据为例来实现Redis的特征存储过程。
实际的过程非常简单首先我们利用最常用的Redis Java客户端Jedis生成redisClient然后遍历训练好的Embedding向量将Embedding向量以字符串的形式存入Redis并设置过期时间ttl。具体实现请参考下面的代码代码参考com.wzhe.sparrowrecsys.offline.spark.featureeng.Embedding 中的trainItem2vec函数
```
if (saveToRedis) {
//创建redis client
val redisClient = new Jedis(redisEndpoint, redisPort)
val params = SetParams.setParams()
//设置ttl为24小时
params.ex(60 * 60 * 24)
//遍历存储embedding向量
for (movieId &lt;- model.getVectors.keys) {
//key的形式为前缀+movieId例如i2vEmb:361
//value的形式是由Embedding向量生成的字符串例如 &quot;0.1693846 0.2964318 -0.13044095 0.37574086 0.55175656 0.03217995 1.327348 -0.81346786 0.45146862 0.49406642&quot;
redisClient.set(redisKeyPrefix + &quot;:&quot; + movieId, model.getVectors(movieId).mkString(&quot; &quot;), params)
}
//关闭客户端连接
redisClient.close()
}
```
**最后是在推荐服务器中把Redis数据读取出来。**
在服务器端根据刚才梳理出的存储方案我们希望服务器能够把所有物品Embedding阶段性地全部缓存在服务器内部用户Embedding则进行实时查询。这里我把缓存物品Embedding的代码放在了下面。
你可以看到它的实现的过程也并不复杂就是先用keys操作把所有物品Embedding前缀的键找出然后依次将Embedding载入内存。
```
//创建redis client
Jedis redisClient = new Jedis(REDIS_END_POINT, REDIS_PORT);
//查询出所有以embKey为前缀的数据
Set&lt;String&gt; movieEmbKeys = redisClient.keys(embKey + &quot;*&quot;);
int validEmbCount = 0;
//遍历查出的key
for (String movieEmbKey : movieEmbKeys){
String movieId = movieEmbKey.split(&quot;:&quot;)[1];
Movie m = getMovieById(Integer.parseInt(movieId));
if (null == m) {
continue;
}
//用redisClient的get方法查询出key对应的value再set到内存中的movie结构中
m.setEmb(parseEmbStr(redisClient.get(movieEmbKey)));
validEmbCount++;
}
redisClient.close();
```
这样一来在具体为用户推荐的过程中我们再利用相似的接口查询出用户的Embedding与内存中的Embedding进行相似度的计算就可以得到最终的推荐列表了。
如果你已经安装好了Redis我非常推荐你运行SparrowRecsys中Offline部分Embedding主函数先把物品和用户Embedding生成并且插入Redis注意把saveToRedis变量改为true。然后再运行Online部分的RecSysServer看一下推荐服务器有没有正确地从Redis中读出物品和用户Embedding并产生正确的推荐结果注意记得要把util.Config中的EMB_DATA_SOURCE配置改为DATA_SOURCE_REDIS
当然除了Redis我们还提到了多种不同的缓存和数据库如Cassandra、EVcache、GuavaCache等等它们都是业界非常流行的存储特征的工具你有兴趣的话也可以在课后查阅相关资料进行进一步的学习。在掌握了我们特征存储的基本原则之后你也可以在业余时间尝试思考一下每个数据库的不同和它们最合适的应用场景。
## 小结
今天我们学习了推荐系统存储模块的设计原则和具体的解决方案并且利用Sparrow Recsys进行了实战。
在设计推荐系统存储方案时我们一般要遵循“分级存储”的原则在开销和性能之间取得权衡。在Sparrow Recsys的实战中我们安装并操作了内存数据库Redis你要记住Redis的特点“Key-value形式存储”和“纯内存数据库”。在具体的特征存取过程中我们应该熟悉利用jedis执行SETGET等Redis常用操作的方法。
最后,我也把重要的知识点总结在了下面,你可以再回顾一下。
<img src="https://static001.geekbang.org/resource/image/5f/08/5f76090e7742593928eaf118d72d2b08.jpeg" alt="">
对于搭建一套完整的推荐服务来说我们已经迈过了两大难关分别是用Jetty Server搭建推荐服务器问题以及用Redis解决特征存储的问题。下节课我们会一起来挑战线上服务召回层的设计。
## 课后思考
你觉得课程中存储Embedding的方式还有优化的空间吗除了string我们是不是还可以用其他Redis value的数据结构存储Embedding数据那从效率的角度考虑使用string和使用其他数据结构的优缺点有哪些为什么
欢迎把你的思考和答案写在留言区,也欢迎你把这节课分享给你的朋友,我们下节课见!

View File

@@ -0,0 +1,195 @@
<audio id="audio" title="11 | 召回层:如何快速又准确地筛选掉不相关物品?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e5/e5/e5b2a2a0da3c6cf28fec224d0e9677e5.mp3"></audio>
你好,我是王喆。今天,我们来一起学习推荐系统中非常重要的一个模块,召回层。
为了弄清楚召回层是什么,我们先试着解决一下这个问题:**如果你是一名快手的推荐工程师你的任务是从500万个候选短视频中为一名用户推荐10个他最感兴趣的。你会怎么做**
我想最直接最暴力的做法就是对这500万个短视频挨个打分、排序取出得分最高的10个推荐给用户。如果这个打分的算法非常靠谱的话我们肯定能够选出用户最感兴趣的Top 10视频。但这个过程会涉及一个非常棘手的工程问题如果利用比较复杂的推荐模型特别是深度学习推荐模型对500万个短视频打分这个过程是非常消耗计算资源的。
而且你要知道,这还只是计算了一个用户的推荐结果,在工业级的线上服务中,每秒可是有几十万甚至上百万的用户同时请求服务器,逐个候选视频打分产生的计算量,是任何集群都承受不了的。
那**在推荐物品候选集规模非常大的时候,我们该如何快速又准确地筛选掉不相关物品,从而节约排序时所消耗的计算资源呢?**这其实就是推荐系统召回层要解决的问题。今天,我就从三个召回层技术方案入手,带你一起来解决这个问题。
## 召回层和排序层的功能特点
在前面的课程中我提到过学习推荐系统的一个主要原则,那就是“深入细节,不忘整体”。对于召回层,我们也应该清楚它在推荐系统架构中的位置。
从技术架构的角度来说,“召回层”处于推荐系统的**线上服务模块**之中,推荐服务器从数据库或内存中拿到所有候选物品集合后,会依次经过召回层、排序层、再排序层(也被称为补充算法层),才能够产生用户最终看到的推荐列表。既然线上服务需要这么多“层”才能产生最终的结果,不同层之间的功能特点有什么区别呢?
<img src="https://static001.geekbang.org/resource/image/b1/6b/b1fd054eb2bbe0ec1237fc316byye66b.jpeg" alt="" title="图1 推荐系统的召回和排序阶段及其特点">
其实从这节课开头的问题出发,你应该已经对召回层和排序层的功能特点有了初步的认识,召回层就是要**快速**、准确地过滤出相关物品,缩小候选集,排序层则要以提升推荐效果为目标,作出精准的推荐列表排序。
再详细一点说,我们可以从候选集规模、模型复杂程度、特征数量、处理速度、排序精度等几个角度来对比召回层和排序层的特点:
<img src="https://static001.geekbang.org/resource/image/55/7e/5535a3d83534byy54ab201e865ec4a7e.jpeg" alt="" title="图2 召回层和排序层的特点">
需要注意的是,在我们设计召回层时,计算速度和召回率其实是两个矛盾的指标。怎么理解呢?比如说,为了提高计算速度,我们需要使召回策略尽量简单,而为了提高召回率或者说召回精度,让召回策略尽量把用户感兴趣的物品囊括在内,这又要求召回策略不能过于简单,否则召回物品就无法满足排序模型的要求。
推荐工程师们就是在这样的矛盾中逐渐做出新的尝试推动着召回层的设计方案不断向前发展。下面我们就详细学习一下三个主要的召回方法以及它们基于SparrowRecSys的代码实现。
## 如何理解“单策略召回”方法?
你会发现,今天我多次提到一个关键字,快。那怎么才能让召回层“快”起来呢?我们知道,排序层慢的原因是模型复杂,算法计算量大,那我们能不能反其道而行之,用一些简单直观的策略来实现召回层呢?当然是可以的,这就是所谓的**单策略召回**。
**单策略召回指的是,通过制定一条规则或者利用一个简单模型来快速地召回可能的相关物品。** 这里的规则其实就是用户可能感兴趣的物品的特点我们拿SparrowRecSys里面的电影推荐为例。在推荐电影的时候我们首先要想到用户可能会喜欢什么电影。按照经验来说很有可能是这三类分别是大众口碑好的、近期非常火热的以及跟我之前喜欢的电影风格类似的。
基于其中任何一条我们都可以快速实现一个单策略召回层。比如在SparrowRecSys中我就制定了这样一条召回策略如果用户对电影A的评分较高比如超过4分那么我们就将与A风格相同并且平均评分在前50的电影召回放入排序候选集中。
基于这条规则,我实现了如下的召回层:
```
//详见SimilarMovieFlow class
public static List&lt;Movie&gt; candidateGenerator(Movie movie){
ArrayList&lt;Movie&gt; candidates = new ArrayList&lt;&gt;();
//使用HashMap去重
HashMap&lt;Integer, Movie&gt; candidateMap = new HashMap&lt;&gt;();
//电影movie包含多个风格标签
for (String genre : movie.getGenres()){
//召回策略的实现
List&lt;Movie&gt; oneCandidates = DataManager.getInstance().getMoviesByGenre(genre, 100, &quot;rating&quot;);
for (Movie candidate : oneCandidates){
candidateMap.put(candidate.getMovieId(), candidate);
}
}
//去掉movie本身
if (candidateMap.containsKey(movie.getMovieId())){
candidateMap.remove(movie.getMovieId());
}
//最终的候选集
return new ArrayList&lt;&gt;(candidateMap.values());
}
```
单策略召回是非常简单直观的,正因为简单,所以它的计算速度一定是非常快的。但我想你应该也发现了其中的问题,就是它有很强的局限性。因为大多数时候用户的兴趣是非常多元的,他们不仅喜欢自己感兴趣的,也喜欢热门的,当然很多时候也喜欢新上映的。这时候,单一策略就难以满足用户的潜在需求了,那有没有更全面的召回策略呢?
## 如何理解“多路召回”方法
为了让召回的结果更加全面,多路召回方法应运而生了。
**所谓“多路召回策略”,就是指采用不同的策略、特征或简单模型,分别召回一部分候选集,然后把候选集混合在一起供后续排序模型使用的策略。**
其中,各简单策略保证候选集的快速召回,从不同角度设计的策略又能保证召回率接近理想的状态,不至于损害排序效果。所以,多路召回策略是在计算速度和召回率之间进行权衡的结果。
这里我们还是以电影推荐为例来做进一步的解释。下面是我给出的电影推荐中常用的多路召回策略包括热门电影、风格类型、高分评价、最新上映以及朋友喜欢等等。除此之外我们也可以把一些推断速度比较快的简单模型比如逻辑回归协同过滤等生成的推荐结果放入多路召回层中形成综合性更好的候选集。具体的操作过程就是我们分别执行这些策略让每个策略选取Top K个物品最后混合多个Top K物品就形成了最终的多路召回候选集。整个过程就如下所示
<img src="https://static001.geekbang.org/resource/image/c6/e6/c6cdccbb76a85f9d1bbda5c0e030dee6.jpeg" alt="" title="图3 常见的多路召回策略">
在SparrowRecsys中我们就实现了由风格类型、高分评价、最新上映这三路召回策略组成的多路召回方法具体代码如下
```
public static List&lt;Movie&gt; multipleRetrievalCandidates(List&lt;Movie&gt; userHistory){
HashSet&lt;String&gt; genres = new HashSet&lt;&gt;();
//根据用户看过的电影,统计用户喜欢的电影风格
for (Movie movie : userHistory){
genres.addAll(movie.getGenres());
}
//根据用户喜欢的风格召回电影候选集
HashMap&lt;Integer, Movie&gt; candidateMap = new HashMap&lt;&gt;();
for (String genre : genres){
List&lt;Movie&gt; oneCandidates = DataManager.getInstance().getMoviesByGenre(genre, 20, &quot;rating&quot;);
for (Movie candidate : oneCandidates){
candidateMap.put(candidate.getMovieId(), candidate);
}
}
//召回所有电影中排名最高的100部电影
List&lt;Movie&gt; highRatingCandidates = DataManager.getInstance().getMovies(100, &quot;rating&quot;);
for (Movie candidate : highRatingCandidates){
candidateMap.put(candidate.getMovieId(), candidate);
}
//召回最新上映的100部电影
List&lt;Movie&gt; latestCandidates = DataManager.getInstance().getMovies(100, &quot;releaseYear&quot;);
for (Movie candidate : latestCandidates){
candidateMap.put(candidate.getMovieId(), candidate);
}
//去除用户已经观看过的电影
for (Movie movie : userHistory){
candidateMap.remove(movie.getMovieId());
}
//形成最终的候选集
return new ArrayList&lt;&gt;(candidateMap.values());
}
```
在实现的过程中,为了进一步优化召回效率,我们还可以通过多线程并行、建立标签/特征索引、建立常用召回集缓存等方法来进一步完善它。
不过多路召回策略虽然能够比较全面地照顾到不同的召回方法但也存在一些缺点。比如在确定每一路的召回物品数量时往往需要大量的人工参与和调整具体的数值需要经过大量线上AB测试来决定。此外因为策略之间的信息和数据是割裂的所以我们很难综合考虑不同策略对一个物品的影响。
那么,是否存在一个综合性强且计算速度也能满足需求的召回方法呢?
## 基于Embedding的召回方法
在[第5讲](https://time.geekbang.org/column/article/295300)和[第6讲](https://time.geekbang.org/column/article/295939)中我们已经介绍了多种离线生成物品Embedding的方案。事实上利用物品和用户Embedding相似性来构建召回层是深度学习推荐系统中非常经典的技术方案。我们可以把它的优势总结为三方面。
一方面多路召回中使用的“兴趣标签”“热门度”“流行趋势”“物品属性”等信息都可以作为Embedding方法中的附加信息Side Information融合进最终的Embedding向量中 。因此在利用Embedding召回的过程中我们就相当于考虑到了多路召回的多种策略。
另一方面Embedding召回的评分具有连续性。我们知道多路召回中不同召回策略产生的相似度、热度等分值不具备可比性所以我们无法据此来决定每个召回策略放回候选集的大小。但是Embedding召回却可以把Embedding间的相似度作为唯一的判断标准因此它可以随意限定召回的候选集大小。
最后在线上服务的过程中Embedding相似性的计算也相对简单和直接。通过简单的点积或余弦相似度的运算就能够得到相似度得分便于线上的快速召回。
在SparrowRecsys中我们也实现了基于Embedding的召回方法。我具体代码放在下面你可以参考一下。
```
public static List&lt;Movie&gt; retrievalCandidatesByEmbedding(User user){
if (null == user){
return null;
}
//获取用户embedding向量
double[] userEmbedding = DataManager.getInstance().getUserEmbedding(user.getUserId(), &quot;item2vec&quot;);
if (null == userEmbedding){
return null;
}
//获取所有影片候选集(这里取评分排名前10000的影片作为全部候选集)
List&lt;Movie&gt; allCandidates = DataManager.getInstance().getMovies(10000, &quot;rating&quot;);
HashMap&lt;Movie,Double&gt; movieScoreMap = new HashMap&lt;&gt;();
//逐一获取电影embedding并计算与用户embedding的相似度
for (Movie candidate : allCandidates){
double[] itemEmbedding = DataManager.getInstance().getItemEmbedding(candidate.getMovieId(), &quot;item2vec&quot;);
double similarity = calculateEmbeddingSimilarity(userEmbedding, itemEmbedding);
movieScoreMap.put(candidate, similarity);
}
List&lt;Map.Entry&lt;Movie,Double&gt;&gt; movieScoreList = new ArrayList&lt;&gt;(movieScoreMap.entrySet());
//按照用户-电影embedding相似度进行候选电影集排序
movieScoreList.sort(Map.Entry.comparingByValue());
//生成并返回最终的候选集
List&lt;Movie&gt; candidates = new ArrayList&lt;&gt;();
for (Map.Entry&lt;Movie,Double&gt; movieScoreEntry : movieScoreList){
candidates.add(movieScoreEntry.getKey());
}
return candidates.subList(0, Math.min(candidates.size(), size));
}
```
这里我再带你简单梳理一下整体的实现思路。总的来说我们通过三步生成了最终的候选集。第一步我们获取用户的Embedding。第二步我们获取所有物品的候选集并且逐一获取物品的Embedding计算物品Embedding和用户Embedding的相似度。第三步我们根据相似度排序返回规定大小的候选集。
在这三步之中最主要的时间开销在第二步虽然它的时间复杂度是线性的但当物品集过大时比如达到了百万以上的规模线性的运算也可能造成很大的时间开销。那有没有什么方法能进一步缩小Embedding召回层的运算时间呢这个问题我们留到下节课来讨论。
## 小结
今天我们一起讨论了推荐系统中召回层的功能特点和实现方法。并且重点讲解了单策略召回、多路召回以及深度学习推荐系统中常用的基于Embedding的召回。
为了方便你对比它们之间的技术特点,我总结了一张表格放在了下面,你可以看一看。
<img src="https://static001.geekbang.org/resource/image/2f/80/2fc1eyyefd964f7b65715de6f896c480.jpeg" alt="">
总的来说,关于召回层的重要内容,我总结成了**一个特点,三个方案**。
特点就是召回层的功能特点召回层要快速准确地过滤出相关物品缩小候选集。三个方案指的是实现召回层的三个技术方案简单快速的单策略召回、业界主流的多路召回、深度学习推荐系统中最常用的Embedding召回。
这三种方法基本囊括了现在业界推荐系统的主流召回方法,希望通过这节课的学习,你能掌握这一关键模块的实现方法。
相信你也一定发现了,召回层技术的发展是循序渐进的,因此我希望你不仅能够学会应用它们,更能够站在前人的技术基础上,进一步推进它的发展,这也是工程师这份职业最大的魅力。
## 课后思考
1. 你能根据我今天讲的内容在SparrowRecsys中实现一个多线程版本的多路召回策略吗
1. 你觉得对于Embedding召回来说我们怎么做才能提升计算Embedding相似度的速度
你理解的召回层也是这样吗?欢迎把你的思考和答案写在留言区。如果有收获,我也希望你能把这节课分享给你的朋友们。

View File

@@ -0,0 +1,170 @@
<audio id="audio" title="12 | 局部敏感哈希如何在常数时间内搜索Embedding最近邻" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/66/c8/66de9a763d1f86cf617a0006f01371c8.mp3"></audio>
你好,我是王喆。
在深度学习推荐系统中我们经常采用Embedding召回这一准确又便捷的方法。但是在面对百万甚至更高量级的候选集时线性地逐一计算Embedding间的相似度往往会造成极大的服务延迟。
这个时候,我们要解决的问题就是,**如何快速找到与一个Embedding最相似的Embedding**这直接决定了召回层的执行速度,进而会影响推荐服务器的响应延迟。
今天我们就一起来学习一下业界解决近似Embedding搜索的主要方法局部敏感哈希。
## 推荐系统中的“快速”Embedding最近邻搜索问题
在深度学习推荐系统中我们经常会使用Embedding方法对物品和用户进行向量化。在训练物品和用户的Embedding向量时如果二者的Embedding在同一个向量空间内如图1我们就可以通过内积、余弦、欧式距离等相似度计算方法来计算它们之间的相似度从而通过用户-物品相似度进行个性化推荐,或者通过物品-物品相似度进行相似物品查找。
<img src="https://static001.geekbang.org/resource/image/7f/54/7f7f9647565848d0d530d27d96927654.jpeg" alt="" title="图1 用户和电影的Embedding向量空间">
假设用户和物品的Embeding都在一个$k$维的Embedding空间中物品总数为$n$,那么遍历计算一个用户和所有物品向量相似度的时间复杂度是多少呢?不难算出是$O(k×n)$。虽然这一复杂度是线性的,但物品总数$n$达到百万甚至千万量级时,线性的时间复杂度也是线上服务不能承受的。
换一个角度思考这个问题由于用户和物品的Embedding同处一个向量空间内因此**召回与用户向量最相似的物品Embedding向量这一问题其实就是在向量空间内搜索最近邻的过程**。如果我们能够找到高维空间快速搜索最近邻点的方法那么相似Embedding的快速搜索问题就迎刃而解了。
## 使用“聚类”还是“索引”来搜索最近邻?
遇到最近邻搜索的问题,我想大部分同学直觉上肯定会想到两种解决方案,**一种是聚类**,我们把相似的点聚类到一起,不就可以快速地找到彼此间的最近邻了吗?**另一种是索引**,比如,我们通过某种数据结构建立基于向量距离的索引,在查找最近邻的时候,通过索引快速缩小范围来降低复杂度。这两种想法可不可行呢?我们一一尝试一下。
对于聚类问题我想最经典的算法当属K-means。它完成聚类的过程主要有以下几步
1. 随机指定k个中心点
1. 每个中心点代表一个类,把所有的点按照距离的远近指定给距离最近的中心点代表的类;
1. 计算每个类包含点的平均值作为新的中心点位置;
1. 确定好新的中心点位置后迭代进入第2步直到中心点位置收敛不再移动。
到这里整个K-means的迭代更新过程就完成了你可以看下图2。
<img src="https://static001.geekbang.org/resource/image/5d/90/5d93557a390be7dabc82ffdd6baebc90.jpeg" alt="" title="图2 三中心点的K-means算法迭代过程">
如果我们能够在离线计算好每个Embedding向量的类别在线上我们只需要在同一个类别内的Embedding向量中搜索就可以了这会大大缩小了Embedding的搜索范围时间复杂度自然就下降了。
但这个过程还是存在着一些边界情况。比如聚类边缘的点的最近邻往往会包括相邻聚类的点如果我们只在类别内搜索就会遗漏这些近似点。此外中心点的数量k也不那么好确定k选得太大离线迭代的过程就会非常慢k选得太小在线搜索的范围还是很大并没有减少太多搜索时间。所以基于聚类的搜索还是有一定局限性的解决上面的问题也会增加过多冗余过程得不偿失。
既然聚类有局限性那索引能不能奏效呢我们这里可以尝试一下经典的向量空间索引方法Kd-treeK-dimension tree。与聚类不同它是为空间中的点/向量建立一个索引。这该怎么理解呢?
举个例子你可以看下图3中的点云我们先用红色的线把点云一分为二再用深蓝色的线把各自片区的点云一分为二以此类推直到每个片区只剩下一个点这就完成了空间索引的构建。如果我们能够把这套索引“搬”到线上就可以利用二叉树的结构快速找到邻接点。比如希望找到点q的m个邻接点我们就可以先搜索它相邻子树下的点如果数量不够我们可以向上回退一个层级搜索它父片区下的其他点直到数量凑够m个为止。
<img src="https://static001.geekbang.org/resource/image/df/3f/dfb2c271d9eaa3a29054d2aea24b5e3f.jpeg" alt="" title="图3 Kd-tree索引">
听上去Kd-tree索引似乎是一个完美的方案但它还是无法完全解决边缘点最近邻的问题。对于点q来说它的邻接片区是右上角的片区但是它的最近邻点却是深蓝色切分线下方的那个点。所以按照Kd-tree的索引方法我们还是会遗漏掉最近邻点它只能保证快速搜索到近似的最近邻点集合。而且Kd-tree索引的结构并不简单离线和在线维护的过程也相对复杂这些都是它的弊端。那有没有更“完美”的解决方法呢
## 局部敏感哈希的基本原理及多桶策略
为了“拯救”我们推荐系统的召回层“局部敏感哈希”Locality Sensitive Hashing,LSH这一方法横空出世它用简洁而高效的方法几乎完美地解决了这一问题。那它是怎么做到的呢
### 1. 局部敏感哈希的基本原理
局部敏感哈希的基本思想是希望让相邻的点落入同一个“桶”,这样在进行最近邻搜索时,我们仅需要在一个桶内,或相邻几个桶内的元素中进行搜索即可。如果保持每个桶中的元素个数在一个常数附近,我们就可以把最近邻搜索的时间复杂度降低到常数级别。
那么,如何构建局部敏感哈希中的“桶”呢?下面,我们以基于欧式距离的最近邻搜索为例,来解释构建局部敏感哈希“桶”的过程。
首先我们要弄清楚一个问题如果将高维空间中的点向低维空间进行映射其欧式相对距离是不是会保持不变呢以图4为例图4中间的彩色点处在二维空间中当我们把二维空间中的点通过不同角度映射到a、b、c这三个一维空间时可以看到原本相近的点在一维空间中都保持着相近的距离。而原本远离的绿色点和红色点在一维空间a中处于接近的位置却在空间b中处于远离的位置。
因此我们可以得出一个定性的结论:**欧式空间中,将高维空间的点映射到低维空间,原本接近的点在低维空间中肯定依然接近,但原本远离的点则有一定概率变成接近的点。**
<img src="https://static001.geekbang.org/resource/image/d9/55/d9476e92e9a6331274e18abc416db955.jpeg" alt="" title="图4 高维空间点向低维空间映射">
利用低维空间可以保留高维空间相近距离关系的性质我们就可以构造局部敏感哈希“桶”。对于Embedding向量来说由于Embedding大量使用内积操作计算相似度因此我们也可以用内积操作来构建局部敏感哈希桶。假设$v$是高维空间中的k维Embedding向量$x$是随机生成的k维映射向量。那我们利用内积操作可以将$v$映射到一维空间,得到数值$h(v)=v·x$。
而且,我们刚刚说了,一维空间也会部分保存高维空间的近似距离信息。因此,我们可以使用哈希函数$h(v)$进行分桶,公式为:$h^{x, b}(v)=\left\lfloor\frac{x \cdot v+b}{w}\right]$ 。其中, ⌊⌋ 是向下取整操作, $w$是分桶宽度,$b$是0到w间的一个均匀分布随机变量避免分桶边界固化。
不过映射操作会损失部分距离信息如果我们仅采用一个哈希函数进行分桶必然存在相近点误判的情况因此我们可以采用m个哈希函数同时进行分桶。如果两个点同时掉进了m个桶那它们是相似点的概率将大大增加。通过分桶找到相邻点的候选集合后我们就可以在有限的候选集合中通过遍历找到目标点真正的K近邻了。
刚才我们讲的哈希策略是基于内积操作来制定的,内积相似度也是我们经常使用的相似度度量方法,事实上距离的定义有很多种,比如“曼哈顿距离”“切比雪夫距离”“汉明距离”等等。针对不同的距离定义,分桶函数的定义也有所不同,但局部敏感哈希通过分桶方式保留部分距离信息,大规模降低近邻点候选集的本质思想是通用的。
### 2. 局部敏感哈希的多桶策略
刚才我们讲到了可以使用多个分桶函数的方式来增加找到相似点的概率。那你可能有疑问,如果有多个分桶函数的话,具体应该如何处理不同桶之间的关系呢?这就涉及局部敏感哈希的多桶策略。
假设有A、B、C、D、E五个点有h<sub>1</sub>和h<sub>2</sub>两个分桶函数。使用h<sub>1</sub>来分桶时A和B掉到了一个桶里C、D、E掉到了一个桶里使用h<sub>2</sub>来分桶时A、C、D掉到了一个桶里B、E在一个桶。那么请问如果我们想找点C的最近邻点应该怎么利用两个分桶结果来计算呢
如果我们用“且”And操作来处理两个分桶结果之间的关系那么结果是这样的找到与点C在h<sub>1</sub>函数下同一个桶的点且在h<sub>2</sub>函数下同一个桶的点作为最近邻候选点。我们可以看到满足条件的点只有一个那就是点D。也就是说点D最有可能是点C的最近邻点。
用“且”操作作为多桶策略可以最大程度地减少候选点数量。但是由于哈希分桶函数不是一个绝对精确的操作点D也只是最有可能的最近邻点不是一定的最近邻点因此“且”操作其实也增大了漏掉最近邻点的概率。
那如果我们采用“或”Or操作作为多桶策略又会是什么情况呢具体操作就是我们找到与点C在h<sub>1</sub>函数下同一个桶的点或在h<sub>2</sub>函数下同一个桶的点。这个时候我们可以看到候选集中会有三个点分别是A、D、E。这样一来虽然我们增大了候选集的规模减少了漏掉最近邻点的可能性但增大了后续计算的开销。
当然局部敏感哈希的多桶策略还可以更加复杂比如使用3个分桶函数分桶把同时落入两个桶的点作为最近邻候选点等等。
那么,我们到底应该选择“且”操作还是“或”操作,以及到底该选择使用几个分桶函数,每个分桶函数分几个桶呢?这些都还是工程上的权衡问题。我虽然不能给出具体的最佳数值,但可以给你一些取值的建议:
1. 点数越多,我们越应该增加每个分桶函数中桶的个数;相反,点数越少,我们越应该减少桶的个数;
1. Embedding向量的维度越大我们越应该增加哈希函数的数量尽量采用且的方式作为多桶策略相反Embedding向量维度越小我们越应该减少哈希函数的数量多采用或的方式作为分桶策略。
最后,我们再回头来解决课程开头提出的问题,局部敏感哈希能在常数时间得到最近邻的结果吗?答案是可以的,如果我们能够精确地控制每个桶内的点的规模是$C$假设每个Embedding的维度是$N$,那么找到最近邻点的时间开销将永远在$O(C·N)$量级。采用多桶策略之后,假设分桶函数数量是$K$,那么时间开销也在$O(K·C·N)$量级,这仍然是一个常数。
## 局部敏感哈希实践
现在我们已经知道了局部敏感哈希的基本原理和多桶策略接下来我们一起进入实践环节利用Sparrow Recsys训练好的物品Embedding来实现局部敏感哈希的快速搜索吧。为了保证跟Embedding部分的平台统一这一次我们继续使用Spark MLlib完成LSH的实现。
在将电影Embedding数据转换成dense Vector的形式之后我们使用Spark MLlib自带的LSH分桶模型BucketedRandomProjectionLSH我们简称LSH模型来进行LSH分桶。其中最关键的部分是设定LSH模型中的BucketLength和NumHashTables这两个参数。其中BucketLength指的就是分桶公式中的分桶宽度wNumHashTables指的是多桶策略中的分桶次数。
清楚了模型中的关键参数执行的过程就跟我们讲过的其他Spark MLlib模型一样了都是先调用fit函数训练模型再调用transform函数完成分桶的过程具体的实现你可以参考下面的代码。
```
def embeddingLSH(spark:SparkSession, movieEmbMap:Map[String, Array[Float]]): Unit ={
//将电影embedding数据转换成dense Vector的形式便于之后处理
val movieEmbSeq = movieEmbMap.toSeq.map(item =&gt; (item._1, Vectors.dense(item._2.map(f =&gt; f.toDouble))))
val movieEmbDF = spark.createDataFrame(movieEmbSeq).toDF(&quot;movieId&quot;, &quot;emb&quot;)
//利用Spark MLlib创建LSH分桶模型
val bucketProjectionLSH = new BucketedRandomProjectionLSH()
.setBucketLength(0.1)
.setNumHashTables(3)
.setInputCol(&quot;emb&quot;)
.setOutputCol(&quot;bucketId&quot;)
//训练LSH分桶模型
val bucketModel = bucketProjectionLSH.fit(movieEmbDF)
//进行分桶
val embBucketResult = bucketModel.transform(movieEmbDF)
//打印分桶结果
println(&quot;movieId, emb, bucketId schema:&quot;)
embBucketResult.printSchema()
println(&quot;movieId, emb, bucketId data result:&quot;)
embBucketResult.show(10, truncate = false)
//尝试对一个示例Embedding查找最近邻
println(&quot;Approximately searching for 5 nearest neighbors of the sample embedding:&quot;)
val sampleEmb = Vectors.dense(0.795,0.583,1.120,0.850,0.174,-0.839,-0.0633,0.249,0.673,-0.237)
bucketModel.approxNearestNeighbors(movieEmbDF, sampleEmb, 5).show(truncate = false)
}
```
为了帮助你更加直观的看到分桶操作的效果我把使用LSH模型对电影Embedding进行分桶得到的五个结果打印了出来如下所示
```
+-------+-----------------------------+------------------+
|movieId|emb |bucketId |
+-------+-----------------------------+------------------------+
|710 |[0.04211471602320671,..] |[[-2.0], [14.0], [8.0]] |
|205 |[0.6645985841751099,...] |[[-4.0], [3.0], [5.0]] |
|45 |[0.4899883568286896,...] |[[-6.0], [-1.0], [2.0]] |
|515 |[0.6064003705978394,...] |[[-3.0], [-1.0], [2.0]] |
|574 |[0.5780771970748901,...] |[[-5.0], [2.0], [0.0]] |
+-------+-----------------------------+------------------------+
```
你可以看到在BucketId这一列因为我们之前设置了NumHashTables参数为3所以每一个Embedding对应了3个BucketId。在实际的最近邻搜索过程中我们就可以利用刚才讲的多桶策略进行搜索了。
事实上,在一些超大规模的最近邻搜索问题中,索引、分桶的策略还能进一步复杂。如果你有兴趣深入学习,我推荐你去了解一下[Facebook的开源向量最近邻搜索库FAISS](https://github.com/facebookresearch/faiss),这是一个在业界广泛应用的开源解决方案。
## 小结
本节课我们一起解决了“Embedding最近邻搜索”问题。
事实上对于推荐系统来说我们可以把召回最相似物品Embedding的问题看成是在高维的向量空间内搜索最近邻点的过程。遇到最近邻问题我们一般会采用聚类和索引这两种方法。但是聚类和索引都无法完全解决边缘点最近邻的问题并且对于聚类来说中心点的数量k也并不好确定而对于Kd-tree索引来说Kd-tree索引的结构并不简单离线和在线维护的过程也相对复杂。
因此,解决最近邻问题最“完美”的办法就是使用局部敏感哈希,在每个桶内点的数量接近时,它能够把最近邻查找的时间控制在常数级别。为了进一步提高最近邻搜索的效率或召回率,我们还可以采用多桶策略,首先是基于“且”操作的多桶策略能够进一步减少候选集规模,增加计算效率,其次是基于“或”操作的多桶策略则能够提高召回率,减少漏掉最近邻点的可能性。
最后,我在下面列出了各种方法的优缺点,希望能帮助你做一个快速的复盘。
<img src="https://static001.geekbang.org/resource/image/40/b1/40yy632948cdd9090fe34d3957307eb1.jpeg" alt="">
## 课后思考
如果让你在推荐服务器内部的召回层实现最近邻搜索过程,你会怎样存储和使用我们在离线产生的分桶数据,以及怎样设计线上的搜索过程呢?
欢迎你在留言区写出你的答案更欢迎你把这一过程的实现提交Pull Request到Sparrow Resys项目如果能够被采纳你将成为这一开源项目的贡献者之一。我们下节课再见

View File

@@ -0,0 +1,169 @@
<audio id="audio" title="13 | 模型服务:怎样把你的离线模型部署到线上?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/74/6c/7458ddfd30943baa11d09f10a6ba7b6c.mp3"></audio>
你好我是王喆。今天我们来讨论“模型服务”Model Serving
在实验室的环境下我们经常使用Spark MLlib、TensorFlow、PyTorch这些流行的机器学习库来训练模型因为不用直接服务用户所以往往得到一些离线的训练结果就觉得大功告成了。但在业界的生产环境中模型需要在线上运行实时地根据用户请求生成模型的预估值。这个把模型部署在线上环境并实时进行模型推断Inference的过程就是模型服务。
模型服务对于推荐系统来说是至关重要的线上服务,缺少了它,离线的模型只能在离线环境里面“干着急”,不能发挥功能。但是,模型服务的方法可谓是五花八门,各个公司为了部署自己的模型也是各显神通。那么,业界主流的模型服务方法都有哪些,我们又该如何选择呢?
今天我就带你学习主流的模型服务方法并通过TensorFlow Serving把你的模型部署到线上。
## 业界的主流模型服务方法
由于各个公司技术栈的特殊性采用不同的机器学习平台模型服务的方法会截然不同不仅如此使用不同的模型结构和模型存储方式也会让模型服务的方法产生区别。总的来说那业界主流的模型服务方法有4种分别是预存推荐结果或Embedding结果、预训练Embedding+轻量级线上模型、PMML模型以及TensorFlow Serving。接下来我们就详细讲讲这些方法的实现原理通过对比它们的优缺点相信你会找到最合适自己业务场景的方法。
### 预存推荐结果或Embedding结果
对于推荐系统线上服务来说最简单直接的模型服务方法就是在离线环境下生成对每个用户的推荐结果然后将结果预存到以Redis为代表的线上数据库中。这样我们在线上环境直接取出预存数据推荐给用户即可。
这个方法的优缺点都非常明显,我把它们总结在了下图中,你可以看看。
<img src="https://static001.geekbang.org/resource/image/f7/78/f71c27199778404d97c7f228635ea278.jpeg" alt="" title="图1 预存推荐结果优缺点对比">
由于这些优缺点的存在,这种直接存储推荐结果的方式往往只适用于用户规模较小,或者一些冷启动、热门榜单等特殊的应用场景中。
那如果在用户规模比较大的场景下我们该怎么减少模型存储所需的空间呢我们其实可以通过存储Embedding的方式来替代直接存储推荐结果。具体来说就是我们先离线训练好Embedding然后在线上通过相似度运算得到最终的推荐结果。
在前面的课程中我们通过Item2vec、Graph Embedding等方法生成物品Embedding再存入Redis供线上使用的过程这就是预存Embedding的模型服务方法的典型应用。
由于线上推断过程非常简单快速因此预存Embedding的方法是业界经常采用的模型服务手段。但它的局限性同样存在由于完全基于线下计算出Embedding这样的方式无法支持线上场景特征的引入并且无法进行复杂模型结构的线上推断表达能力受限。因此对于复杂模型我们还需要从模型实时线上推断的角度入手来改进模型服务的方法。
### 预训练Embedding+轻量级线上模型
事实上直接预存Embedding的方法让模型表达能力受限这个问题的产生主要是因为我们仅仅采用了“相似度计算”这样非常简单的方式去得到最终的推荐分数。既然如此那我们能不能在线上实现一个比较复杂的操作甚至是用神经网络来生成最终的预估值呢当然是可行的这就是业界很多公司采用的“预训练Embedding+轻量级线上模型”的模型服务方式。
详细一点来说,这样的服务方式指的是“**用复杂深度学习网络离线训练生成Embedding存入内存数据库再在线上实现逻辑回归或浅层神经网络等轻量级模型来拟合优化目标**”。
口说无凭接下来我们就来看一个业界实际的例子。我们先来看看下面这张模型结构图这是阿里的推荐模型MIMNMulti-channel user Interest Memory Network多通道用户兴趣记忆网络的结构。神经网络才是真正在线上服务的部分。
仔细看这张图你会注意到,左边粉色的部分是复杂模型部分,右边灰色的部分是简单模型部分。看这张图的时候,其实你不需要纠结于复杂模型的结构细节,你只要知道左边的部分不管多复杂,它们其实是在线下训练生成的,而右边的部分是一个经典的多层神经网络,它才是真正在线上服务的部分。
<img src="https://static001.geekbang.org/resource/image/1e/53/1e0c2a6c404786b709c5177f7d337553.jpg" alt="" title="图2 阿里的MIMN模型 出自Practice on Long Sequential User Behavior Modeling for Click-Through Rate Prediction">
这两部分的接口在哪里呢你可以看一看图中连接处的位置有两个被虚线框框住的数据结构分别是S(1)-S(m)和M(1)-M(m)。它们其实就是在离线生成的Embedding向量在MIMN模型中它们被称为“多通道用户兴趣向量”这些Embedding向量就是连接离线模型和线上模型部分的接口。
线上部分从Redis之类的模型数据库中拿到这些离线生成Embedding向量然后跟其他特征的Embedding向量组合在一起扔给一个标准的多层神经网络进行预估这就是一个典型的“预训练Embedding+轻量级线上模型”的服务方式。
它的好处显而易见就是我们隔离了离线模型的复杂性和线上推断的效率要求离线环境下你可以尽情地使用复杂结构构建你的模型只要最终的结果是Embedding就可以轻松地供给线上推断使用。
### 利用PMML转换和部署模型
虽然Embedding+轻量级模型的方法既实用又高效但它还是把模型进行了割裂让模型不完全是End2End端到端训练+End2End部署这种最“完美”的方式。那有没有能够在离线训练完模型之后什么都不用做直接部署模型的方式呢当然是有的也就是我接下来要讲的脱离于平台的通用模型部署方式PMML。
PMML的全称是“预测模型标记语言”(Predictive Model Markup Language, PMML)它是一种通用的以XML的形式表示不同模型结构参数的标记语言。在模型上线的过程中PMML经常作为中间媒介连接离线训练平台和线上预测平台。
这么说可能还比较抽象。接下来我就以Spark MLlib模型的训练和上线过程为例来和你详细解释一下PMML在整个机器学习模型训练及上线流程中扮演的角色。
<img src="https://static001.geekbang.org/resource/image/83/8b/835f47b8c7eac3e18711c8c6e22dbd8b.jpeg" alt="" title="图3 Spark模型利用PMML的上线过程">
图3中的例子使用了JPMML作为序列化和解析PMML文件的libraryJPMML项目分为Spark和Java Server两部分。Spark部分的library完成Spark MLlib模型的序列化生成PMML文件并且把它保存到线上服务器能够触达的数据库或文件系统中而Java Server部分则完成PMML模型的解析生成预估模型完成了与业务逻辑的整合。
JPMML在Java Server部分只进行推断不考虑模型训练、分布式部署等一系列问题因此library比较轻能够高效地完成推断过程。与JPMML相似的开源项目还有MLeap同样采用了PMML作为模型转换和上线的媒介。
事实上JPMML和MLeap也具备Scikit-learn、TensorFlow等简单模型的转换和上线能力。我把[JPMML](https://github.com/jpmml)和[MLeap](https://github.com/combust/mleap)的项目地址放在这里,感兴趣的同学可以进一步学习和实践。
### TensorFlow Serving
既然PMML已经是End2End训练+End2End部署这种最“完美”的方式了那我们的课程中为什么不使用它进行模型服务呢这是因为对于具有复杂结构的深度学习模型来说PMML语言的表示能力还是比较有限的还不足以支持复杂的深度学习模型结构。由于咱们课程中的推荐模型篇会主要使用TensorFlow来构建深度学习推荐模型这个时候PMML的能力就有点不足了。想要上线TensorFlow模型我们就需要借助TensorFlow的原生模型服务模块也就是TensorFlow Serving的支持。
从整体工作流程来看TensorFlow Serving和PMML类工具的流程一致它们都经历了模型存储、模型载入还原以及提供服务的过程。在具体细节上TensorFlow在离线把模型序列化存储到文件系统TensorFlow Serving把模型文件载入到模型服务器还原模型推断过程对外以HTTP接口或gRPC接口的方式提供模型服务。
再具体到咱们的Sparrow Recsys项目中我们会在离线使用TensorFlow的Keras接口完成模型构建和训练再利用TensorFlow Serving载入模型用Docker作为服务容器然后在Jetty推荐服务器中发出HTTP请求到TensorFlow Serving获得模型推断结果最后推荐服务器利用这一结果完成推荐排序。
<img src="https://static001.geekbang.org/resource/image/88/f4/882b2c61f630084e74427b724f64eef4.jpg" alt="" title="图4 Sparrow Recsys项目模型服务部分的架构">
## 实战搭建TensorFlow Serving模型服务
好了清楚了模型服务的相关知识相信你对各种模型服务方法的优缺点都已经了然于胸了。刚才我们提到咱们的课程选用了TensorFlow作为构建深度学习推荐模型的主要平台并且选用了TensorFlow Serving作为模型服务的技术方案它们可以说是整个推荐系统的核心了。那为了给之后的学习打下基础接下来我就带你搭建一个TensorFlow Serving的服务把这部分重点内容牢牢掌握住。
总的来说搭建一个TensorFlow Serving的服务主要有3步分别是安装Docker建立TensorFlow Serving服务以及请求TensorFlow Serving获得预估结果。为了提高咱们的效率我希望你能打开电脑跟着我的讲解和文稿里的指令代码一块儿来安装。
### 1. 安装Docker
TensorFlow Serving最普遍、最便捷的服务方式就是使用Docker建立模型服务API。为了方便你后面的学习我再简单说说Docker。Docker是一个开源的应用容器引擎你可以把它当作一个轻量级的虚拟机。它可以让开发者打包他们的应用以及依赖包到一个轻量级、可移植的容器中然后发布到任何流行的操作系统比如Linux/Windows/Mac的机器上。Docker容器相互之间不会有任何接口而且容器本身的开销极低这就让Docker成为了非常灵活、安全、伸缩性极强的计算资源平台。
因为TensorFlow Serving对外提供的是模型服务接口所以使用Docker作为容器的好处主要有两点一是可以非常方便的安装二是在模型服务的压力变化时可以灵活地增加或减少Docker容器的数量做到弹性计算弹性资源分配。Docker的安装也非常简单我们参考[官网的教程](https://www.docker.com/get-started),像安装一个普通软件一样下载安装就好。
安装完Docker后你不仅可以通过图形界面打开并运行Docker而且可以通过命令行来进行Docker相关的操作。那怎么验证你是否安装成功了呢只要你打开命令行输入docker --version命令它能显示出类似“Docker version 19.03.13, build 4484c46d9d”这样的版本号就说明你的Docker环境已经准备好了。
### 2. 建立TensorFlow Serving服务
Docker环境准备好之后我们就可以着手建立TensorFlow Serving服务了。
首先我们要利用Docker命令拉取TensorFlow Serving的镜像:
```
# 从docker仓库中下载tensorflow/serving镜像
docker pull tensorflow/serving
```
然后我们再从TenSorflow的官方GitHub地址下载TensorFlow Serving相关的测试模型文件
```
# 把tensorflow/serving的测试代码clone到本地
git clone https://github.com/tensorflow/serving
# 指定测试数据的地址
TESTDATA=&quot;$(pwd)/serving/tensorflow_serving/servables/tensorflow/testdata&quot;
```
最后我们在Docker中启动一个包含TensorFlow Serving的模型服务容器并载入我们刚才下载的测试模型文件half_plus_two
```
# 启动TensorFlow Serving容器在8501端口运行模型服务API
docker run -t --rm -p 8501:8501 \
-v &quot;$TESTDATA/saved_model_half_plus_two_cpu:/models/half_plus_two&quot; \
-e MODEL_NAME=half_plus_two \
tensorflow/serving &amp;
```
在命令执行完成后如果你在Docker的管理界面中看到了TenSorflow Serving容器如下图所示就证明TensorFlow Serving服务被你成功建立起来了。
<img src="https://static001.geekbang.org/resource/image/35/3c/3539eccb2a57573a75902738c148fe3c.jpg" alt="" title="图5 TensorFlow Serving容器的Docker启动管理界面">
### 3. 请求TensorFlow Serving获得预估结果
最后我们再来验证一下是否能够通过HTTP请求从TensorFlow Serving API中获得模型的预估结果。我们可以通过curl命令来发送HTTP POST请求到TensorFlow Serving的地址或者利用Postman等软件来组装POST请求进行验证。
```
# 请求模型服务API
curl -d '{&quot;instances&quot;: [1.0, 2.0, 5.0]}' \
-X POST http://localhost:8501/v1/models/half_plus_two:predict
```
如果你看到了下图这样的返回结果就说明TensorFlow Serving服务已经成功建立起来了。
```
# 返回模型推断结果如下
# Returns =&gt; { &quot;predictions&quot;: [2.5, 3.0, 4.5] }
```
如果对这整个过程还有疑问的话你也可以参考TensorFlow Serving的[官方教程](https://www.tensorflow.org/tfx/serving/docker)。
不过有一点我还想提醒你这里我们只是使用了TensorFlow Serving官方自带的一个测试模型来告诉你怎么准备环境。在推荐模型实战的时候我们还会基于TensorFlow构建多种不同的深度学习模型到时候TensorFlow Serving就会派上关键的用场了。
那对于深度学习推荐系统来说我们只要选择TensorFlow Serving的模型服务方法就万无一失了吗当然不是它也有需要优化的地方。在搭建它的过程会涉及模型更新整个Docker Container集群的维护而且TensorFlow Serving的线上性能也需要大量优化来提高这些工程问题都是我们在实践过程中必须要解决的。但是它的易用性和对复杂模型的支持还是让它成为上线TensorFlow模型的第一选择。
## 小结
业界主流的模型服务方法有4种分别是预存推荐结果或Embeding结果、预训练Embeding+轻量级线上模型、利用PMML转换和部署模型以及TensorFlow Serving。
它们各有优缺点,为了方便你对比,我把它们的优缺点都列在了表格中,你可以看看。
<img src="https://static001.geekbang.org/resource/image/51/52/51f65a9b9e10b0808338388e20217d52.jpeg" alt="">
我们之后的课程会重点使用TensorFlow Serving它是End2End的解决方案使用起来非常方便、高效而且它支持绝大多数TensorFlow的模型结构对于深度学习推荐系统来说是一个非常好的选择。但它只支持TensorFlow模型而且针对线上服务的性能问题需要进行大量的优化这是我们在使用时需要重点注意的。
在实践部分我们一步步搭建起了基于Docker的TensorFlow Serving服务这为我们之后进行深度学习推荐模型的上线打好了基础。整个搭建过程非常简单相信你跟着我的讲解就可以轻松完成。
## 课后思考
我们今天讲了如此多的模型服务方式,你能结合自己的经验,谈一谈你是如何在自己的项目中进行模型服务的吗?除了我们今天说的,你还用过哪些模型服务的方法?
欢迎在留言区分享你的经验,也欢迎你把这节课分享出去,我们下节课见!

View File

@@ -0,0 +1,109 @@
<audio id="audio" title="14 | 融会贯通Sparrow RecSys中的电影相似推荐功能是如何实现的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/cc/57/cc197c38ac9e8343faac60d8f9b30457.mp3"></audio>
你好,我是王喆。
课程进行到这里推荐系统架构的大部分知识点包括特征工程、Embedding模型到推荐服务的搭建线上推荐过程的实现我们都已经学习并且实践过了。如果你坚持跟着我一起学下来的话可以说已经是“武功小成”了。
为了帮你巩固所学,今天,我就带你从头到尾地实现一个完整的推荐功能,**相似电影推荐**,来帮助你打通推荐系统的“任督二脉”。
## “清点技能库”,看看我们已有的知识储备有哪些
在开始实现相似电影推荐功能之前,我想先带着你一起清点一下自己的技能库。我喜欢把推荐的过程比喻成做菜的过程,接下来,我就按照做菜的四个关键步骤,带你回顾一下前面学过的重点知识。
**第一步,准备食材。** 准备食材的过程就是我们准备推荐所需特征的过程。在特征工程篇中我们不仅学会了怎么挑选“食材”怎么处理“食材”而且还实践了“备菜”的高级技能Embedding技术。具体来说就是我们能够利用物品序列数据通过Item2vec方法训练出Embedding也能够使用Deep Walk和Node2vec把图结构数据生成Graph Embedding。
总的来说因为Embedding技术的本质就是利用了物品之间的相关性所以Embedding是做好“相似推荐”这盘菜的关键。
**第二步,食材下锅。** 备好了菜在正式开炒之前我们肯定要把食材下锅。在推荐系统中“食材下锅”的过程有两个一是把线上推荐所用的特征存储到数据库中在之前的课程中我们已经实践过使用Redis作为特征数据库的方法另一个是把模型部署到模型服务模块我们也已讲过了预训练EmbeddingEmbedding加轻量级线上模型TensorFlow Serving等多种模型服务方式这节课我们将采用预训练Embedding的方式进行模型服务。
**第三步,做菜技术。** “做菜的技术”说的是推荐服务器线上推荐的整个流程是否合理。那回到推荐系统中就是指,召回层要快速准确,模型排序部分要精确。这些具体的实现都影响着最终的推荐效果。
对于召回层来说我们已经学过单策略召回、多路召回和基于Embedding的召回。对于排序来说我们会主要利用Embedding相似度来排序后续我们还会学习基于多种推荐模型的排序。
**最后是菜品上桌的过程** 也就是把推荐的结果呈现给用户的过程。这节课我会带你一起实现这个过程。提前“剧透”一下在Sparrow Recsys中我们会先利用JavaScript异步请求推荐服务API获取推荐结果再利用JavaScript+HTML把结果展现给用户”。因为这一部分内容不是推荐系统的重点所以我们这里只要做到界面清爽、逻辑清晰就可以了。
相信到这里,各位“大厨”已经准备好了所要用到的技能,下面就让我们一起来实现**Sparrow RecSys中的相似电影推荐功能吧**
## 如何实现相似电影推荐功能?
在正式开始相似电影推荐功能之前我们先来看看我总结的Sparrow Recsys相似电影推荐功能的详细技术架构图。细心的你可能已经发现了这个架构图就是Sparrow Recsys架构的精简版。因为我们还没有学习深度学习推荐模型和模型评估的相关知识所以把重点聚焦在已经学过的知识上就可以了。
<img src="https://static001.geekbang.org/resource/image/f4/4f/f408eeeeb04bccd127a6726b8bf91d4f.jpg" alt="" title="图1 Sparrow Recsys 相似电影推荐功能的技术架构图">
接下来,我就结合这个技术架构图,带你一步步地实现其中的每一个模块。并且,我还会给你讲解一些项目中没有实现的其他业界主流方法,如果你还学有余力,希望你能抓住这个机会,来扩展一下自己的知识面。
### 1. 数据和模型部分
数据和模型部分的实现,其实和我们[第8讲](https://time.geekbang.org/column/article/296932)讲的Embedding的实战思路是一样的我们可以选用Item2vec、Deep Walk等不同的Embedding方法来生成物品Embedding向量。考虑到大数据条件下数据处理与训练的一致性在Sparrow Recsys中我们会采用Spark进行数据处理同时选择Spark MLlib进行Embedding的训练。这部分内容的代码你可以参考项目中的`_com.wzhe.sparrowrecsys.offline.spark.embedding.__Embedding_`对象它定义了所有项目中用到的Embedding方法。
对于一些比较复杂的Embedding方案比如特征种类很多网络结构也更多样化的Embedding模型业界也多采用Spark进行原始数据处理生成训练样本后交由TensorFlow、PyTorch训练的方案。
但是不论训练平台是怎样的Embedding方法的产出都是一致的就是物品ID对应的Embedding向量。那为了方便线上服务使用我们还需要在生成Embedding后把它们存入某个高可用的数据库。Sparrow Recsys选择了最主流的内存数据库Redis作为实现方案这一部分的具体实现你可以参照`com.wzhe.sparrowrecsys.offline.spark.embedding.Embedding`对象中trainItem2vec函数的Redis存储操作。当然业界也会使用Cassandra+缓存RocksDB等不同的存储方案来实现Embedding向量的高效读取但我们现阶段只要学会Redis存储和读取操作就够用了。
到这里Redis成为了连接线下和线上的关键节点那我们的线上服务部分又是怎么利用Redis中的Embedding数据进行相似电影推荐的呢
### 2. 线上服务部分
线上服务部分是直接接收并处理用户推荐请求的部分,从架构图的最左边到最右边,我们可以看到三个主要步骤:候选物品库的建立、召回层的实现、排序层的实现。我们逐个来讲一讲。
首先是候选物品库的建立。Sparrow Recsys中候选物品库的建立采用了非常简单的方式就是直接把MovieLens数据集中的物品数据载入到内存中。但对于业界比较复杂的推荐业务来说候选集的选取往往是有很多条件的 比如物品可不可用有没有过期有没有其他过滤条件等等所以工业级推荐系统往往会通过比较复杂的SQL查询或者API查询来获取候选集。
第二步是召回层的实现。我们在[第11讲](https://time.geekbang.org/column/article/299494)曾经详细学习了召回层的技术这里终于可以学以致用了。因为物品的Embedding向量已经在离线生成所以我们可以自然而然的使用Embedding召回的方法来完成召回层的实现。同时Sparrow Recsys也实现了基于物品metadata元信息的多路召回方法具体的实现你可以参照`com.wzhe.sparrowrecsys.online.recprocess.SimilarMovieProcess`类中的multipleRetrievalCandidates函数和retrievalCandidatesByEmbedding函数。
第三步是排序层的实现。根据Embedding相似度来进行“相似物品推荐”是深度学习推荐系统最主流的解决方案所以在Sparrow Recsys中我们当然也是先根据召回层过滤出候选集再从Redis中取出相应的Embedding向量然后计算目标物品和候选物品之间的相似度最后进行排序就可以了。
这里“相似度”的定义是多样的可以是余弦相似度也可以是内积相似度还可以根据你训练Embedding时定义的不同相似度指标来确定。因为在Word2vec中相似度的定义是内积相似度所以,这里我们也采用内积作为相似度的计算方法。同样具体的实现你可以参照com.wzhe.sparrowrecsys.online.recprocess.SimilarMovieProcess类中的ranker函数。
经历了这三个主要的线上服务步骤Sparrow Recsys就可以向用户返回推荐列表了。所以接下来我们要解决的问题就是怎么把这些结果通过前端页面展示给用户。
### 3. 前端部分
Sparrow Recsys的前端部分采用了最简单的HTML+AJAX请求的方式。AJAX的全称是Asynchronous JavaScript and XML异步JavaScript和XML请求。它指的是不刷新整体页面用JavaScript异步请求服务器端更新页面中部分元素的技术。当前流行的JavaScript前端框架React、Vue等等也大多是基于AJAX来进行数据交互的。
但前端毕竟不是我们课程的重点你知道我在上面提到的基本原理就可以了。如果你已经在本地的6010端口运行起了Sparrow Recsys那直接点击这个链接[http://localhost:6010/movie.html?movieId=589](http://localhost:6010/movie.html?movieId=589) 就可以看到电影《终结者2》的详情页面和相似电影推荐结果了如图2
<img src="https://static001.geekbang.org/resource/image/a3/yy/a36a1ba15f4c464c84797fc87caf85yy.jpg" alt="" title="图2 终结者2的相似电影推荐结果">
## 相似电影推荐的结果和初步分析
到这里我相信你已经串联起来了Sparrow Recsys相似电影推荐的所有实现看到了推荐结果。那么问题来了推荐结果的好坏到底是如何判断的呢关于这个问题我们也会在后面的“模型评估篇”中进行系统性的学习。不过这里我也想先跟你聊聊这个话题让你对它有一个大体认识这对你建立后续的模型评估体系是非常有帮助的。
首先提醒你的是Sparrow Recsys开源项目中自带的MovieLens数据集是经过我采样后的缩小集所以基于这个数据集训练出的模型的准确性和稳定性是比较低的。如果你有兴趣的话可以去[MovieLens官网](https://grouplens.org/datasets/movielens/)选择**MovieLens 20M Dataset**下载并重新训练,相信会得到更准确的推荐结果。
其次,针对相似物品推荐这个推荐场景,我们其实很难找到一个统一的衡量标准。比如,你能说出《功夫熊猫》这部电影是跟《玩具总动员》更相近,还是跟《飞屋环游记》更相近吗?好在,工程师们还是总结出了一些有效的评估方法。这里,我挑出了三个最常用的来给你讲讲。
**方法一人肉测试SpotCheck。** 在一种Embedding结果新鲜出炉的时候你作为创造它们的工程师应该第一时间做一个抽样测试看一看基于Embedding的相似推荐结果是不是符合你自己的常识。比如说我在Embedding训练完之后随便在Sparrow Recsys中翻了翻看到了两个页面一个是儿童电影《Free Willy》《人鱼童话》的相似电影推荐页面图3左另一个是著名动画电影《Toy Story》《玩具总动员》的相似电影推荐页面图3右
<img src="https://static001.geekbang.org/resource/image/43/87/43be7ebfd05yye98f9c432d4bb113987.jpg" alt="" title="图3 随机测试">
直观上来看《Free Willy》的推荐结果就非常不错因为你可以看到相似电影中都是适合儿童看的甚至这些电影都和动物相关。但是《玩具总动员》就不一样了它的相似电影里不仅有动画片还有《真实的谎言》《True Lies》、《阿甘正传》这类明显偏成人的电影。这明显不是一个非常好的推荐结果。
为什么会出现这样的结果呢?我们来做一个推测。事实上,《玩具总动员》本身是一部非常流行的电影,跟它近似的也都是类似《真实的谎言》、《阿甘正传》这类很热门的电影。这就说明了一个问题,热门电影其实很容易跟其他大部分电影产生相似性,因为它们会出现在大多数用户的评分序列中。
针对这个问题其实仅利用基于用户行为序列的Embedding方法是很难解决的。这需要我们引入更多内容型特征进行有针对性的改进比如电影类型、海报风格或者在训练中有意减少热门电影的样本权重增大冷门电影的样本权重等等。总的来说遇到推荐结果不合理的情况我们需要做更多的调查研究发掘这些结果出现的真实原因才能找到改进方向。
**方法二指定Ground truth可以理解为标准答案。** 虽然我们说相似影片的Ground truth因人而异。但如果只是为了进行初步评估我们也可以指定一些比较权威的验证集。比如对于相似影片来说我们可以利用IMDB的more like this的结果去做验证我们的相似电影结果。当然要补充说明的是要注意有些Ground truth数据集的可用范围不能随意在商业用途中使用未经许可的数据集。
**方法三:利用商业指标进行评估。** 既然相似影片比较难以直接衡量,那我们不如换一个角度,来思考一下做相似影片这个功能的目的是什么。对于一个商业网站来说,无非是提高点击率,播放量等等。因此,我们完全可以跃过评估相似度这样一个过程,直接去评估它的终极商业指标。
举个例子我们可以通过上线一个新的相似电影模型让相似电影这个功能模块的点击率提高假设提高了5%,那这就是一个成功的模型改进。至于相似电影到底有没有那么“相似”,我们反而不用那么纠结了。
## 小结
这节课我们使用Embedding方法准备好了食材使用Redis把食材下锅做菜的步骤稍微复杂一点分为建立候选集、实现召回层、实现排序层这3个步骤。最后我们用HTML+Ajax的方式把相似电影推荐这盘菜呈现出来。
既然有做菜的过程当然也有品菜的阶段。针对相似物品推荐这一常见的功能我们可以使用人肉测试、Ground truth和商业指标评估这三种方法对得到的结果进行评估。也希望你能够在实际的业务场景中活学活用用评估结果指导模型的下一步改进。
我希望,通过这节课的总结和实战,能让你融会贯通的厘清我们学过的知识。所以我把你需要掌握的重要知识点,总结在了一张图里,你可以利用它复习巩固。
<img src="https://static001.geekbang.org/resource/image/dc/9f/dcbb6cf20283ee362235255841b00c9f.jpg" alt="">
好了那到这里我们线上服务篇的内容就全部结束了。通过这一篇的学习我相信你已经清楚了推荐系统的全部技术架构以及深度学习核心技术Embedding的运用方法。
但盛宴还未开始,下一篇我们将进入深度推荐模型的学习和实践。我曾经说过,深度推荐模型是深度学习推荐系统这个王冠上的明珠,正是它对推荐模型的革命,让深度学习的浪潮席卷推荐系统领域。希望你再接再厉,让我们一起把这颗明珠摘下吧!
## 课后思考
刚才我说到《玩具总动员》的相似电影推荐结果并不好我认为可能是因为热门电影的头部效应造成的。你认同这一观点吗你觉得还有其他可能的原因吗如果让你去做一些Embedding方法上的改进你还有什么好的想法吗
欢迎把你的成果和优化想法分享到留言区,也欢迎你能把这节课转发出去,让更多人从我们的实践中受益,我们下节课见!

View File

@@ -0,0 +1,109 @@
<audio id="audio" title="答疑 | 线上服务篇留言问题详解" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b9/cb/b9b773d5c6892244568f7b615dee96cb.mp3"></audio>
你好,我是王喆。
今天是专栏的第二次答疑加餐时间,第一次答疑我已经对基础篇和特征工程篇中常见的问题进行了解答,所以这节课我们重点来看看线上服务篇中很有代表性的课后思考题和留言问题,我会对它们进行一些补充回答,希望对你有帮助。
## 关于项目的开源精神
在开始回答问题之前我想先跟你聊一聊我们SparrowRecsys项目的开源精神。在课程一开始我就说过SparrowRecsys这个项目是我们的一个种子项目它肯定不完美但我希望它是一个工业级推荐系统的雏形。在学习的过程中我希望能够跟你一起完善它让它的羽翼逐渐丰满起来。
让我很高兴的是已经有不少同学投身到改进SparrowRecsys的队伍中来比如GitHub ID叫[dxzmpk](https://github.com/dxzmpk)的同学添加了[Node2vec模型的代码](https://github.com/wzhe06/SparrowRecSys/pull/14)还有GitHub ID叫jason-wang1的同学添加了[多路召回多线程版本的代码](https://github.com/wzhe06/SparrowRecSys/pull/13)还有更多的同学修改了项目中的Bug优化了一些实现感谢你们的投入
我是开源精神的坚定拥护者我也相信在我们的共同努力下SparrowRecsys未来能够发展成为在业界有影响力的开源项目。所以在这里我呼吁同学们能够多参与进来多提Pull Request让我们共同成为项目的第一批原作者。
好,下面我们进入问题解答的环节。
## [《03深度学习基础你打牢深度学习知识的地基了吗》](https://time.geekbang.org/column/article/291245)
**思考题1哪些因素影响着深度学习网络的结构深度学习模型是越深越好吗**
这两个问题我们分开来看,先看看影响深度学习网络结构的因素。在业界的应用中,影响深度学习网络结构的因素非常多。不过,我认为可以总结出二类最主要的因素。
第一类:业务场景中用户行为的特点。很多模型结构的实现是为了模拟用户行为的特点,比如注意力机制的引入是用来模拟用户的注意力行为特点,序列模型是用来模拟用户兴趣变迁,特征交叉层是为了让用户和物品的相关特征进行交叉等等。
第二类:数据规模、算力的制约。这一点“一天”同学回答得非常有价值,在实际的业界应用中,数据规模大起来之后,我们往往不能够随意选择复杂模型,而是要在数据规模,平台算力的制约下,尽量选择效果最优的模型结构。
我们再来看第二个问题,深度学习模型是越深越好吗?
这个答案是否定的。深度学习模型变深之后,并不总是能够提高模型效果,有时候深度的增加对模型效果的贡献是微乎其微的。而且模型复杂之后的负面影响也非常多,比如训练时间加长,收敛所需数据和训练轮数增加,模型不一定稳定收敛,模型过拟合的风险增加等等。所以在模型深度的选择上,我们要在尽量保证效果的前提下,选择结构较简单的方案。
借助这道思考题,我希望能帮助你更好地理解深度学习的特点,以及实际应用中的一些经验。
## [《09线上服务如何在线上提供高并发的推荐服务》](https://time.geekbang.org/column/article/299155)
思考题2在一个高并发的推荐服务集群中负载均衡服务器的作用至关重要如果你是负载均衡服务器的策略设计师你会怎么实现这个“工头”的调度策略让它能够公平又高效地完成调度任务呢比如是按每个节点的能力分配还是按照请求本身的什么特点来分配如何知道什么时候应该扩展节点什么时候应该关闭节点
负载均衡的策略其实有多种选择比如“smjccj”同学的回答就很专业他说可以进行源地址哈希或根据服务器计算能力加权随机分配。这是一个很好的答案这里我再补充一下。通常来说常用的负载均衡的策略有三种分别是轮询调度、哈希调度和一致性哈希调度。我们一一来看。
轮询调度就是以轮询的方式依次把请求调度到不同的服务器。在服务器的算力等硬件配置不同的时候,我们还可以为每个服务器设定权重,按权重比例为能力强的服务器分配更多的请求。
而哈希调度指的是通过某个哈希函数把key分配给某个桶这里key可以是请求中的用户ID物品ID等ID型信息桶的总数就是服务器的总数。这样一来我们就可以把某个用户的请求分配给某个服务器处理。这么做的好处是可以让一个key落在固定的服务器节点上有利于节约服务器内部缓存的使用。
哈希方式的缺点在于无法高效处理故障点一旦某个点有故障需要减少桶的数量或者在QPS增大时需要增加服务器整个分配过程就被完全打乱。因此一致性哈希调度就是更好的解决方案简单来说就是使用哈希环来解决计算节点的增加和减少的问题具体的实现我推荐你参考[《一致性哈希算法的理解与实践》](%E4%B8%80%E8%87%B4%E6%80%A7%E5%93%88%E5%B8%8C%E7%AE%97%E6%B3%95%E7%9A%84%E7%90%86%E8%A7%A3%E4%B8%8E%E5%AE%9E%E8%B7%B5/)这篇文章。
留言问题1在一个成熟的工业级推荐系统中每个用户请求的时间、地点、context都不一样缓存策略是怎么工作的才能把这些数据大部分都缓存起来
<img src="https://static001.geekbang.org/resource/image/a8/y7/a896f3ee7258d3b1ec5e6178b9da0yy7.jpg" alt="">
这里,同学们一定要理解缓存的意义。如果请求中的变量每次都不一样,那我们确实就没有必要进行缓存了,因为每次返回的结果都是不同的。但真实情况往往不是这样,我们其实可以在具体的业务场景中挖掘出巨大的优化空间。
比如,电商网站存在着大量没有购买记录的新用户,我们其实可以根据这些新用户有限的特征把他们分成少量的几个类别,对一个类别内的用户展示同样的推荐结果。这样,我们就没必要每次都请求复杂的推荐模型了。
再比如,同一个用户有可能多次请求同一个页面,如果推荐系统对这些操作进行了缓存,就不用对每次重复的请求重复计算推荐结果了,在处理完首次请求之后,面对之后的重复请求,推荐系统直接返回缓存结果就可以了。当然,推荐系统具体能存储多少用户缓存,也取决于硬件配置,也取决于缓存的过期时间,这些都需要我们灵活进行配置。
留言问题2推荐系统中的冷启动策略指的是什么
<img src="https://static001.geekbang.org/resource/image/c1/18/c19dc90720b21f317450aa7c94b66d18.jpeg" alt="">
冷启动是推荐系统一定要考虑的问题。它是指推荐系统在没有可用信息,或者可用信息很少的情形下怎么做推荐的问题,冷启动可以分为用户冷启动和物品冷启动两类。
用户冷启动是指用户没有可用的行为历史情况下的推荐问题。一般来说我们需要清楚在没有推荐历史的情况下还有什么用户特征可以使用比如注册时的信息访问APP时可以获得的地点、时间信息等等根据这些有限的信息我们可以为用户做一个聚类为每类冷启动用户返回合适的推荐列表。当然我们也可以利用可用的冷启动特征来构建一个较简单的冷启动推荐模型去解决冷启动问题。
对于物品冷启动来说主要处理的是新加入系统的物品它们没有跟用户的交互信息。所以针对物品冷启动我们除了用类似用户冷启动的方式解决它以外还可以通过物品分类等信息找到一些相似物品如果这些相似物品已经具有了预训练的Embedding我们也可以采用相似物品Embedding平均的方式来快速确定冷启动物品的Embedding让它们通过Embedding的方式参与推荐过程。
## [《11召回层如何快速又准确地筛选掉不相关物品》](https://time.geekbang.org/column/article/299494)
留言问题3用户的多兴趣标签怎么与物品的标签进行最优匹配当物品的标签有多层时如何利用上一层的标签
<img src="https://static001.geekbang.org/resource/image/d7/26/d74e828bee4ddde80a2110b75e852026.jpeg" alt="">
这个问题最简单的做法就是把用户的兴趣标签和物品对应的标签都转换成Multi-hot向量然后我们就可以计算出用户和物品的相似度了。
除此之外,我们也可以进一步计算每个兴趣标签的[TF-IDF值](https://baike.baidu.com/item/tf-idf/8816134?fr=aladdin)为标签分配权重后再把它们转换成Multi-hot向量这样我们也可以计算出用户和物品的相似度。
如果标签有多层我们也可以把多层标签全部放到Multi-hot向量中再把高层标签的权重适当降低这也是可行的思路之一。
留言问题4在电商领域下如何解决EGES训练非常慢的问题
<img src="https://static001.geekbang.org/resource/image/7b/d8/7b6f322d2d0cb95c730a33e08a9d86d8.jpeg" alt="">
这是一个非常好的业界实践问题。这里我先给同学们解释一下什么是EGES。EGES指的是阿里提出的一种Graph Embedidng方法全称是Enhanced Graph Embedding with Side Information补充信息增强图Embedding。它是一种融合了经典的Deep Walk Graph Embedding结果和其他特征的Embedding方法。
针对EGES的训练比较慢的问题我这里有两条建议可供同学们参考。
第一条是我们可以把商品Embedding进行预训练再跟其他side information特征一起输入EGES不用直接在EGES中加Embedding层进行End2End训练。
第二条是我们可以把商品进行聚类后再输入EGES网络比如非常类似的商品可以用一个商品聚类id替代当作一个商品来处理。事实上这种方法往往可以大幅减少商品数量的量级AirBnb就曾经非常成功地应用了该方法用一些特征的组合来代替一类商品或用户不仅大幅加快训练速度而且推荐效果也没有受到影响。
## [《12局部敏感哈希如何在常数时间内搜索Embedding最近邻》](https://time.geekbang.org/column/article/301739)
留言问题5在用Item2vec等方法生成物品Embedding后用户的Embedding是怎么生成的呢 物品和用户在同一个向量空间,这是怎么保证的呢?
<img src="https://static001.geekbang.org/resource/image/d7/02/d74505c44e579c2aac7f8990b50d8102.jpeg" alt="">
在咱们的项目里用户Embedding的生成方法是很直观的就是对用户评论过的高分电影Embedding取平均值得到的。这相当于说用户Embedding是在物品Embedding向量空间中进行运算得到的那它们肯定是在一个向量空间内我们也就可以使用相似度计算来求取相似度。
因此只要是利用用户历史的Item Embedding生成的用户Embedding都是在一个向量空间内这些生成方式包括average pooling、sum pooling、attention等等。
但是如果用户Embedding和物品Embedding是分别独立生成的或者说是通过一个模型中没有直接关系的两个Embedidng层生成的那么它们就不在一个向量空间内了。注意啦这个时候我们不能直接求用户和物品之间的相似度只能求用户-用户的相似度,和物品-物品的相似度。
留言问题6“在局部敏感哈希的函数中b是0到w间的一个均匀分布随机变量是为了避免分桶边界固化”。这是什么意思呢是说可以通过调整b来形成另外一个个Hash函数
<img src="https://static001.geekbang.org/resource/image/40/db/40559a19901eb73aa9a7871b7cb973db.jpeg" alt="">
首先,我要说这个局部敏感哈希相关的问题非常好,推荐其他同学也关注一下。
说回到这个问题如果我们总是固定分桶的边界很容易让边界两边非常接近的点被分到两个桶里这是我们不想看到的。所以这里我们就可以通过随机调整b的方式来生成多个Hash函数在进行多个Hash函数的分桶之后再采用或的方式对分桶结果进行组合查找最近邻向量就可以一定程度避免这些边界点的问题。
好了这节课的答疑就到这里非常感谢同学们的积极提问和思考。希望在接下来的课程里你也能多多参与进来与我一起完善SparrowRecsys项目的代码共同进步