mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 22:23:45 +08:00
mod
This commit is contained in:
171
极客时间专栏/深度学习推荐系统实战/线上服务篇/09 | 线上服务:如何在线上提供高并发的推荐服务?.md
Normal file
171
极客时间专栏/深度学习推荐系统实战/线上服务篇/09 | 线上服务:如何在线上提供高并发的推荐服务?.md
Normal 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的压力下,事情还会变得更复杂。接下来,我们就来看第二个问题,说一说阿里、字节、腾讯这样级别的公司,使用了哪些策略来承接住每秒百万甚至是上千万推荐请求的。
|
||||
|
||||
说实话,想彻底讲清楚这个问题并不容易,因为大厂关于甚高并发具体的解决方案是集整个集团的技术精英打造的,而且维护一个高可用的服务集群的工作也不是一个算法工程师的主要工作方向。但这里,我还是希望你能够从宏观的角度了解高并发的主要解决方案,因为它是一个工业级推荐系统的重要组成部分,也是我们在与架构组配合工作时应有的知识储备。
|
||||
|
||||
宏观来讲,高并发推荐服务的整体架构主要由三个重要机制支撑,它们分别是**负载均衡、缓存、推荐服务降级机制。**下面,我们一一来看。
|
||||
|
||||
首先是负载均衡。它是整个推荐服务能够实现高可用、可扩展的基础。当推荐服务支持的业务量达到一定规模的时候,单独依靠一台服务器是不可行的,无论这台服务器的性能有多强大,都不可能独立支撑起高QPS(Queries 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("0.0.0.0", port);
|
||||
//创建Jetty服务器
|
||||
Server server = new Server(inetAddress);
|
||||
//创建Jetty服务器的环境handler
|
||||
ServletContextHandler context = new ServletContextHandler();
|
||||
context.setContextPath("/");
|
||||
context.setWelcomeFiles(new String[] { "index.html" });
|
||||
|
||||
|
||||
//添加API,getMovie,获取电影相关数据
|
||||
context.addServlet(new ServletHolder(new MovieService()), "/getmovie");
|
||||
//添加API,getuser,获取用户相关数据
|
||||
context.addServlet(new ServletHolder(new UserService()), "/getuser");
|
||||
//添加API,getsimilarmovie,获取相似电影推荐
|
||||
context.addServlet(new ServletHolder(new SimilarMovieService()), "/getsimilarmovie");
|
||||
//添加API,getrecommendation,获取各类电影推荐
|
||||
context.addServlet(new ServletHolder(new RecommendationService()), "/getrecommendation");
|
||||
//设置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("application/json");
|
||||
response.setStatus(HttpServletResponse.SC_OK);
|
||||
response.setCharacterEncoding("UTF-8");
|
||||
response.setHeader("Access-Control-Allow-Origin", "*");
|
||||
|
||||
//获得请求中的id参数,转换为movie id
|
||||
String movieId = request.getParameter("id");
|
||||
//从数据库中获取该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("");
|
||||
}
|
||||
|
||||
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
response.getWriter().println("");
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
熟悉了这个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="">
|
||||
|
||||
好了,推荐服务器的相关内容我就先讲到这里,下节课我会继续讲解线上服务的另一个主要的组成部分,存储模块。
|
||||
|
||||
## 课后思考
|
||||
|
||||
在一个高并发的推荐服务集群中,负载均衡服务器的作用至关重要,如果你是负载均衡服务器的策略设计师的话,你会怎么实现这个“工头”的调度策略,让它能够公平又高效的完成调度任务呢?(比如是按每个节点的能力分配?还是按照请求本身的什么特点来分配?如何知道什么时候应该扩展节点,什么时候应该关闭节点?)
|
||||
|
||||
欢迎把你的思考和答案写在留言区,也欢迎你把这节课分享给你的朋友,我们下节课见!
|
||||
151
极客时间专栏/深度学习推荐系统实战/线上服务篇/10 | 存储模块:如何用Redis解决推荐系统特征的存储问题?.md
Normal file
151
极客时间专栏/深度学习推荐系统实战/线上服务篇/10 | 存储模块:如何用Redis解决推荐系统特征的存储问题?.md
Normal 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推荐系统的架构图(如图1)吗?Netflix采用了非常经典的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和keys,value的数据类型用到了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 <- model.getVectors.keys) {
|
||||
//key的形式为前缀+movieId,例如i2vEmb:361
|
||||
//value的形式是由Embedding向量生成的字符串,例如 "0.1693846 0.2964318 -0.13044095 0.37574086 0.55175656 0.03217995 1.327348 -0.81346786 0.45146862 0.49406642"
|
||||
redisClient.set(redisKeyPrefix + ":" + movieId, model.getVectors(movieId).mkString(" "), params)
|
||||
}
|
||||
//关闭客户端连接
|
||||
redisClient.close()
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
**最后是在推荐服务器中把Redis数据读取出来。**
|
||||
|
||||
在服务器端,根据刚才梳理出的存储方案,我们希望服务器能够把所有物品Embedding阶段性地全部缓存在服务器内部,用户Embedding则进行实时查询。这里,我把缓存物品Embedding的代码放在了下面。
|
||||
|
||||
你可以看到,它的实现的过程也并不复杂,就是先用keys操作把所有物品Embedding前缀的键找出,然后依次将Embedding载入内存。
|
||||
|
||||
```
|
||||
//创建redis client
|
||||
Jedis redisClient = new Jedis(REDIS_END_POINT, REDIS_PORT);
|
||||
//查询出所有以embKey为前缀的数据
|
||||
Set<String> movieEmbKeys = redisClient.keys(embKey + "*");
|
||||
int validEmbCount = 0;
|
||||
//遍历查出的key
|
||||
for (String movieEmbKey : movieEmbKeys){
|
||||
String movieId = movieEmbKey.split(":")[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执行SET,GET等Redis常用操作的方法。
|
||||
|
||||
最后,我也把重要的知识点总结在了下面,你可以再回顾一下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5f/08/5f76090e7742593928eaf118d72d2b08.jpeg" alt="">
|
||||
|
||||
对于搭建一套完整的推荐服务来说,我们已经迈过了两大难关,分别是用Jetty Server搭建推荐服务器问题,以及用Redis解决特征存储的问题。下节课,我们会一起来挑战线上服务召回层的设计。
|
||||
|
||||
## 课后思考
|
||||
|
||||
你觉得课程中存储Embedding的方式还有优化的空间吗?除了string,我们是不是还可以用其他Redis value的数据结构存储Embedding数据,那从效率的角度考虑,使用string和使用其他数据结构的优缺点有哪些?为什么?
|
||||
|
||||
欢迎把你的思考和答案写在留言区,也欢迎你把这节课分享给你的朋友,我们下节课见!
|
||||
195
极客时间专栏/深度学习推荐系统实战/线上服务篇/11 | 召回层:如何快速又准确地筛选掉不相关物品?.md
Normal file
195
极客时间专栏/深度学习推荐系统实战/线上服务篇/11 | 召回层:如何快速又准确地筛选掉不相关物品?.md
Normal 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<Movie> candidateGenerator(Movie movie){
|
||||
ArrayList<Movie> candidates = new ArrayList<>();
|
||||
//使用HashMap去重
|
||||
HashMap<Integer, Movie> candidateMap = new HashMap<>();
|
||||
//电影movie包含多个风格标签
|
||||
for (String genre : movie.getGenres()){
|
||||
//召回策略的实现
|
||||
List<Movie> oneCandidates = DataManager.getInstance().getMoviesByGenre(genre, 100, "rating");
|
||||
for (Movie candidate : oneCandidates){
|
||||
candidateMap.put(candidate.getMovieId(), candidate);
|
||||
}
|
||||
}
|
||||
//去掉movie本身
|
||||
if (candidateMap.containsKey(movie.getMovieId())){
|
||||
candidateMap.remove(movie.getMovieId());
|
||||
}
|
||||
//最终的候选集
|
||||
return new ArrayList<>(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<Movie> multipleRetrievalCandidates(List<Movie> userHistory){
|
||||
HashSet<String> genres = new HashSet<>();
|
||||
//根据用户看过的电影,统计用户喜欢的电影风格
|
||||
for (Movie movie : userHistory){
|
||||
genres.addAll(movie.getGenres());
|
||||
}
|
||||
//根据用户喜欢的风格召回电影候选集
|
||||
HashMap<Integer, Movie> candidateMap = new HashMap<>();
|
||||
for (String genre : genres){
|
||||
List<Movie> oneCandidates = DataManager.getInstance().getMoviesByGenre(genre, 20, "rating");
|
||||
for (Movie candidate : oneCandidates){
|
||||
candidateMap.put(candidate.getMovieId(), candidate);
|
||||
}
|
||||
}
|
||||
//召回所有电影中排名最高的100部电影
|
||||
List<Movie> highRatingCandidates = DataManager.getInstance().getMovies(100, "rating");
|
||||
for (Movie candidate : highRatingCandidates){
|
||||
candidateMap.put(candidate.getMovieId(), candidate);
|
||||
}
|
||||
//召回最新上映的100部电影
|
||||
List<Movie> latestCandidates = DataManager.getInstance().getMovies(100, "releaseYear");
|
||||
for (Movie candidate : latestCandidates){
|
||||
candidateMap.put(candidate.getMovieId(), candidate);
|
||||
}
|
||||
//去除用户已经观看过的电影
|
||||
for (Movie movie : userHistory){
|
||||
candidateMap.remove(movie.getMovieId());
|
||||
}
|
||||
//形成最终的候选集
|
||||
return new ArrayList<>(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<Movie> retrievalCandidatesByEmbedding(User user){
|
||||
if (null == user){
|
||||
return null;
|
||||
}
|
||||
//获取用户embedding向量
|
||||
double[] userEmbedding = DataManager.getInstance().getUserEmbedding(user.getUserId(), "item2vec");
|
||||
if (null == userEmbedding){
|
||||
return null;
|
||||
}
|
||||
//获取所有影片候选集(这里取评分排名前10000的影片作为全部候选集)
|
||||
List<Movie> allCandidates = DataManager.getInstance().getMovies(10000, "rating");
|
||||
HashMap<Movie,Double> movieScoreMap = new HashMap<>();
|
||||
//逐一获取电影embedding,并计算与用户embedding的相似度
|
||||
for (Movie candidate : allCandidates){
|
||||
double[] itemEmbedding = DataManager.getInstance().getItemEmbedding(candidate.getMovieId(), "item2vec");
|
||||
double similarity = calculateEmbeddingSimilarity(userEmbedding, itemEmbedding);
|
||||
movieScoreMap.put(candidate, similarity);
|
||||
}
|
||||
|
||||
List<Map.Entry<Movie,Double>> movieScoreList = new ArrayList<>(movieScoreMap.entrySet());
|
||||
//按照用户-电影embedding相似度进行候选电影集排序
|
||||
movieScoreList.sort(Map.Entry.comparingByValue());
|
||||
|
||||
|
||||
//生成并返回最终的候选集
|
||||
List<Movie> candidates = new ArrayList<>();
|
||||
for (Map.Entry<Movie,Double> 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相似度的速度?
|
||||
|
||||
你理解的召回层也是这样吗?欢迎把你的思考和答案写在留言区。如果有收获,我也希望你能把这节课分享给你的朋友们。
|
||||
170
极客时间专栏/深度学习推荐系统实战/线上服务篇/12 | 局部敏感哈希:如何在常数时间内搜索Embedding最近邻?.md
Normal file
170
极客时间专栏/深度学习推荐系统实战/线上服务篇/12 | 局部敏感哈希:如何在常数时间内搜索Embedding最近邻?.md
Normal 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-tree(K-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指的就是分桶公式中的分桶宽度w,NumHashTables指的是多桶策略中的分桶次数。
|
||||
|
||||
清楚了模型中的关键参数,执行的过程就跟我们讲过的其他Spark MLlib模型一样了,都是先调用fit函数训练模型,再调用transform函数完成分桶的过程,具体的实现你可以参考下面的代码。
|
||||
|
||||
```
|
||||
def embeddingLSH(spark:SparkSession, movieEmbMap:Map[String, Array[Float]]): Unit ={
|
||||
//将电影embedding数据转换成dense Vector的形式,便于之后处理
|
||||
val movieEmbSeq = movieEmbMap.toSeq.map(item => (item._1, Vectors.dense(item._2.map(f => f.toDouble))))
|
||||
val movieEmbDF = spark.createDataFrame(movieEmbSeq).toDF("movieId", "emb")
|
||||
|
||||
|
||||
//利用Spark MLlib创建LSH分桶模型
|
||||
val bucketProjectionLSH = new BucketedRandomProjectionLSH()
|
||||
.setBucketLength(0.1)
|
||||
.setNumHashTables(3)
|
||||
.setInputCol("emb")
|
||||
.setOutputCol("bucketId")
|
||||
//训练LSH分桶模型
|
||||
val bucketModel = bucketProjectionLSH.fit(movieEmbDF)
|
||||
//进行分桶
|
||||
val embBucketResult = bucketModel.transform(movieEmbDF)
|
||||
|
||||
//打印分桶结果
|
||||
println("movieId, emb, bucketId schema:")
|
||||
embBucketResult.printSchema()
|
||||
println("movieId, emb, bucketId data result:")
|
||||
embBucketResult.show(10, truncate = false)
|
||||
|
||||
//尝试对一个示例Embedding查找最近邻
|
||||
println("Approximately searching for 5 nearest neighbors of the sample embedding:")
|
||||
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项目,如果能够被采纳,你将成为这一开源项目的贡献者之一。我们下节课再见!
|
||||
169
极客时间专栏/深度学习推荐系统实战/线上服务篇/13 | 模型服务:怎样把你的离线模型部署到线上?.md
Normal file
169
极客时间专栏/深度学习推荐系统实战/线上服务篇/13 | 模型服务:怎样把你的离线模型部署到线上?.md
Normal 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,存入内存数据库,再在线上实现逻辑回归或浅层神经网络等轻量级模型来拟合优化目标**”。
|
||||
|
||||
口说无凭,接下来,我们就来看一个业界实际的例子。我们先来看看下面这张模型结构图,这是阿里的推荐模型MIMN(Multi-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文件的library(库),JPMML项目分为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="$(pwd)/serving/tensorflow_serving/servables/tensorflow/testdata"
|
||||
|
||||
```
|
||||
|
||||
最后,我们在Docker中启动一个包含TensorFlow Serving的模型服务容器,并载入我们刚才下载的测试模型文件half_plus_two:
|
||||
|
||||
```
|
||||
# 启动TensorFlow Serving容器,在8501端口运行模型服务API
|
||||
docker run -t --rm -p 8501:8501 \
|
||||
-v "$TESTDATA/saved_model_half_plus_two_cpu:/models/half_plus_two" \
|
||||
-e MODEL_NAME=half_plus_two \
|
||||
tensorflow/serving &
|
||||
|
||||
```
|
||||
|
||||
在命令执行完成后,如果你在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 '{"instances": [1.0, 2.0, 5.0]}' \
|
||||
-X POST http://localhost:8501/v1/models/half_plus_two:predict
|
||||
|
||||
```
|
||||
|
||||
如果你看到了下图这样的返回结果,就说明TensorFlow Serving服务已经成功建立起来了。
|
||||
|
||||
```
|
||||
# 返回模型推断结果如下
|
||||
# Returns => { "predictions": [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服务,这为我们之后进行深度学习推荐模型的上线打好了基础。整个搭建过程非常简单,相信你跟着我的讲解就可以轻松完成。
|
||||
|
||||
## 课后思考
|
||||
|
||||
我们今天讲了如此多的模型服务方式,你能结合自己的经验,谈一谈你是如何在自己的项目中进行模型服务的吗?除了我们今天说的,你还用过哪些模型服务的方法?
|
||||
|
||||
欢迎在留言区分享你的经验,也欢迎你把这节课分享出去,我们下节课见!
|
||||
@@ -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作为特征数据库的方法,另一个是把模型部署到模型服务模块,我们也已讲过了预训练Embedding,Embedding加轻量级线上模型,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方法上的改进,你还有什么好的想法吗?
|
||||
|
||||
欢迎把你的成果和优化想法分享到留言区,也欢迎你能把这节课转发出去,让更多人从我们的实践中受益,我们下节课见!
|
||||
109
极客时间专栏/深度学习推荐系统实战/线上服务篇/答疑 | 线上服务篇留言问题详解.md
Normal file
109
极客时间专栏/深度学习推荐系统实战/线上服务篇/答疑 | 线上服务篇留言问题详解.md
Normal 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项目的代码,共同进步!
|
||||
Reference in New Issue
Block a user