头条项目推荐的相关技术(八):实时推荐业务流实现与AB测试

1. 写在前面

这里是有关于一个头条推荐项目的学习笔记,主要是整理工业上的推荐系统用到的一些常用技术, 这是第八篇, 上一篇文章介绍了离线排序模型训练与在线计算的相关内容,到这里为止, 推荐系统的离线部分已经结束了,通过离线,会得到用户召回结果并存储到了hbase, 而通过在线,也解决了用户的冷启动问题, 召回了热门文章,新文章存入了Redis,并玩完了通过内容的召回。 下面就开始实时推荐了,所以这篇文章主要介绍实时推荐业务流的实现流程以及ABTest技术。主要内容如下:

  • 实时推荐业务介绍(实时推荐的逻辑梳理)
  • grpc接口对接(参数介绍,grpc简介, rpc协议介绍,头条推荐接口服务端编写)
  • ABTest实验中心(ABTest及细节补充,流量切分)
  • 推荐中心逻辑
  • 召回集读取服务
  • 推荐缓存服务
  • 排序模型的在线预测

这篇文章又是知识量庞大啊哈哈, Ok, let’s go!

2. 实时推荐业务介绍

这里主要是看下实时推荐的逻辑,流程如下:

  1. 后端发送推荐请求, 实时推荐系统拿到请求参数 ---- > web后台与推荐系统的接口对接(grpc对接)
  2. 根据用户进行ABTest分流 ---- > ABTest实验中心, 用于分流任务, 方便测试调整不同的模型上线
    比如,我们在线下重新训练了一个w&d模型, 发现线下的AUC要远远高于之前的逻辑回归模型, 那么我们想将W&D部署到线上的时候,并不是一下子就抛弃逻辑回归的,而是要进行AB分流测试,让一部分用户用逻辑回归推荐, 另一部分用W&D模型推荐, 通过曝光与点击,我们最终再决定是不是真的W&D就比逻辑回归好,再去决定是不是完全替换。 有时候线下效果好,线上不一定的, 得慢慢来,缓冲一下子。
  3. 推荐中心服务 ---- > 根据用户在ABTest分配的算法进行召回服务和排序服务,并读取返回结果, 不同的模型和算法服务和读取方式会不一样,这一块就是为用户在不同的算法中读取推荐结果而服务的
  4. 返回推荐结果给web后台解析和埋点参数封装

实时推荐来个图看看:

在这里插入图片描述
看这个图再来理一下整体的实时推荐逻辑,首先用户先产生新文章请求(比如下拉一下子), 这时候用户请求传到后台,然后通过rpc接口与推荐系统对接,请求新文章, 推荐系统此时先去Redis缓存里面看看该用户下有没有新文章了,如果有,直接把这里面的文章推给用户。 如果没有了, 就去HBase中进行召回,这里面是存储了几百篇文章的候选的, 召回个新的几百篇,然后去排序,产生排序结果比如200篇。 此时,注意并不是立马推,为了提高热门文章和新文章的曝光比例,这里还会从热门和新文章取每个频道选出一部分, 比如20篇文章, 与排序结果进行混合, 按照一定的比例从新文章这边和排序那边取出个10篇,这个比例可以动态调整, 这10篇文章就可以推荐给当前用户, 剩下的210篇文章,存入Redis缓存里面去。这就是整体的一个实时推荐逻辑。

下面就是把上面的逻辑进行模块化一下,分成几个模块去完成上面的整个推荐流程:

在这里插入图片描述
下面进行每个模块的细节部分了。首先,要先和后台对接。

3. grpc接口对接

3.1 参数介绍和大体流程

首先,先看下接口对接时的请求参数(web后台传到推荐系统)和返回参数(推荐系统返回给web后台):

  • 请求参数:
    • feed流推荐:用户ID,频道ID,推荐文章数量,请求推荐时间戳
    • 相似文章获取:文章ID,推荐文章数量
  • 返回参数:
    • feed流推荐:曝光参数,每篇文章的所有行为参数,上一条时间戳
      这个格式是下面这样,也就是用户下拉一下子,比如我们给他曝光了4篇新文章:
      # 埋点参数参考:
      # {
      #     "param": '{"action": "exposure", "userId": 1, "articleId": [1,2,3,4],  "algorithmCombine": "c1"}',
      #     "recommends": [
      #         {"article_id": 1, "param": {"click": "{"action": "click", "userId": "1", "articleId": 1, "algorithmCombine": 'c1'}", "collect": "", "share": "","read":""}},
      #         {"article_id": 2, "param": {"click": "", "collect": "", "share": "", "read":""}},
      #         {"article_id": 3, "param": {"click": "", "collect": "", "share": "", "read":""}},
      #         {"article_id": 4, "param": {"click": "", "collect": "", "share": "", "read":""}}
      #     ]
      #     "timestamp": 1546391572
      # }
      
    注意,我们这里不仅是要返回曝光的文章id,还得把埋点的相关格式给返回回去。当用户点击其中的某篇文章的时候, web后台就立即根据埋点好的这种格式,将用户行为存储到日志里面,然后我们就能读取这种日志进行分析。

这对接的整个流程可以简化成下面这张表:

在这里插入图片描述
那么,拿什么东西去对接web呢?

3.2 gRPC简介

内部服务器之间调用服务的时候,应该拿什么去对接呢(通信)? 可以基于grpc协议去对接:

  • gRPC是由Google公司开源的高性能RPC框架。
  • gRPC支持多语言
  • gRPC原生使用C、Java、Go进行了三种实现,而C语言实现的版本进行封装后又支持C++、C#、Node、ObjC、 Python、Ruby、PHP等开发语言
  • gRPC支持多平台
  • 支持的平台包括:Linux、Android、iOS、MacOS、Windows
  • gRPC的消息协议使用Google自家开源的Protocol Buffers协议机制(proto3) 序列化
  • gRPC的传输使用HTTP/2标准,支持双向流和连接多路复用

使用方法:

  1. 使用Protocol Buffers(proto3)的IDL接口定义语言定义接口服务,编写在文本文件(以.proto为后缀名)中。
  2. 使用protobuf编译器生成服务器和客户端使用的stub代码

在gRPC中推荐使用proto3版本。

3.3 代码结构

3.3.1 Protocol Buffers版本

Protocol Buffers文档的第一行非注释行,为版本申明,不填写的话默认为版本2。

syntax = "proto3";   # 语法版本默认
或者
syntax = "proto2";

消息类型(用户的请求参数都可以成为消息类型, 比如请求参数当中有哪些字段,有哪些类型):Protocol Buffers使用message定义消息数据。在Protocol Buffers中使用的数据都是通过message消息数据封装基本类型数据或其他消息数据,对应Python中的类

message SearchRequest {
  # 消息字段以及编号  唯一且名字要固定
  string query = 1;
  int32 page_number = 2;
  int32 result_per_page = 3;
}

字段编号(这个字段编号指的上面那个数字编号): 消息定义中的每个字段都有唯一的编号。这些字段编号用于以消息二进制格式标识字段,并且在使用消息类型后不应更改。 请注意,1到15范围内的字段编号需要一个字节进行编码,包括字段编号和字段类型。16到2047范围内的字段编号占用两个字节。因此,您应该为非常频繁出现的消息元素保留数字1到15。请记住为将来可能添加的常用元素留出一些空间。

最小的标识号可以从1开始,最大到2^29 - 1,或 536,870,911。不可以使用其中的[19000-19999]的标识号, Protobuf协议实现中对这些进行了预留。如果非要在.proto文件中使用这些预留标识号,编译时就会报警。同样你也不能使用早期保留的标识号。

上面的消息字段里面的类型都是最基本的一些类型,但是如果我们如果有更加复杂的类型怎么办? 比如列表,字典等, 这时候需要我们指定字段规则,消息字段可以是以下之一

  • singular:格式良好的消息可以包含该字段中的零个或一个(但不超过一个)。

  • repeated:此字段可以在格式良好的消息中重复任意次数(包括零)。将保留重复值的顺序。对应Python的列表。

    message Result {
      string url = 1;
      string title = 2;
      repeated string snippets = 3;   # snippets可以存储多个文章了
    }
    

协议保存的时候,必须以proto协议保存进行保存。

添加更多消息类型: 可以在单个.proto文件中定义多个消息类型(在一个文件中添加多个message)。

message SearchRequest {
  string query = 1;
  int32 page_number = 2;
  int32 result_per_page = 3;
}

message SearchResponse {
 ...
}

安装protobuf编译器和grpc库: pip install grpcio-tools

编译生成代码: python -m grpc_tools.protoc -I. --python_out=.. --grpc_python_out=.. itcast.proto

  • -I表示搜索proto文件中被导入文件的目录
  • --python_out表示保存生成Python文件的目录,生成的文件中包含接口定义中的数据类型
  • --grpc_python_out表示保存生成Python文件的目录,生成的文件中包含接口定义中的服务类型

3.4 黑马头条推荐接口protoco协议定义

在reco_sys目录下面创建abtest目录,将相关接口代码放入user_reco.proto(abtext目录下)协议文件

  • 用户刷新feed流接口: user_recommend(User) returns (Track)
  • 文章相似(猜你喜欢)接口: article_recommend(Article) returns(Similar)

首先,在这个协议文件下面,定义好上面介绍的消息体,输入消息体和输出消息体,数据字段等信息。这是返回给web后台的参数,具体协议格式定义如下:

syntax = "proto3";   # 语法版本
# 消息以及字段及编号
message User {

    string user_id = 1;
    int32 channel_id = 2;
    int32 article_num = 3;
    int64 time_stamp = 4;
}
// int32 ---> int64 article_id
message Article {

    int64 article_id = 1;
    int32 article_num = 2;

}

message param2 {
    string click = 1;
    string collect = 2;
    string share = 3;
    string read = 4;
}

message param1 {              # 这是个字典
    int64 article_id = 1;
    param2 params = 2;       # param2这个玩意又是一个字典,上面
}

#这个东西对应的就是我们上面的埋点的那一坨参数
message Track {
    string exposure = 1;
    repeated param1 recommends = 2;  # repeated声明的一个recommends列表, 这就是日志格式的那一块
    int64 time_stamp = 3;
}

message Similar {
    repeated int64 article_id = 1;
}

# 下面是定义了两个函数,定义了那两个接口, 第一个接收的用户请求信息,然后的是Track,而这个Track,就是上面的埋点参数那三个东西
service UserRecommend {
    // feed recommend
    rpc user_recommend(User) returns (Track) {}
    # 第二个接口是相似文章的推荐,接收过来一个文章id和数量,返回的是相似文章
    rpc article_recommend(Article) returns(Similar) {}
}

通过命令生成: 这个是在服务器上进这个目录, 然后找到这个文件,然后python命令

python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. user_reco.proto

运行完了之后会生成两个.py文件。

在这里插入图片描述
这两个文件时用于编写客户端与服务器端的代码。

3.5 黑马头条grpc服务端编写

在abtest目录下面创建routing.py文件,填写服务端代码:

先导入相关包:

import os
import sys

BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(BASE_DIR))
from concurrent import futures

# 这两个东西还在服务器上, 得需要down下载到本地的pycharm,本地的文件能自动上传,但是远程的新文件得手动下载
from abtest import user_reco_pb2
from abtest import user_reco_pb2_grpc
from setting.default import DefaultConfig
import grpc
import time
import json

这里面首先定义一个服务函数, 这里面的逻辑基本上都是固定的,不用改,只需要给定端口, 和一个UserRecommendServicer()类即可。

def serve():

    # 多线程服务器
    server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
    # 注册本地服务
    user_reco_pb2_grpc.add_UserRecommendServicer_to_server(UserRecommendServicer(), server)
    # 监听端口
    server.add_insecure_port(DefaultConfig.RPC_SERVER)

    # 开始接收请求进行服务
    server.start()
    # 使用 ctrl+c 可以退出服务
    _ONE_DAY_IN_SECONDS = 60 * 60 * 24
    try:
        while True:
            time.sleep(_ONE_DAY_IN_SECONDS)
    except KeyboardInterrupt:
        server.stop(0)


if __name__ == '__main__':
    # 测试grpc服务
    serve()

这里的RPC_SERVER,我们依然是在setting里面的default.py里面进行定义:

# rpc  这个rpc其实就是个服务,所以我们需要给他一个服务端口
RPC_SERVER = '192.168.19.137:9999'

接下来比较重要的就是UserRecommendServicer()这个类的定义。这个东西必须继承自user_reco_pb2_grpc.py里面的UserRecommendServicer这个类,因为这个类里面就是我们在协议里面封装的那两个函数:

在这里插入图片描述
只有在继承了之后,我们才能写这两个方法的运行逻辑。具体的代码在这下面,但是在看代码之前,得梳理下下面的代码干啥事情,要不然读起来会很懵逼。 这里面的核心是user_recommend和article_recommend函数的编写。 对于user_commend函数, 其实在干这样的几个事情

  1. 接收参数解析封装:首先接收的参数是request和context,这个request就是我们接收的请求信息, 符合上面定义的消息体格式。 所以我们第一步就是先根据消息体里面的格式,拿到我们的user_id, channel_id, article_num和time_stamp, 表示某个用户在某个时间请求某个频道里面的文章数量。
  2. 去获取用户的abtest分流,到推荐中心去获取推荐结果,封装参数, 这个核心在于一个add_track函数,这个东西会把我们获取到的推荐结果封装成我们上面的埋点参数那样的格式。
  3. 下一步就是将上面获取到的这种格式, 再按照grpc定义的消息体格式,进行再封装,进行返回
# 基于用户推荐的rpc服务推荐
# 定义指定的rpc服务输入输出参数格式proto
class UserRecommendServicer(user_reco_pb2_grpc.UserRecommendServicer):
    """
    对用户进行技术文章推荐
    """
    def user_recommend(self, request, context):
        """
        用户feed流推荐
        :param request:
        :param context:
        :return:
        """
        # 选择C4组合  第一步的逻辑
        user_id = request.user_id
        channel_id = request.channel_id
        article_num = request.article_num
        time_stamp = request.time_stamp

        # 解析参数,并进行推荐中心推荐(暂时使用假数据替代)    第二步的逻辑
        class Temp(object):
            user_id = -10
            algo = 'test'
            time_stamp = -10

        tp = Temp()
        tp.user_id = user_id
        tp.time_stamp = time_stamp
        _track = add_track([], tp)

        # 解析返回参数到rpc结果参数
        # 参数如下
        # [       {"article_id": 1, "param": {"click": "", "collect": "", "share": "", 'detentionTime':''}},
        #         {"article_id": 2, "param": {"click": "", "collect": "", "share": "", 'detentionTime':''}},
        #         {"article_id": 3, "param": {"click": "", "collect": "", "share": "", 'detentionTime':''}},
        #         {"article_id": 4, "param": {"click": "", "collect": "", "share": "", 'detentionTime':''}}
        #     ]
        # 第二个rpc参数
        # 第三步的逻辑    这里从后面往前看比较好理解,结合上上面定义的消息体返回格式来理解
        # 这里封装_param1里面的消息体类型
        _param1 = []
        for _ in _track['recommends']:
            # 封装_param2的消息体
            _params2 = user_reco_pb2.param2(click=_['param']['click'],
                                           collect=_['param']['collect'],
                                           share=_['param']['share'],
                                           read=_['param']['read'])
            # 封装_param1的消息体
            _param1 = user_reco_pb2.param1(article_id=_['article_id'], params=_params)
            _param1.append(_p2)
        # param  这里_track里面都封装好了
        return user_reco_pb2.Track(exposure=_track['param'], recommends=_param1, time_stamp=_track['timestamp'])

#    def article_recommend(self, request, context):
#        """
#       文章相似推荐
#       :param request:
#       :param context:
#       :return:
#       """
#       # 获取web参数
#       article_id = request.article_id
#       article_num = request.article_num
#
#        # 进行文章相似推荐,调用推荐中心的文章相似
#       _article_list = article_reco_list(article_id, article_num, 105)
#
#       # rpc参数封装
#       return user_reco_pb2.Similar(article_id=_article_list)

这里的一个重点: 封装rpc协议返回结果的时候,一定要从后往前去封装, 先看整体的消息体, 然后再往里面看,一层层的往上,要不然都不知道准备哪些东西。比如上面的那个,我先封装Track,这个里面又发现param1消息体,这个是个列表,我倒数第二步进行封装, 在这里又发现了需要param2消息体,所以再往上拿到param2的消息体这样就OK了。

下面就是add_track函数的实现过程, 这里明白干了个啥事就好理解了,见上面的逻辑2:

def add_track(res, temp):
    """
    封装埋点参数
    :param res: 推荐文章id列表
    :param cb: 合并参数
    :param rpc_param: rpc参数
    :return: 埋点参数
        文章列表参数
        单文章参数
    """
    # 添加埋点参数
    track = {}

    # 准备曝光参数
    # 全部字符串形式提供,在hive端不会解析问题
    _exposure = {"action": "exposure", "userId": temp.user_id, "articleId": json.dumps(res),
                 "algorithmCombine": temp.algo}

    track['param'] = json.dumps(_exposure)
    track['recommends'] = []

    # 准备其它点击参数
    for _id in res:
        # 构造字典
        _dic = {}
        _dic['article_id'] = _id
        _dic['param'] = {}

        # 准备click参数
        _p = {"action": "click", "userId": temp.user_id, "articleId": str(_id),
              "algorithmCombine": temp.algo}

        _dic['param']['click'] = json.dumps(_p)
        # 准备collect参数
        _p["action"] = 'collect'
        _dic['param']['collect'] = json.dumps(_p)
        # 准备share参数
        _p["action"] = 'share'
        _dic['param']['share'] = json.dumps(_p)
        # 准备detentionTime参数
        _p["action"] = 'read'
        _dic['param']['read'] = json.dumps(_p)

        track['recommends'].append(_dic)

    track['timestamp'] = temp.time_stamp
    return track

这里有些难度了,只玩了一下接收用户的请求然后去进行推荐,没弄猜你喜欢的这种文章推荐。这里还提供了一份测试代码,这里就不测试了,太长了这东西, 并且感觉写出来也没有意义,到时候真用着的时候,直接去参考文档吧。 这地方感觉能理清楚逻辑,知道是怎么走的就可以了。

下面梳理一下上面的四大步骤:

  1. 定义黑马接口文件
  2. 使用grpc工具生成两个文件,用于编写客户端和服务器端代码
  3. 编写服务器端代码
  4. 测试端代码进行测试

4. ABTest实验中心

个性化推荐系统、搜索引擎、广告系统,这些系统都需要在线上不断上线,不断优化,优化之后怎么确定是好是坏。这时就需要ABTest来确定,最近想的办法、优化的算法、优化的逻辑数据是正向的,是有意义的,是提升数据效果的。

4.1 ABTest

有几个重要的功能:

  • 一个是ABTest实时分流服务, 根据用户设备信息,用户信息进行ab分流
  • 实时效果分析统计, 将分流后程序点击,浏览等通过hive,hadoop程序统计后, 在统计平台上进行展示。

在这里插入图片描述

模型替换一定要观察一个长期的数据分析结果,当我们有了一个新算法的时候,并且在线下验证,新算法要比原来算法好的时候,我们是不能直接放到线上替换掉原来算法的, 得需要通过AB测试,也就是先一小部分流量用户使用新算法测试, 大部分用户还是使用原来的算法,这样进行一个对比, 如果经过很长的一段时间, A算法都比原来的算法好了, 这时候,我们才能证明提出的算法线上是有效的。此时,会慢慢的将B算法替换掉。

4.2 关于ABTest更多的细节补充

这一块是参考了王喆老师的《深度学习推荐系统》对ABTest进行细节的一些补充,ABTest是面试常考的一个问题,不仅知道要怎么做,还得用比较专业的术语描述它的原理哈哈。

A/B测试又称为分流或者分桶测试,是一个随机试验,通常分为实验组和对照组。 在控制变量的前提呀,将AB两组数据对比,得出结论。 在互联网算法测试中,可随机将用户分为实验组和对照组,对实验组用户施以新模型,对照组用户用旧模型, 比较这两组数据在线上评估指标上的差异。

相比离线评估,线上A/B测试无法被替代原因:

  • 离线评估无法完全消除数据有偏现象的影响,因此线性评估的结果可能无法替代线上
  • 离线评估无法完全还原线上的工程关键,一般离线评估不考虑线上环境的延迟,数据丢失,标签数据缺失等,所以得出的结果存在失真
  • 线上系统的某些商业指标在离线评估中无法计算,离线评估往往是ROC曲线,PR曲线等,而线上常用用户点击率,留存时长,PV访问量(页面浏览量或者点击量)等变化。 这些都由A/B测试全面评估。

    在互联网行业中,用户在某段时间内开始使用应用,经过一段时间后,仍然继续使用该应用的用户,被认作是留存用户。这部分用户占当时新增用户的比例即是留存率,会按照每隔1单位时间(例日、周、月)来进行统计。顾名思义,留存指的就是“有多少用户留下来了”。留存用户和留存率体现了应用的质量和保留用户的能力。

实际A/B测试场景中, 同一个应用往往会同时进行多组不同的A/B测试,比如前端APP界面A/B,业务层中间件效率A/B,算法层的不同推荐场景的A/B测试,那么这时候得指定一些原则,使得各个层不能干扰。两个原则:

  1. 层与层之间的流量“正交”:层与层之间的独立实验流量是正交的,即实验中每组的流量穿越该层后,都会被再次随机打散,且均匀分布。看下面这个图: X层的流量进入Y层之后,要被随机打散,这样才能保证Y层这边的无偏。否则, Y层这边将会受到X这边的影响。
    在这里插入图片描述

  2. 同层之间的流量“互斥”:

    1. 如果是同层之间多组A/B测试,那么不同测试之间的流量互斥,即不重叠。
    2. 一组A/B测试中,实验组和对照组的流量也必须互斥

推荐系统中,用户体验一致性特别重要,所以在A/B测试中,保证同一个用户始终分配在同一个组是必要的。

A/B测试是模型上线的最后一道测试,通过A/B测试检验的模型直接服务于线上用户,完成公司的商业目标。因此A/B测试的指标应该与线上业务的和核心指标保持一致。

下面是各类推荐场景下线上业务的核心指标:

在这里插入图片描述

离线评估不具备直接计算业务核心指标的调节,因此选择了偏向于技术评估的模型相关指标,而公司里面,更关心能够驱动业务发展的核心指标,因此,利用A/B测试验证模型对业务的核心指标的提升效果是必要的,所以A/B测试的作用离线评估永远无法代替。

4.3 流量切分

A/B测试的流量切分是在Rank Server端完成的。我们根据用户ID将流量切分为多个桶(Bucket),每个桶对应一种排序策略,桶内流量将使用相应的策略进行排序。使用ID进行流量切分,是为了保证用户体验的一致性。

在这里插入图片描述
分桶原则:样本的独立性和采样方式的无偏, 即同一个用户在测试全程只能被分到同一个桶,在分桶过程中所用用户ID是随机数,这样才能保证无偏。

下面是abtest的实验参数代码,这个放到setting目录的default.py文件中。依然是梳理逻辑,这里面定义了什么东西: 首先,定义了两个新的算法Algo-1和Algo-2, 这两个算法封装到了字典里面, 对应的值是一个元组, 表示编号, 读取的召回集, 采用的排序策略等。 而召回集这里,是直接定义了之前整理到的5中召回方式als模型召回,内容召回, 在线召回,新文章召回和热门文章召回。也就是这两种算法都是直接可以读取这5种召回集。

from collections import namedtuple

# abtest参数信息
# ABTest参数
param = namedtuple('RecommendAlgorithm', ['COMBINE',
                                          'RECALL',
                                          'SORT',
                                          'CHANNEL',
                                          'BYPASS']
                   )

RAParam = param(
    COMBINE={
        'Algo-1': (1, [100, 101, 102, 103, 104], []),  # 首页推荐,所有召回结果读取+LR排序
        'Algo-2': (2, [100, 101, 102, 103, 104], [])  # 首页推荐,所有召回结果读取 排序
    },
    RECALL={
        100: ('cb_recall', 'als'),  # 离线模型ALS召回,recall:user:1115629498121 column=als:18
        101: ('cb_recall', 'content'),  # 离线word2vec的画像内容召回 'recall:user:5', 'content:1'
        102: ('cb_recall', 'online'),  # 在线word2vec的画像召回 'recall:user:1', 'online:1'
        103: 'new_article',  # 新文章召回 redis当中    ch:18:new
        104: 'popular_article',  # 基于用户协同召回结果 ch:18:hot
        105: ('article_similar', 'similar')  # 文章相似推荐结果 '1' 'similar:2'
    },
    SORT={
        200: 'LR',
    },
    CHANNEL=25,
    BYPASS=[
            {
                "Bucket": ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd'],
                "Strategy": "Algo-1"
            },
            {
                "BeginBucket": ['e', 'f'],
                "Strategy": "Algo-2"
            }
        ]
)

上面的BYPASS指的是具体的分桶策略,也就是具体哪些用户用哪个算法进行测试, Bucket里面是指定的编号,下面会通过md5算法产生随机数,如果随机数在上面的那些里面,就走第一种算法,如果在下面的里面,就走下面的算法。参数都已经定义好了,那么到底怎么进行流量的切分呢?

4.4 实验中心流量切分

这里解决的问题就是某个用户过来,怎么进行分桶呢? 这里使用了一种md5的分桶方式。ABTest分流逻辑实现代码如下

  • import hashlib
  • from setting.default import DefaultConfig, RAParam

这东西在干个什么样的事情呢? 就是当前端的用户请求过来了,我们通过上面的grpc接口拿到用户请求的那4个基本信息(user_id, channel_id, article_num, time_stamp),接下来判断下id是否为空,如果是空的话就不用管,如果不是空, 那么就进行分桶实现分流, 这里直接用了一种md5的哈希映射得到

接下来的话,就是add_track就封装推荐结果,然后再封装成grpc定义的消息体格式返回回去。

def feed_recommend(user_id, channel_id, article_num, time_stamp):
    """
    1、根据web提供的参数,进行分流
    2、找到对应的算法组合之后,去推荐中心调用不同的召回和排序服务
    3、进行埋点参数封装
    :param user_id:用户id
    :param article_num:推荐文章个数
    :return: track:埋点参数结果: 参考上面埋点参数组合
    """

    #  产品前期推荐由于较少的点击行为,所以去做 用户冷启动 + 文章冷启动
    # 用户冷启动:'推荐'频道:热门频道的召回+用户实时行为画像召回(在线的不保存画像)  'C2'组合
    #            # 其它频道:热门召回 + 新文章召回   'C1'组合
    # 定义返回参数的类
    class TempParam(object):
        user_id = -10
        channel_id = -10
        article_num = -10
        time_stamp = -10
        algo = ""

    temp = TempParam()
    temp.user_id = user_id
    temp.channel_id = channel_id
    temp.article_num = article_num
    # 请求的时间戳大小
    temp.time_stamp = time_stamp

    # 先读取缓存数据redis+待推荐hbase结果
    # 如果有返回并加上埋点参数
    # 并且写入hbase 当前推荐时间戳用户(登录和匿名)的历史推荐文章列表

    # 传入用户id为空的直接召回结果
    if temp.user_id == "":
        temp.algo = ""
        return add_track([], temp)
    # 进行分桶实现分流,制定不同的实验策略
    bucket = hashlib.md5(user_id.encode()).hexdigest()[:1]
    if bucket in RAParam.BYPASS[0]['Bucket']:
        temp.algo = RAParam.BYPASS[0]['Strategy']     # 第一种推荐算法
    else:
        temp.algo = RAParam.BYPASS[1]['Strategy']    # 第二种推荐算法

    # 推荐服务中心推荐结果(这里做测试)
    track = add_track([], temp)
    return track

这里的分桶实现分流代码,就是

bucket = hashlib.md5(user_id.encode()).hexdigest()[:1]   # 产生一个16位的随机数

所以把上面的分桶分流代码写到上面user_recommend函数的track那句代码的上面就可以啦。

这样第一块ABTest就介绍完毕。 下面是推荐中心服务。

5. 推荐中心逻辑

推荐中一般作为整体召回结果读取与排序模型进行排序过程的作用,主要是产生推荐结果的部分。在reco_sys下面建立一个server的目录,来作为整个推荐中心的目录。

1
server目录为整个推荐中心建立的目录

  • recall_service.py: 召回数据读取目录
  • reco_center.py: 推荐中心逻辑代码
  • redis_cache.py: 推荐结果缓存目录
  • utils: 装有自己封装的HBase数据库读取存储工具

5.1 推荐中心推荐存储设计

在线推荐部分也会有个数据库来存储实时推荐结果的, 这就是推荐中心的存储设计:

  • 实时推荐产生推荐结果的地方
  • 设计HBase数据表的存储结果

HBase数据库表设计:

  • wait_recommend: 经过各种多路召回,排序之后的待推荐结果保存

    • 只要刷新一次,没有缓存,才主动收集各种召回集合一起给wait_recommend写入,所以不用设置多个版本
  • history_recommend: 每次真正推荐出去给用户的历史推荐结果列表

    1. 按照频道存储用户的历史推荐结果, 用户万一要想看历史文章的时候就从这里面拿
    2. 需要保留多个版本,需要建立版本信息
    3. 用户在翻阅历史记录时提供

在HBase中建表:

create 'wait_recommend', 'channel'

put 'wait_recommend', 'reco:1', 'channel:18', [17283, 140357, 14668, 15182, 17999, 13648, 12884, 17302, 13846, 18135]
put 'wait_recommend', 'reco:1', 'channel:0', [17283, 140357, 14668, 15182, 17999, 13648, 12884, 17302, 13846, 18135]

创建历史的base结果,这里依然要加版本以及TTL,要保留多个历史版本:

create 'history_recommend', {NAME=>'channel', TTL=>7776000, VERSIONS=>999999}   86400
# 每次指定一个时间戳,可以达到不同版本的效果
put 'history_recommend', 'reco:his:1', 'channel:18', [17283, 140357, 14668, 15182, 17999, 13648, 12884, 17302, 13846, 18135]

# 这时候保存,HBase是会给每条记录指定一个时间戳的, 这时候,如果想查询某个时间内的历史记录,就可以直接根据这个时间戳去查询。
# 放入历史数据,存在时间戳,到时候取出历史数据就是每个用户的历史时间戳可以
get "history_recommend", 'reco:his:1', {COLUMN=>'channel:18',VERSIONS=>1000, TIMESTAMP=>1546242869000}

这里与上次召回cb_recall以及history_recall有不同用处:

  • history_recall: 存放召回过的数据, 用户过滤推荐初始的产生结果
  • history_recommend: 存放的是某个用户在某频道的真正推荐过的历史记录
    • 同时过滤掉新文章和热门文章的推荐结果

5.2 feed流推荐中心逻辑

目的:根据ABTest分流之后的用户,进行制定算法的召回和排序读取

步骤:

  1. Hbase数据库工具封装的介绍

  2. feed时间戳进行推荐逻辑判断 — 根据用户的刷新时间来推荐

  3. 读取召回结果(无实时排序)

5.2.1 HBase读取存储等工具类封装

为什么封装? 在写happybase代码的时候会有过多的重复代码,将这些封装成简便的工具,减少代码冗余

包含方法:

  • get_table_row(self, table_name, key_format, column_format=None, include_timestamp=False): 获取具体表中的键、列族中的行数据
  • get_table_cells(self, table_name, key_format, column_format=None, timestamp=None, include_timestamp=False): 获取Hbase中多个版本数据
  • get_table_put(self, table_name, key_format, column_format, data, timestamp=None): 存储数据到Hbase当中
  • get_table_delete(self, table_name, key_format, column_format): 删除Hbase中的数据

关于这里面的具体逻辑代码,这里就不放上来了,太多了,并且由于都是HBase的一些插入删除等操作, 没有啥意义。

5.2.2 增加feed_recommend_logic函数,进行时间戳逻辑判断

这一部分挺重要的, 用户下拉表示刷新,用户上拉表示看历史记录,那么我们是怎么样确定这两种行为的呢? 那就是加一个时间戳。这时候我们就可以通过传过来的时间戳大小来判断用户的这两种行为。

  • 时间戳T小于HBASE历史推荐记录,则获取历史记录,返回该时间戳T上次的时间戳T-1
  • 时间戳T大于HBASE历史推荐记录,则获取新推荐,则获取HBASE数据库中最近的一次时间戳
    • 如果有缓存,从缓存中拿,并且写入推荐历史表中
    • 如果没有缓存,就进行一次指定算法组合的召回结果读取,排序,然后写入待推荐wait_recommend中,其中推荐出去的放入历史推荐表中

那么代码具体怎么做呢? 我们首先会写一个feed_recommend_logic函数, 这里面接收的是一个temp参数,这个是我们通过rpc接口接收的那4个用户请求的参数(user_id, channel_id, article_num, time_stamp), 也就是我们拿到了用户请求, 下面看代码的逻辑:

def feed_recommend_logic(self, temp):
	"""推荐流业务逻辑"""
	:param temp: ABTest传入的业务请求参数

	# 判断用户请求的时间戳大小,来决定是获取用户历史记录还是刷新文章
	# 这里是获取用户历史记录里面最后的一个时间戳
	try:
        last_stamp = self.hbu.get_table_row('history_recommend', 'reco:his:{}'.format(temp.user_id).encode(),'channel:{}'.format(temp.channel_id).encode(), include_timestamp=True)[1]
    # 如果用户没有历史记录,就置为0
    # 实际代码中是有日志的,通过打印的日志来看是否异常,我这里现代码太乱,看不清逻辑,所以这里都去掉了。
    except Exception as e:
        last_stamp = 0
	
	# 2. 这里是拿用户传过来的新请求时间戳与用户历史记录的最后一个时间戳对比
	# 如果用户新请求时间戳大于历史记录时间戳,说明此时用户想要新文章, 那么就从缓存里面获取新文章
	# 如果缓存里面没有新文章, 就走一遍召回+排序,同时写入到HBase待推荐结果列表
	if last_stamp < temp.time_stamp:
        # 获取缓存结果
        res = redis_cache.get_reco_from_cache(temp, self.hbu)
        # 如果没有,然后走一遍算法推荐 召回+排序,同时写入到hbase待推荐结果列表
        if not res:
            res = self.user_reco_list(temp)
            temp.time_stamp = int(last_stamp)
            track = add_track(res, temp)
     # 如果历史时间戳大于用户请求的这次时间戳,那么就是在获取历史记录
     # 用户请求的历史时间戳是具体某个历史记录的时间戳T
     else:  
     	try:
            row = self.hbu.get_table_cells('history_recommend',
                                          'reco:his:{}'.format(temp.user_id).encode(),
                                          'channel:{}'.format(temp.channel_id).encode(),
                                          timestamp=temp.time_stamp + 1,  
                                          include_timestamp=True)
           # 这里的row是这样的格式:
           # [[1559148615353, [1530,...], [], []]    时间戳, 召回结果的形式,有可能是多条历史记录
           # 这里时间戳加1的原因是HBase本身时间戳定义时候的一个特点
           # 就是如果我们传入的是当前的某个时间戳比如1559148615353, HBase只能拿到比这个时间小的时间戳的数据,但其实我们是想拿当前时间戳的数据的,所以得需要加1,这是HBase比较坑的地方,这样才能获取所有当前的历史记录
           # 还有个坑的地方,用户请求的时间戳格式(后台传的)其实是和HBase里面的格式不一样的
           # Hbase的时间戳是time.time() * 1000 才是与web后台传入的类型一样,这个在前面的某个地方改了
        except Exception as e:   
            row = []
            res = []
        
        # 1、如果没有历史数据,返回时间戳0以及结果空列表
        # 2、如果历史数据只有一条,返回这一条历史数据以及时间戳正好为请求时间戳,修改时间戳为0
        # 表示后面请求以后就没有历史数据了(APP的行为就是翻历史记录停止了)
        # 3、如果历史数据多条,返回最近一条历史数据,然后返回之后第二条历史数据的时间戳
        if not row:   # 没有历史记录
            temp.time_stamp = 0
            res = []
        elif len(row) == 1 and row[0][1] == temp.time_stamp:  # 只有一条历史记录且正好是用户想请求的
            res = eval(row[0][0])    # 注意这里的row是bytes类型的,得通过eval才能转成列表
            temp.time_stamp = 0
        elif len(row) >= 2:   # 有多条历史记录,只返回最上面那个,但注意返回的时间戳要置为下面一条历史记录的时间戳
            res = eval(row[0][0])    
            temp.time_stamp = int(row[1][1])
       
       res = list(map(int, res))
       track = add_track(res, temp)   # 这里同样也得封装下 json的格式
       # 曝光参数设置为空
       track['param'] = ''
    return track

self.bu这个东西,其实就是把happse的各个功能进行封装后传过来的一个对象。这样代码的冗余量就小了。然后就是要注意这里历史记录里面时间戳的返回逻辑了。这里返回的时间戳是当前历史记录的上一条时间戳, 这样用户再请求历史记录的时候,就根据这个时间戳判断,因为之前的历史记录用户已经翻阅过了。 另外还要注意的就是HBase时间戳格式的那两个小坑。

这个函数在server目录下面重新创建一个reco_center.py, 把这个函数写进去,然后再修改ABTest中的推荐调用:

from server.reco_center import RecoCenter
# 推荐   这一行加入到上面的feed_recommend的倒数第二行那里, 然后再修改之前user_recommend里面的一点逻辑
track = RecoCenter().feed_recommend_logic(temp)

这样,只要再修改user_recommend里面的一点逻辑,就把ABTest和用户下拉,上拉进行推荐的功能加入了进去。 下面还有一块没有解决,就是上面的用户请求新文章的这块的实现,也就是当用户的请求时间戳大于当前用户历史记录的时间戳,说明要给用户进行新文章的推荐了,这时候两个逻辑:

  • 读取召回服务, 也就是去读取召回结果
  • 读取排序服务,得到新的推荐结果

下面看这两块的主要逻辑及实现。

6. 召回集读取服务

这里实现的是从推荐中心服务去读取召回服务这块的逻辑,也就是召回集读取与推荐中心的对接。在server目录下面创建一个recall_service.py文件,用来写召回集的读取服务代码。

6.1 多路召回结果读取

目的: 读取离线和在线存储的召回结果

  • HBase已经存储的召回集: cb_recall, als, content, online

步骤:

  1. 初始化Redis, HBase相关工具
  2. 在线画像召回,离线画像召回,离线协同召回数据的读取
  3. Redis新文章和热门文章结果读取
  4. 相似文章读取接口

关于Redis和HBase的初始化,这里整理了。主要看看召回结果读取的逻辑吧。

init文件中添加相关初始化数据库变量

import redis
import happybase
from setting.default import DefaultConfig
from pyspark import SparkConf
from pyspark.sql import SparkSession


pool = happybase.ConnectionPool(size=10, host="hadoop-master", port=9090)

# 加上decode_responses=True, 写入键值对中的value为str类型, 不加这个参数写入的则为字节类型
redis_client = redis.StrictRedis(host=DefaultConfig.REDIS_HOST,
                                 port=DefaultConfig.REDIS_PORT,
                                 db=10,
                                 decode_responses=True) 
6.1.1 在线画像召回,离线画像召回,离线协同召回数据的读取

读取用户的指定列族的召回数据,并且读取之后要删除原来的推荐召回结果'cb_recall'

def read_hbase_recall_data(self, table_name, key_format, column_format):
    """
    读取cb_recall当中的推荐数据
	table_name: 表名
	key_format: 哪个用户
	column_format: 读取的时候可以选择列族进行读取als, online, content
    :return:
    """
    recall_list = []
    try:
        data = self.hbu.get_table_cells(table_name, key_format, column_format)

        # data是多个版本的推荐结果[[],[],[],],所以下面这个代码是把这多个列表结果进行合并
        for _ in data:
            recall_list = list(set(recall_list).union(set(eval(_))))

        # self.hbu.get_table_delete(table_name, key_format, column_format)
    except Exception as e:
            # 打印日志
    return recall_list
  
# 召回结果的读取封装
# print(rr.read_hbase_recall_data('cb_recall', b'recall:user:1114864874141253632', b'online:18'))
6.1.2 Redis新文章和热门文章结果读取

新文章读取逻辑,这里就比较简单了,拿到频道号,然后直接从Redis里面读取相应的键即可。

 def read_redis_new_article(self, channel_id):
    """
    读取新闻章召回结果
    :param channel_id: 提供频道
    :return:
    """
    _key = "ch:{}:new".format(channel_id)
    try:   # 这里直接去取数据即可
        res = self.client.zrevrange(_key, 0, -1)
    except Exception as e:
        res = []
    return list(map(int, res))

热门文章读取:热门文章记录了很多,可以选取前K个

def read_redis_hot_article(self, channel_id):
    """
    读取新闻章召回结果
    :param channel_id: 提供频道
    :return:
    """
    _key = "ch:{}:hot".format(channel_id)
    try:
        res = self.client.zrevrange(_key, 0, -1)
    except Exception as e:
        res = []

    # 由于每个频道的热门文章有很多,因为保留文章点击次数
    res = list(map(int, res))
    if len(res) > self.hot_num:
        res = res[:self.hot_num]
    return res
6.1.3 相似文章读取接口

会有接口获取固定的文章数量(用在黑马头条APP中的猜你喜欢接口)

def read_hbase_article_similar(self, table_name, key_format, article_num):
    """获取文章相似结果
    :param article_id: 文章id
    :param article_num: 文章数量
    :return:
    """
    # 第一种表结构方式测试:
    # create 'article_similar', 'similar'
    # put 'article_similar', '1', 'similar:1', 0.2
    # put 'article_similar', '1', 'similar:2', 0.34
    try:
        _dic = self.hbu.get_table_row(table_name, key_format)

        res = []
        _srt = sorted(_dic.items(), key=lambda obj: obj[1], reverse=True)
        if len(_srt) > article_num:
            _srt = _srt[:article_num]
        for _ in _srt:
            res.append(int(_[0].decode().split(':')[1]))
    except Exception as e:
        res = []
    return res

最后把上面的四个代码封装到一个ReadRecall类里面去即可。这样就能把各种召回结果给获取到了,接下来就是在推荐中心里面去获取。

6.2 推荐中心 — 获取多路召回结果,过滤历史推荐记录逻辑

这里实现的推荐服务中心的重新走一遍召回 + 排序,同时写入到HBase推荐结果列表的逻辑:

在这里插入图片描述

目的: 在推荐中加入召回文章结果及历史文章过滤的逻辑

步骤:

  1. 循环算法组合参数,遍历不同召回结果进行过滤
  2. 过滤当前该请求频道推荐历史结果,如果不是0频道,需要过滤0频道推荐结果,防止出现推荐频道与25个频道有重复推荐
  3. 过滤之后, 推荐出去指定个数的新文章列表,写入历史记录,剩下多的写入缓存结果。

在reco_center.py里面定义一个user_reco_list函数,实现读取用户的召回结果。

def user_reco_list(self, temp):
	"""用户下拉刷新获取新数据的逻辑"""
	#上面的三大步
	# 6.2.1
	# 6.2.2
	# 6.2.3
6.2.1 循环算法组合参数, 遍历不同召回结果进行过滤
reco_set = []

# 这里需要上面导入RAparm的结果,需要读取具体的某个算法的召回组合列表
# (1, [100, 101, 102, 103, 104], [])
for number in RAparam.COMBINE[temp.algo][1]:
	if number == 103:   # 这个是新文章读取
		_res = self.recall_service.read_redis_new_article(temp.channel_id)
		reco_set = list(set(reco_set).union(set(_res)))
	elif number == 104: # 热门文章召回
		_res = self.recall_service.read_redis_host_article(temp.channel_id)
		reco_set = list(set(reco_set).union(set(_res)))
	else:
		# 101, 102, 100 召回结果读取
		_res = self.recall_service.read_hbase_recall(RAParam.RECALL[number][0],
													 'recall:user:{}'.format(temp.user_id).encode(),
													 '{}:{}'.format(RAParam.RECALL[number][1], temp.channel_id).encode()	
													)
	   reco_set = list(set(reco_set).union(set(_res)))

这样,其实就已经得到了召回列表reco_set了, 但是这个召回结果里面可能有历史推荐过的,所以接下来的一步就是过滤。

6.2.2 过滤当前该请求频道推荐历史结果

过滤当前该请求频道推荐历史结果,如果不是0频道,需要过滤0频道推荐结果,防止出现推荐频道与25个频道有重复推荐。0频道是推荐频道,也就是其他各个频道的文章都可能在0频道出现。

下面看代码,接着上面往下

history_list = []

# 当前频道
try:
	data = self.hbu.get_table_cells('history_recommend',
									'reco:hist:{}'.format(temp.user_id).encode(),
									'channel:{}'.format(temp.channel_id).encode())
	for _ in data:
		history_list = list(set(history_list).union(set(eval(_))))
except Exception as e:
	# 打印错误日志

# 0频道
try:
	data = self.hbu.get_table_cells('history_recommend',
									'reco:hist:{}'.format(temp.user_id).encode(),
									'channel:{}'.format(0).encode())
	for _ in data:
		history_list = list(set(history_list).union(set(eval(_))))
except Exception as e:
	# 打印错误日志

# 下面进行过滤操作 reco_set, history_list
reco_set = list(set(reco_set).difference(set(history_list)))

这样就过滤完了,过滤好了之后,就可以进行推荐啦。同时,还要把推荐出去的文章加入历史列表。

6.2.3 推荐出去指定个数的新文章列表,写入历史记录,剩下多的写入缓存结果

依然是接着上面的代码:

if not reco_set:
	return reco_set    # 这个是空
else:
	# 如果reco_set个数小于用户需要推荐的文章  下拉一次可能是12个,但是不够12,那也直接返回
	if len(reco_set) <= temp.article_num:
		res = reco_set
	else: 
		# 如果大于文章推荐的文章结果
		res = reco_set[:temp.article_num]
		# 将剩下的文章列表写入待推荐的结果
		self.hbu.get_table_put('wait_recommend', 
						   'reco:hist:{}'.format(temp.user_id).encode(), 
						   'channel:{}'.format(temp.channel_id).encode(),
						   str(reco_set[temp.article_num:]).encode(),
						   temestamp=temp.time_stamp)
						  
	# 直接写入历史记录当中, 表示这次又成功推荐一次
	self.hbu.get_table_put('history_recommend', 
						   'reco:hist:{}'.format(temp.user_id).encode(), 
						   'channel:{}'.format(temp.channel_id).encode(),
						   str(res).encode(),
						   temestamp=temp.time_stamp)
return res  # 返回结果						  

到这里, 召回结果读取服务逻辑搞定。

那么如果此时用户再请求一次的话, 还是想要新文章, 那么我们就需要从缓存结果中去读取,然后推荐给用户了,那么这个过程应该怎么实现呢?

7. 推荐缓存服务

这里实现的是推荐服务中心的获取缓存结果的逻辑:

在这里插入图片描述

7.1 待推荐结果的Redis缓存

目的:对待推荐结果进行二级缓存,多级缓存减少数据库读取压力

  • 一级缓存用Redis
  • 二级缓存用wait_recommend
  • 这两个数据库读取速度相当, 但是如果数据量很大,且缓存数据不能丢失的话,那就用HBase。

步骤:

  1. 获取redis结果,进行判断
    • 如果redis有,读取需要推荐的文章数量放回,并删除这些文章,并且放入推荐历史推荐结果中
    • 如果redis当中不存在,则从wait_recommend中读取
      • 如果wait_recommend中也没有,直接返回
      • 如果wait_recommend有,从wait_recommend取出所有结果,定一个数量(如100篇)存入redis,剩下放 回wait_recommend,不够100,全部放入redis,然后清空wait_recommend
      • 从redis中拿出要推荐的文章结果,然后放入历史推荐结果中

下面增加一个redis缓存数据库

# 缓存在8号当中
cache_client = redis.StrictRedis(host=DefaultConfig.REDIS_HOST,
                                 port=DefaultConfig.REDIS_PORT,
                                 db=8,
                                 decode_responses=True)

redis 8 号数据库读取

# 1、直接去redis拿取对应的键,如果为空
# 构造读redis的键
key = 'reco:{}:{}:art'.format(temp.user_id, temp.channel_id)
# 读取,删除,返回结果
pl = cache_client.pipeline()

# 拿督redis数据
res = cache_client.zrevrange(key, 0, temp.article_num - 1)
if res:
    # 手动删除读取出来的缓存结果   这是Redis的相关代码了
    pl.zrem(key, *res)

redis没有数据,进行wait_recommend读取,放入redis中

else:
    # 如果没有redis缓存数据
    # 删除键
    cache_client.delete(key)
    try:
        # 1、# - 首先从wait_recommend中读取,没有直接返回空,进去正常召回流程
        wait_cache = eval(hbu.get_table_row('wait_recommend',
                                            'reco:{}'.format(temp.user_id).encode(),
                                            'channel:{}'.format(temp.channel_id).encode()))
    except Exception as e:
            wait_cache = []

    if not wait_cache:
        return wait_cache
    # 2、- 首先从wait_recommend中读取,有数据,读取出来放入自定义100个文章到redis当中,如有剩余放回到wait_recommend。小于自定义100,全部放入redis,wait_recommend直接清空
    # - 直接取出被推荐的结果,记录一下到历史记录当中
    # 假设是放入到redis当中为100个数据

    if len(wait_cache) > 100:
        cache_redis = wait_cache[:100]
        # 前100个数据放入redis
        pl.zadd(key, dict(zip(cache_redis, range(len(cache_redis)))))
        # 100个后面的数据,在放回wait_recommend
        hbu.get_table_put('wait_recommend',
                          'reco:{}'.format(temp.user_id).encode(),
                          'channel:{}'.format(temp.channel_id).encode(),
                          str(wait_cache[100:]).encode())
    else:
        # 清空wait_recommend数据
        hbu.get_table_put('wait_recommend',
                          'reco:{}'.format(temp.user_id).encode(),
                          'channel:{}'.format(temp.channel_id).encode(),
                           str([]).encode())

        # 所有不足100个数据,放入redis
        pl.zadd(key, dict(zip(wait_cache, range(len(wait_cache)))))

    res = cache_client.zrange(key, 0, temp.article_num - 1)

推荐出去的结果放入历史结果

# redis初始有无数据
pl.execute()

# 进行类型转换   redis里面取出来的是字符串
res = list(map(int, res))

# 进行推荐出去,要做放入历史推荐结果当中
hbu.get_table_put('history_recommend',
                  'reco:his:{}'.format(temp.user_id).encode(),
                  'channel:{}'.format(temp.channel_id).encode(),
                  str(res).encode(),
                  timestamp=temp.time_stamp
                  )
return res

7.2 推荐中心加入缓存逻辑

from server import redis_cache

# 1、获取缓存
res = redis_cache.get_reco_from_cache(temp, self.hbu)

# 如果没有,然后走一遍算法推荐 召回+排序,同时写入到hbase待推荐结果列表
if not res:
   res = self.user_reco_list(temp)

这样就把推荐中心读取召回和缓存的逻辑梳理完毕,下面还剩下一块排序的逻辑,这里是这样的,就是在读6.2.2的下面,召回服务中读取了召回列表之后,接着是过滤掉了在历史中出现过的文章, 此时应该再加一个排序模块,因为召回回来的文章一般是几百的这样一个量级,我们不可能直接把这个推荐给用户或者存到缓存里面去。所以需要在真正推荐给用户文章之间,在召回,然后过滤掉在历史文章中出现过的文章之后, 在走一个排序模块,把几百篇的量级降到几十篇,接下来就可以推荐或者放缓存了。

所以下面就说说排序这里的逻辑,这个是会用到之前训练的逻辑回归模型的。

8. 模型在线预测

在server目录下面建立一个sort_service.py写排序这块的逻辑:

在这里插入图片描述

8.1 排序模型在线排序的逻辑

由于我们之前已经训练好了逻辑回归模型,这里的排序逻辑就是我们只要读取过来之前保存好的用户特征中心和文章特征中心的特征,就能直接构造出样本进行预测。这就是我们之前构造用户特征中心和文章特征中心的意义所在,会发现有了这个东西,排序这块用起来就非常轻松

所以下面的逻辑是这样:

  1. 读取用户特征中心的特征
  2. 读取文章特征中心的特征, 合并用户文章特征构造预测样本
  3. 预测并进行排序筛选

这里其实就比较简单了,因为我们只要能拿到用户id,文章id,其实就能去特征中心去拿相应的用户特征和文章特征。这里只是取的一个过程:

8.1.1 读取用户特征中心特征
hbu = HBaseUtils(pool)
# 排序
# 1、读取用户特征中心特征
try:# 读到的这个结果是bytes类型的,这里要eval下, bytes类型长这样b'[]'
    user_feature = eval(hbu.get_table_row('ctr_feature_user',
                                           '{}'.format(1115629498121846784).encode(),
                                           'channel:{}'.format(18).encode()))
except Exception as e:
    user_feature = []
8.1.2 读取文章特征中心特征, 并与用户特征进行合并, 构造要推荐文章的样本

合并特征向量(channel_id1个+文章向量100个+用户特征权重10个+文章关键词权重) = 121个特征

if user_feature:
    # 2、读取文章特征中心特征
    result = []

    for article_id in [17749, 17748, 44371, 44368]:
        try:
            article_feature = eval(hbu.get_table_row('ctr_feature_article',
                                                     '{}'.format(article_id).encode(),
                                                     'article:{}'.format(article_id).encode()))
        except Exception as e:
            article_feature = [0.0] * 111
        f = []
        # 下面这个顺序不能变,严格按照逻辑回归当时训练的特征顺序走
        # 第一个channel_id
        f.extend([article_feature[0]])
        # 第二个article_vector
        f.extend(article_feature[11:])
        # 第三个用户权重特征
        f.extend(user_feature)
        # 第四个文章权重特征
        f.extend(article_feature[1:11])
        vector = DenseVector(f)

        result.append([1115629498121846784, article_id, vector])
8.1.3 处理样本格式,模型加载预测
# 4、预测并进行排序是筛选
df = pd.DataFrame(result, columns=["user_id", "article_id", "features"])
test = spark.createDataFrame(df)

# 加载逻辑回归模型
model = LogisticRegressionModel.load("hdfs://hadoop-master:9000/headlines/models/LR.obj")
predict = model.transform(test)
8.1.4 预测结果进行筛选
def vector_to_double(row):
    return float(row.article_id), float(row.probability[1])
res = predict.select(['article_id', 'probability']).rdd.map(vector_to_double).toDF(['article_id', 'probability']).sort('probability', ascending=False)

获取排序之后的前N个文章

article_list = [i.article_id for i in res.collect()]
if len(article_list) > 100:
    article_list = article_list[:100]         # 这里选择了100篇文章去推荐
reco_set = list(map(int, article_list))

8.2 添加实时排序的模型预测

这里需要先添加spark配置, 这个在default.py文件中添加, 具体的这里就不看了,这块代码也是比较多,预测函数的话就是把上面那四块封装成一个lr_sort_service函数,然后把这个函数在推荐中心加入排序。

# 配置default
RAParam = param(
    COMBINE={
        'Algo-1': (1, [100, 101, 102, 103, 104], [200]),  # 首页推荐,所有召回结果读取+LR排序
        'Algo-2': (2, [100, 101, 102, 103, 104], [200])  # 首页推荐,所有召回结果读取 排序
    },

# reco_center
from server.sort_service import lr_sort_service
sort_dict = {
    'LR': lr_sort_service,
}

# 排序代码逻辑 把下面的这段代码逻辑放到reco_center.py里面的把召回结果读到之后排序的那个地方即可。
_sort_num = RAParam.COMBINE[temp.algo][2][0]
reco_set = sort_dict[RAParam.SORT[_sort_num]](reco_set, temp, self.hbu)

就是在这里了:

在这里插入图片描述

8.3 supervisor添加grpc实时推荐程序

在线实时推荐,也是需要实时运行的,所以在后台加上实时运行rooting.py的代码:

[program:online]
environment=JAVA_HOME=/root/bigdata/jdk,SPARK_HOME=/root/bigdata/spark,HADOOP_HOME=/root/bigdata/hadoop,PYSPARK_PYTHON=/miniconda2/envs/reco_sys/bin/python ,PYSPARK_DRIVER_PYTHON=/miniconda2/envs/reco_sys/bin/python
command=/miniconda2/envs/reco_sys/bin/python /root/toutiao_project/reco_sys/abtest/routing.py
directory=/root/toutiao_project/reco_sys/abtest
user=root
autorestart=true
redirect_stderr=true
stdout_logfile=/root/logs/recommendsuper.log
loglevel=info
stopsignal=KILL
stopasgroup=true
killasgroup=true

9. 小总

这篇文章的内容也是非常多, 下面一个思维导图拎起来:

在这里插入图片描述

参考:

  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值