背景
图1 外卖排序系统框架
外卖的排序策略是由机器学习模型驱动的,模型迭代效率制约着策略优化效果。如上图所示,在排序系统里,特征是最为基础的部分:有了特征之后,我们离线训练出模型,然后将特征和模型一起推送给线上排序服务使用。特征生产Pipeline对于策略迭代的效率起着至关重要的作用。经过实践中的积累和提炼,我们整理出一套通用的特征生产框架,大大节省开发量,提高策略迭代效率。
外卖排序系统使用GBDT(Gradient Boosting Decision Tree)树模型,比较复杂。受限于计算能力,除了上下文特征(如时间、地域、终端类型、距离等)之外,目前使用的主要是一些宽泛的统计特征,比如商家销量、商家单均价、用户的品类偏好等。这些特征的生产流程包括:离线的统计、离线到在线的同步、在线的加载等。
图2 特征生产流程
如上图,目前外卖排序的特征生产流程主要有:
前两步为离线操作,后两步为在线操作。特征同步由离线推送和在线获取共同完成。离线生产流程是一个周期性的Pipeline,目前是以天为周期。
为此,我们设计了一套通用的框架,基于此框架,只需要简单的配置和少量代码开发,就可以新增一组特征。下文将详细介绍框架的各个部分。
特征统计
排序模型用到的特征大部分是统计特征。有些特征比较简单,如商家的月均销量、商家单均价等,可用ETL统计(GROUP BY + SUM/AVG);有些特征稍微复杂,如用户的品类偏好(在不同品类上的占比)、用户的下单额分布(不同金额区段的占比),用ETL就比较繁琐。针对后一种情况,我们开发了一套Spark程序来统计。我们发现,这种统计需求可以规约成一种范式:针对某些统计对象(用户、商家)的一些维度(品类、下单额),基于某些度量值(点击、下单)做统计(比例/总和)。
同一对象,可统计不同维度;同一维度,有不同的度量角度;同一度量角度,有不同的统计方式。如下图:
图3 特征统计范式
例如,对于用户点击品类偏好、用户下单品类偏好、用户下单额分布、用户下单总额等特征,可做范式分解:
图4 特征统计范式示例
其中,
另外,统计通常是在一定时间窗口内进行的,由于不同时期的数据价值不同(新数据比老数据更有价值),我们引入了时间衰减,对老数据降权。
基于以上考虑,整个统计流程可以分解为(基于Spark):
图5 特征统计流程
维度算子、度量算子、统计算子都可以通过扩展接口的方式实现自定义。
如下是统计用户点击品类偏好、用户下单品类偏好、用户下单额分布的配置文件和Hive表示例([Toml][1]格式)
图6 特征统计配置示例
相对于ETL,这套Spark统计框架更为简单清晰,还可以同时统计多个相关的特征。通过简单的配置就可以实现特征的统计,开发量比较小。
特征同步
离线统计得到的特征存储在Hive表中,出于性能的考虑,不能在线上直接访问。我们需要把特征从Hive中推送到更为高效的KV数据库中,线上服务再从KV中获取。整个同步过程可以分为如下步骤:
图7 特征推送流程
前两步为离线操作,第三步为在线操作(在预测代码中被调用)。
我们针对Hive开发了一套ORM库(见图8),主要基于Java反射,除了支持基本类型(int/long/float/double/String等),还支持POJO类型和集合类型(List/Map)。因为ETL不支持json拼接,为了兼容基于ETL统计的特征数据,我们的POJO以及集合类型是基于自定义的规范做编解码。针对Spark统计的特征数据,后续我们可以支持json格式的编解码。
图8 Hive ORM示意
特征序列化和反序列我们统一封装为通用的KvService:负责序列化与反序列,以及读写KV。如下图:
图9 KvService
对于新特征,只需要定义一个Domain类,并实现接口key()即可,KvService自动完成Key值的拼接(以Domain的类名作为Key的prefix),序列化和反序列化,读写KV。
我们通过周期性的离线MapReduce任务,读取Hive表的记录,并调用KvService的put接口,将特征数据推送到KV中。由于KvService能够统一处理各种Domain类型,MapReduce任务也是通用的,无需为每个特征单独开发。
对于特征同步,只需要开发Domain类,并做少量配置,开发量也很小。目前,我们为了代码的可读性,采用Domain这种强类型的方式来定义特征,如果可以弱化这种需求的话,还可以做更多的框架优化,省去Domain类开发这部分工作。
特征加载
通过前面几步,我们已经准备好特征数据,并存储于KV中。线上有诸多模型在运行,不同模型需要不同的特征数据。特征加载这一步主要解决怎么高效便捷地为模型提供相应的特征数据。
离线得到的只是一些原始特征,在线还可能需要基于原始特征做更多的处理,得到高阶特征。比如离线得到了商家和用户的下单金额分布,在线我们可能需要基于这两个分布计算一个匹配度,以表征该商家是否在用户消费能力的承受范围之内。
我们把在线特征抽象为一个特征算子:FeatureOperator。类似的,一个特征算子包含了一组相关的在线特征,且可能依赖一组相关的离线特征。它除了封装了在线特征的计算过程,还通过两个Java Annotation声明该特征算子产出的特征清单(@Features)和所需要的数据清单(@Fetchers)。所有的数据获取都是由DataFetcher调用KvService的get接口实现,拿到的Domain对象统一存储在DataPortal对象中以便后续使用。
服务启动时,会自动扫描所有的FeatureOperator的Annotation(@Features、@Fetchers),拿到对应的特征清单和数据清单,从而建立起映射关系:FeatureFeatureOperatorDataFetcher。而每个模型通过配置文件给定其所需要的特征清单,这样就建立起模型到特征的映射关系(如图9):
Model → Feature → FeatureOperator → DataFetcher
不同的在线特征可能会依赖相同的离线特征,也就是FeatureOperatorDataFetcher是多对多的关系。为了避免重复从KV读取相同的数据以造成性能浪费,离线特征的获取和在线特征的抽取被划分成两步:先汇总所有离线特征需求,统一获取离线特征;得到离线特征后,再进行在线特征的抽取。这样,我们也可以在离线特征加载阶段采用并发以减少网络IO延时。整个流程如图10所示:
图10 模型和特征数据的映射关系
图11 特征加载流程
对于新特征,我们需要实现对应的FeatureOperator、DataFetcher。DataFetcher主要封装了Domain和DataPortal的关系。类似的,如果我们不需要以强类型的方式来保证代码的业务可读性,也可以通过优化框架省去DataFetcher和DataPortal的定制开发。
总结
我们在合理抽象特征生产过程的各个环节后,设计了一套较为通用的框架,只需要少量的代码开发(主要是自定义一些算子)以及一些配置,就可以很方便地生产一组特征,有效地提高了策略迭代效率。
参考文献