抱歉,您的浏览器无法访问本站

本页面需要浏览器支持(启用)JavaScript


了解详情 >

广告深度预估技术在美团到店场景下的突破与畅想

参考文献

  • [1] Friedman J H . Greedy Function Approximation: A Gradient Boosting Machine[J]. Annals of Statistics, 2001, 29(5):1189-1232.
  • [2] Rendle S. Factorization machines[C]//2010 IEEE International conference on data mining. IEEE, 2010: 995-1000.
  • [3] HT Cheng, et al. Wide & Deep Learning for Recommender Systems, 2016
  • [4] Zhou, Guorui, et al. “Deep interest network for click-through rate prediction.” Proceedings of the 24th ACM SIGKDD International Conference on Knowledge Discovery & Data Mining. 2018
  • [5] Modeling Task Relationships in Multi-task Learning with Multi-gate Mixture-of-Experts. ACM, 2018.
  • [6] Wen, Ling, Chua. A closer look at strategies for memorization.[J]. Clavier Companion, 2014, 6(6):50-52.
  • [7] Huang J, Hu K, Tang Q, et al. Deep Position-wise Interaction Network for CTR Prediction[J]. arXiv preprint arXiv:2106.05482, 2021.
  • [8] Search-based User Interest Modeling with Lifelong Sequential Behavior Data for Click-Through Rate Prediction
  • [9] Qi, Yi, et al. “Trilateral Spatiotemporal Attention Network for User Behavior Modeling in Location-based Search”, CIKM 2021.
  • [10] Overcoming catastrophic forgetting in neural networks[J]. Proceedings of the National Academy of Sciences of the United States of America, 2017.
  • [11] M. Zinkevich. Online convex programming and generalized infinitesimal gradient ascent. In ICML, 2003.
  • [12] Optimized Cost per Click in Taobao Display Advertising[C]// the 23rd ACM SIGKDD International Conference. ACM, 2017.
  • [13] https://tech.meituan.com/2020/08/20/kdd-cup-debiasing-practice.html

招聘信息

美团到店广告平台广告算法团队立足广告场景,探索深度学习、强化学习、人工智能、大数据、知识图谱、NLP和计算机视觉前沿的技术发展,探索本地生活服务电商的价值。主要工作方向包括:

  • 触发策略:用户意图识别、广告商家数据理解,Query改写,深度匹配,相关性建模。
  • 质量预估:广告质量度建模。点击率、转化率、客单价、交易额预估。
  • 机制设计:广告排序机制、竞价机制、出价建议、流量预估、预算分配。
  • 创意优化:智能创意设计。广告图片、文字、团单、优惠信息等展示创意的优化。

岗位要求

  • 有三年以上相关工作经验,对CTR/CVR预估、NLP、图像理解,机制设计至少一方面有应用经验。
  • 熟悉常用的机器学习、深度学习、强化学习模型。
  • 具有优秀的逻辑思维能力,对解决挑战性问题充满热情,对数据敏感,善于分析/解决问题。
  • 计算机、数学相关专业硕士及以上学历。

具备以下条件优先

  • 有广告/搜索/推荐等相关业务经验。
  • 有大规模机器学习相关经验。

感兴趣的同学可投递简历至:chengxiuying@meituan.com(邮件标题请注明:美团广平算法团队)。

美团智能客服核心技术与实践

客服是在用户服务体验不完美的情况下,尽可能帮助体验顺畅进行下去的一种解决办法,是问题发生后的一种兜底方案。而智能客服能让大部分简单的问题得以快速自助解决,让复杂问题有机会被人工高效解决。在用户服务的全旅程中,美团平台/搜索与NLP部提供了问题推荐、问题理解、对话管理、答案供给、话术推荐和会话摘要等六大智能客服核心能力,以期达到低成本、高效率、高质量地与用户进行沟通的目的。本文主要介绍了美团智能客服核心技术以及在美团的实践。

1 背景

目前,美团的年交易用户量为6.3亿,服务了770万生活服务类商家。此外,在美团优选业务中还有一个很大的团长群体。美团平台涵盖吃、住、行、游、购、娱等200多个生活服务品类,在平台服务的售前、售中、售后各个环节,都有大量信息咨询、订单状态获取以及申诉投诉等沟通诉求。另外,作为一家拥有几万名员工的上市企业,员工之间亦有大量的沟通诉求。面对以上这些需求,如果都是通过人力进行实现,显然不符合公司长远发展的目标,这就需要引入智能客服。

1.1 面对不同场景的智能客服落地

首先,我们看看日常生活中几种最为常见的客服场景。

  • 售前场景:比如消费者在平台选择入住酒店,对房型价格、酒店设施、入退房政策等,下单前都有很强的信息咨询诉求。
  • 售中场景:比如外卖催单还没到,添加备注不要辣、加开发票等咨询等等,售前和售中场景主要发生在消费者和商家或平台之间。
  • 售后场景:比如外卖场景投诉菜品少送、骑手送餐超时、要求退款等,酒店场景投诉酒店到店无法入住等,售后往往涉及到客服座席、消费者、骑手和商家,需要多方协同解决。
  • 办公场景:比如IT、人力资源、财务、法务等咨询,产运研对提供的接口产品的咨询答疑,产品对销售顾问的答疑,以及销售顾问对商家的答疑等等。

1.2 面对不同人群的智能客服落地

沟通是人类的一项基本需求,在绝大多数场景下,我们对沟通的追求都是以低成本、高效率和高质量为目标,而对话机器人也需要同时满足这三点要求。目前我们按照服务的群体进行划分,智能客服落地场景大体可以分为以下四类:

  • 面向用户:提供智能客服机器人,来帮助他们自助解决大部分的问题。
  • 面向座席:用话术推荐或者会话摘要等能力来提升人工座席的工作效率,改善人工座席的工作体验。
  • 面向商家:打造商家助手来降低商家回复的费力度,改善消费者和商家的沟通体验。
  • 面向员工:通过对话机器人,可以自助给员工进行答疑,从而提升办公效率。

1.3 智能客服是什么

要回答智能客服是什么,可以先看看客服是什么。我们的理解是,客服是在用户服务体验不完美的时候,来帮助体验顺畅进行下去的一种解决办法,是问题发生后的一种兜底方案。而智能客服能让大部分简单的问题得以快速自助解决,让复杂问题有机会被人工高效解决。

上图展示的是用户服务旅程。首先,用户会通过在线打字或者拨打热线电话的方式进线寻求服务,其中在线咨询流量占比在85%以上。当用户进入到服务门户后,先是用户表达需求,然后是智能机器人响应需求,过程中机器人先要理解问题,比如是追加备注或是修改地址,还是申请退款等等,继而机器人尝试自助解决。如果解决不了,再及时地流转到人工进行兜底服务。最后,当用户离开服务时,系统会发送调查问卷,期待用户对本次服务进行评价。

2 智能客服核心技术

2.1 对话交互技术概述

智能客服背后的技术主要是以对话交互技术为核心。常见的对话任务可分为闲聊型、任务型和问答型:

  • 闲聊型:通常是不关注某项特定任务,它的主要的目标是和人进行开放领域的对话,关注点是生成流畅、合理且自然的回复。
  • 任务型:通常是帮助用户完成某项任务指令,如查找酒店、查询订单状态、解决用户的退款申请等等。用户的需求通常比较复杂,需要通过多轮交互来不断收集任务所需的必要信息,进而根据信息进行决策,执行不同的动作,最终完成用户的指令。
  • 问答型:侧重于一问一答,即直接根据用户的问题给出精准答案。问答型和任务型最本质的区别在于,系统是否需要维护一个用户目标状态的表示和是否需要一个决策过程来完成任务。

在技术实现上,通常又可以划分为检索式、生成式和任务式:

  • 检索式:主要思路是从对话语料库中找出与输入语句最匹配的回复,这些回复通常是预先存储的数据。
  • 生成式:主要思路是基于深度学习的Encoder-Decoder架构,从大量语料中习得语言能力,根据问题内容及相关实时状态信息直接生成回答话术。
  • 任务式:就是任务型对话,通常要维护一个对话状态,根据不同的对话状态决策下一步动作,是查询数据库还是回复用户等等。

闲聊、问答、任务型对话本质都是在被动地响应用户需求。在具体业务中还会有问题推荐、商品推荐等来主动引导用户交互。在美团的业务场景里主要是任务型和问答型,中间也会穿插一些闲聊,闲聊主要是打招呼或者简单情绪安抚,起到润滑人机对话的作用。

如前面用户服务流程所介绍的那样,用户的沟通对象可能有两个,除了跟机器人沟通外,还可能跟人工沟通。如果是找客服场景人工就是客服座席,如果是找商家场景人工就是商家。机器人的能力主要包括问题推荐、问题理解、对话管理以及答案供给。

目前,衡量机器人能力好坏的核心输出指标是不满意度和转人工率,分别衡量问题解决的好坏,以及能帮人工处理多少问题。而在人工辅助方面,我们提供了话术推荐和会话摘要等能力,核心指标是ATT和ACW的降低,ATT是人工和用户的平均沟通时长,ACW是人工沟通后的其它处理时长。

2.2 智能机器人——多轮对话

这是一个真实的多轮对话的例子。当用户进入到服务门户后,先选择了一个推荐的问题“如何联系骑手”,机器人给出了联系方式致电骑手。同时为了进一步厘清场景,询问用户是否收到了餐品,当用户选择“还没有收到”的时候,结合预计送达时间和当前时间,发现还未超时,给出的方案是“好的,帮用户催一下”,或者是“我再等等吧”,这时候用户选择了“我再等等吧”。

这个例子背后的机器人是怎么工作的呢?首先当用户输入“如何联系骑手”的时候,问题理解模块将它与知识库中的拓展问进行匹配,进而得到对应的标准问即意图“如何联系骑手”。然后对话管理模块根据意图“如何联系骑手”触发相应的任务流程,先查询订单接口,获取骑手电话号码,进而输出对话状态给到答案生成模块,根据模板生成最终结果,如右边的红框内容所示。在这个过程中涉及到要先有意图体系、定义好Task流程,以及订单的查询接口,这些都是业务强相关的,主要由各业务的运营团队来维护。那么,对话系统要做的是什么呢?一是将用户的输入与意图体系中的标准问进行匹配,二是完成多轮交互里面的调度。

问题理解是将用户问题与意图体系进行匹配,匹配到的拓展问所对应的标准问即用户意图。机器人的工作过程实际是要做召回和精排两件事情。召回更多地是用现有检索引擎实现,技术上更多地关注精排。

美团自研的智能客服系统是从2018年开始搭建的,在建设的过程中,我们不断地将业界最先进的技术引入到我们的系统中来,同时根据美团业务的特点,以及问题理解这个任务的特点,对这些技术进行适配。

比如说,当2018年底BERT(参见《美团BERT的探索和实践》一文)出现的时候,我们很快全量使用BERT替换原来的DSSM模型。后面,又根据美团客服对话的特点,我们将BERT进行了二次训练及在线学习改造,同时为了避免业务之间的干扰,以及通过增加知识区分性降低噪音的干扰,我们还做了多任务学习(各业务在上层为独立任务)以及多域学习(Query与拓展问匹配,改为与拓展问、标准问和答案的整体匹配),最终我们的模型为Online Learning based Multi-task Multi-Field RoBERTa。经过这样一系列技术迭代,我们的识别准确率也从最初不到80%到现在接近90%的水平。

理解了用户意图后,有些问题是可以直接给出答案解决的,而有些问题则需要进一步厘清。比如说“如何申请餐损”这个例子,不是直接告诉申请的方法,而是先厘清是哪一个订单,是否影响食用,进而厘清一些用户的诉求是部分退款还是想安排补送,从而给出不同的解决方案。这样的一个流程是跟业务强相关的,需要由业务的运营团队来进行定义。如右边任务流程树所示,我们首先提供了可视化的TaskFlow编辑工具,并且把外呼、地图以及API等都组件化,然后业务运营人员可以通过拖拽的方式来完成Task流程设计。

对话引擎在与用户的真实交互中,要完成Task内各步骤的匹配调度。比如这个例子里用户如果不是点选”可以但影响就餐了…”这条,而是自己输入说“还行,我要部分退款”,怎么办?这个意图也没有提前定义,这就需要对话引擎支持Task内各步骤的模糊匹配。我们基于Bayes Network搭建的TaskFlow Engine恰好能支持规则和概率的结合,这里的模糊匹配算法复用了问题理解模型的语义匹配能力。

这是另外一个例子,在用户问完“会员能否退订”后,机器人回复的是“无法退回”,虽然回答了这个问题,但这个时候用户很容易不满意,转而去寻找人工服务。如果这个时候我们除了给出答案外,还去厘清问题背后的真实原因,引导询问用户是“外卖红包无法使用”或者是“因换绑手机导致的问题”,基于顺承关系建模,用户大概率是这些情况,用户很有可能会选择,从而会话可以进一步进行,并给出更加精细的解决方案,也减少了用户直接转人工服务的行为。

这个引导任务称为多轮话题引导,具体做法是对会话日志中的事件共现关系以及顺承关系进行建模。如右边图所示,这里原本是要建模句子级之间的引导,考虑到句子稀疏性,我们是将其抽象到事件之间的引导,共现关系我们用的是经典的协同过滤方式建模。另外,考虑到事件之间的方向性,我们对事件之间的顺承关系进行建模,公式如下:

并通过多目标学习,同时考虑点击指标和任务指标,如在非转人工客服数据和非不满意数据上分别建模顺承关系,公式如下:

最终,我们在点击率、不满意度、转人工率层面,都取得了非常正向的收益。

美团平台涵盖吃、住、行、游、购、娱等200多个生活服务品类,当用户是从美团App或点评App等综合服务门户入口进入服务时,需要先行确定用户要咨询的是哪个业务,这里的一个任务是“判断用户Query是属于哪个业务”,该任务我们叫做领域识别。若能明确判断领域时,则直接用该领域知识来解答;当不能明确判断时,则还需要多轮对话交互与用户进行澄清。比如用户输入“我要退款”,在多个业务里都存在退款意图,这个时候就需要我们先判断是哪个业务的退款意图,如果判断置信度不高,则给出业务列表让用户自行选择来进行澄清。

领域识别模型主要是对三类数据建模:各领域知识库的有标数据、各领域大量弱监督无标数据和个性化数据。

  1. 依据从各领域知识库的有标数据中学习得到的问题理解模型信号,可以判断用户输入属于各业务各意图的可能性。
  2. 我们注意到除了美团App、点评App等综合服务入口涉及多个业务外,还有大量能够明确业务的入口,比如说订单入口,从商品详情页进来的入口,这些入口进来的对话数据是有明确业务标签信息的。因此,我们可以得到大量的弱监督的各业务领域的数据,基于这些数据我们可以训练一个一级分类模型。
  3. 同时,有些问题是需要结合用户订单状态等个性化数据才能进一步明确的。比如“我要退款”,多个业务里都会有。因此,又要结合用户状态特征一起来训练一个二级模型,最终来判断用户的输入属于哪个业务。

最终,该二级领域识别模型在满意度、转人工率以及成功转接率指标上都取得了非常不错的收益。

2.3 智能机器人——问题推荐

在介绍完多轮对话基础模块问题理解和对话管理后,接下来我们介绍一下智能机器人的另外两个模块:问题推荐和答案供给。如前面多轮对话的例子所示,当用户进入服务门户后,机器人首先是要如何引导用户精准地表达需求,这样即可降低用户迷失或者直接转人工服务,也降低了若机器人不能正确理解时带来的多轮澄清等无效交互。

该问题是一个标准的曝光点击问题,它的本质是推荐问题。我们采用了CTR预估任务经典的FM模型来作为基础模型,同时结合业务目标,期望用户点击的问题的解决方案能够解决用户问题,该问题最终定义为“曝光、点击、解决”问题,最终的模型是结合多目标学习的ESSM-FM,对有效交互的转化率、转人工率和不满意度等指标上都带来了提升。

2.4 智能机器人——答案供给

售后客服场景通常问题较集中,且问题的解决多依赖业务内部系统数据及规则,通常是业务部门维护知识库,包括意图体系、Task流程和答案等。但在售前场景,知识多来自于商户或商品本身、用户体验及评价信息等,具有用户问题开放、知识密度高、人工难以整理答案等特点。比如去哪个城市哪个景点游玩,附近有哪些酒店,酒店是否有浴缸,酒店地址在哪里等,都需要咨询”决策”,针对这些诉求,我们通过智能问答来解决咨询以及答案供给问题。

智能问答就是从美团数据中习得答案供给,来快速回答用户的问题,基于不同的数据源,我们建设了不同的问答技术。

  • 针对商家基础信息,比如问营业时间、地址、价格等,我们通过图谱问答(KBQA)来解决。利用商家基础信息构建图谱,通过问题理解模型来理解问题,进而查询图谱获取准确的答案。
  • 针对社区数据,即商户详情页中“问大家”模块的用户问用户答的社区数据,构建社区问答(Community QA)能力,通过对用户问题与问大家中的”问答对”的相似度建模,选择相似度最高的作为答案,来回答用户的一些开放性问题。
  • 针对UGC评论数据以及商户政策等无结构化数据,构建文档问答(Document QA)能力,针对用户问题利用机器阅读理解技术从文档中抽取答案,类似我们小时候语文考试中的阅读理解题,进一步回答用户的一些开放性问题。

最后,针对多个问答模块给出的答案,进行多答案来源的答案融合排序,来挑选最终的答案,此外这里还考察了答案真实性,即对“相信多数认为正确的则正确”建模。这部分的详细介绍大家可以参考《美团智能问答技术探索与实践》一文。

3 人工辅助核心技术

3.1 人工辅助——话术推荐

前文介绍的都是智能机器人技术,用户除了跟机器人沟通外,还可能是跟人工沟通。我们在客服座席职场调研过程中发现,座席在与用户的对话聊天中经常回复相似甚至相同的话术,他们一致期望提供话术推荐的能力来提高效率。此外,除了请求客服座席帮助外,很多情况下用户与商家直接沟通会使得解决问题更高效,而沟通效率不仅影响到消费者的体验,也影响到了商家的经营。比如在外卖业务中,消费者的下单率和商家的回复时长有较为明显的反比关系,无论是客服座席还是商家,都有很强的话术推荐诉求。

那么,话术推荐具体要怎么做呢?常见的做法是先准备好常用通用话术库,部分座席或商家也会准备个人常见话术库,然后系统根据用户的Query及上下文来检索最合适的话术来推荐。我们根据调查发现,这部分知识库维护得很不好,既有业务知识变更频繁导致已维护的知识很快不可用因素,也有座席或商家本身意愿不强的因素等。另外,针对新客服座席或者新商家,可用的经验更少。因此我们采用了自动记忆每个座席及其同技能组的历史聊天话术,商家及其同品类商家的历史聊天话术,根据当前输入及上下文,预测接下来可能的回复话术,无需人工进行整理,大大提升了效率。

我们将历史聊天记录构建成“N+1”QA问答对的形式建模,前N句看作问题Q,后1句作为回复话术A,整个框架就可以转化成检索式的问答模型。在召回阶段,除了文本信息召回外,我们还加入了上文多轮槽位标签,Topic标签等召回优化,排序为基于BERT的模型,加入角色信息建模,角色为用户、商家或者座席。

整个架构如上图所示,分为离线和在线两部分。另外上线后我们也加入了一层CTR预估模型来提升采纳率。当前多个业务的话术推荐平均采纳率在24%左右,覆盖率在85%左右。话术推荐特别是对新座席员工价值更大,新员工通常难以组织话术,通过采纳推荐的话术可以来缩减熟练周期,观测发现,3个月内座席员工的平均采纳率是3个月以上座席员工的3倍。

3.2 人工辅助——会话摘要

在客服场景座席跟用户沟通完后,还需要对一些必要信息进行工单纪要,包括是什么事件,事件发生的背景是什么,用户的诉求是什么,最后的处理结果是什么等等。而填写这些内容对座席来说其实是很不友好,通常需进行总结归纳,特别是有些沟通进行的时间还比较长,需要来回翻看对话历史才能正确总结。另外,为了持续对于服务产品进行改善,也需要对会话日志进行相应事件抽取及打上标签,从而方便经营分析。

这里有些问题是选择题,有些问题是填空题,比如这通会话具体聊的是哪个事件,我们提前整理有比较完整的事件体系,可以看成是个选择题,可以用分类或者语义相似度计算模型来解决。又比如说事件发生的背景,如外卖退款的背景是因餐撒了、酒店退款的背景是到店没有房间等,是个开放性问题,分析发现可以很好地从对话内容中抽取,可以用摘要抽取模型来解决。而对于处理结果,不仅仅依赖对话内容,还包括是否外呼,外呼了是否商家接通了,后续是否需要回访等等,我们实验发现生成模型更有效。具体使用的模型如上图所示,这里事件选择考虑到经常有新事件的添加,我们转成了双塔的相似度计算任务,背景抽取采用的是BERT-Sum模型,处理结果采用的是谷歌的PEGASUS模型。

04 小结与下一步计划

4.1 小结——交互立方

前面介绍了美团智能客服实践中的一些核心技术,过程中也穿插着介绍了客服座席与消费者/商家/骑手/团长等之间的沟通提效,以及消费者与商家之间的沟通提效。除了这两部分之外,在企业办公场景,其实还有员工之间、销售顾问与商家之间的大量沟通。如果一个个去做,成本高且效率低,解决方案是把智能客服中沉淀的能力进行平台化,最好“一揽子”进行解决,以固定成本来支持更多的业务需求。于是我们搭建了美团的对话平台-摩西对话平台,用“一揽子”方案以固定成本来解决各业务的智能客服需求。

4.2 小结——对话平台“摩西”

构建一个怎么样的对话平台,才能提供期望的没有NLP能力的团队也能拥有很好的对话机器人呢?首先是把对话能力工具化和流程化。如上图所示,系统可分为四层:应用场景层、解决方案层、对话能力层、平台功能层。

  • 应用场景层:在售前应用场景,一类需求是商家助手,如图中所列的美团闪购IM助手和到综IM助手,需要辅助商家输入和机器人部分接管高频问题能力;还有一类需求是在没有商家IM的场景需要智能问答来填补咨询空缺,比如图中所列的酒店问一问和景点问答搜索;另外售中、售后以及企业办公场景,各自需求也不尽相同。
  • 解决方案层:这就要求我们有几套解决方案,大概可以分为智能机器人、智能问答、商家辅助、座席辅助等。每个解决方案的对话能力要求也有所不同,这些解决方案是需要很方便地对基础对话能力进行组装,对使用方是透明的,可以拿来即用。
  • 对话能力层:前面也进行了相应的介绍,六大核心能力包括问题推荐、问题理解、对话管理、答案供给、话术推荐和会话摘要。
  • 平台功能层:此外,我们需要提供配套的运营能力,提供给业务方的运营人员来日常维护知识库、数据分析等等。

其次,提供“一揽子”的解决方案,还需要针对处在不同阶段的业务提供不同阶段的解决方案。

  • 有些业务只希望维护好常用的问答,能回答高频的问题就好,那么他们只需要维护一个入门级的机器人,只需要在意图管理模块来维护它的意图,意图的常见说法以及答案就可以了。
  • 而对于有运营资源的团队,他们希望不断地去丰富知识库来提升问答能力,这个时候可以使用知识发现模块,可以自动地从每天的日志里面发现新意图及意图的新说法,运营人员只需要每天花一点时间来确认添加及维护答案即可,这是一个进阶的业务方。
  • 还有一些高级的业务方希望调用他们业务中的API来完成复杂问题的求解。这个时候他们可以使用TaskFlow编辑引擎,在平台上直接注册业务的API,通过可视化拖拽的方式来完成Task编辑。

此外, 为了进一步方便更多的业务介入,我们也提供了一些闲聊、通用指令、地区查询等官方技能包,业务方可以直接勾选使用。另外,随着我们不断在业务中沉淀,也会有越来越多的官方行业技能包。整体方向上是逐步让业务方使用的门槛变得越来越低。

4.3 下一步计划

前文所介绍的对话系统是一种Pipeline式对话系统,按照功能划分为不同的模块,各个模块单独建模,依次串联。这种方式的好处是可以做到不同团队职责的有效分工,比如研发同学专注于建设好问题推荐模型、问题理解模型和Task引擎等;业务运营同学专注于意图体系维护、Task流程设计以及答案设计等等。它的劣势也很明显,模块耦合,误差累积,很难联合优化,进而各模块负责的同学可能会去修修补补,容易导致动作变形。

另一类建模方式是End-to-End,将Pipeline式对话系统的各个模块联合建模成一个模型,直接实现语言到语言的转变,此类方法最初应用在闲聊式对话系统里面,近期随着大规模预训练模型的快速发展,学术上也逐渐开始研究基于预训练模型的端到端任务型对话系统。它的优点是模型可以充分利用无监督人人会话,用数据驱动可以快速迭代;缺点是模型的可控性差,不易解释且缺乏干预能力。目前主要以学术研究为主,未见成熟的应用案例。

除了使用这种大量无监督的人人会话日志外,还有一种思路是基于Rule-Based TaskFlow构建规则的用户模拟器,进行交互以生成大量的对话数据,进而训练对话模型。为了保证对话系统的鲁棒性,也可使用类似对抗攻击的方法优化,可以模拟Hard User的行为,不按顺序执行TaskFlow,随机打断、跳转某个对话节点等等。

此外,通过对比分析人机对话日志和人人对话日志,人机对话比较僵硬死板,无法有效捕捉用户的情绪,而人就很擅长这方面。这在客服场景非常重要,用户往往进来就是带着负面情绪的,机器人需要有共情能力。而端到端数据驱动的对话和对话共情能力建设,也将是接下来一段时间我们尝试的重点方向。

如何优雅地记录操作日志?

操作日志几乎存在于每个系统中,而这些系统都有记录操作日志的一套 API。操作日志和系统日志不一样,操作日志必须要做到简单易懂。所以如何让操作日志不和业务逻辑耦合,如何让操作日志的内容易于理解,让操作日志的接入更加简单?上面这些都是本文要回答的问题,主要围绕着如何“优雅”地记录操作日志展开描述。

1. 操作日志的使用场景

例子

系统日志和操作日志的区别

系统日志:系统日志主要是为开发排查问题提供依据,一般打印在日志文件中;系统日志的可读性要求没那么高,日志中会包含代码的信息,比如在某个类的某一行打印了一个日志。

操作日志:主要是对某个对象进行新增操作或者修改操作后记录下这个新增或者修改,操作日志要求可读性比较强,因为它主要是给用户看的,比如订单的物流信息,用户需要知道在什么时间发生了什么事情。再比如,客服对工单的处理记录信息。

操作日志的记录格式大概分为下面几种: * 单纯的文字记录,比如:2021-09-16 10:00 订单创建。 * 简单的动态的文本记录,比如:2021-09-16 10:00 订单创建,订单号:NO.11089999,其中涉及变量订单号“NO.11089999”。 * 修改类型的文本,包含修改前和修改后的值,比如:2021-09-16 10:00 用户小明修改了订单的配送地址:从“金灿灿小区”修改到“银盏盏小区” ,其中涉及变量配送的原地址“金灿灿小区”和新地址“银盏盏小区”。 * 修改表单,一次会修改多个字段。

2. 实现方式

2.1 使用 Canal 监听数据库记录操作日志

Canal 是一款基于 MySQL 数据库增量日志解析,提供增量数据订阅和消费的开源组件,通过采用监听数据库 Binlog 的方式,这样可以从底层知道是哪些数据做了修改,然后根据更改的数据记录操作日志。

这种方式的优点是和业务逻辑完全分离。缺点也很明显,局限性太高,只能针对数据库的更改做操作日志记录,如果修改涉及到其他团队的 RPC 的调用,就没办法监听数据库了,举个例子:给用户发送通知,通知服务一般都是公司内部的公共组件,这时候只能在调用 RPC 的时候手工记录发送通知的操作日志了。

2.2 通过日志文件的方式记录

log.info("订单创建")
log.info("订单已经创建,订单编号:{}", orderNo)
log.info("修改了订单的配送地址:从“{}”修改到“{}”, "金灿灿小区", "银盏盏小区")

这种方式的操作记录需要解决三个问题。

问题一:操作人如何记录

借助 SLF4J 中的 MDC 工具类,把操作人放在日志中,然后在日志中统一打印出来。首先在用户的拦截器中把用户的标识 Put 到 MDC 中。

@Component
public class UserInterceptor extends HandlerInterceptorAdapter {
  @Override
  public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    //获取到用户标识
    String userNo = getUserNo(request);
    //把用户 ID 放到 MDC 上下文中
    MDC.put("userId", userNo);
    return super.preHandle(request, response, handler);
  }

  private String getUserNo(HttpServletRequest request) {
    // 通过 SSO 或者Cookie 或者 Auth信息获取到 当前登陆的用户信息
    return null;
  }
}

其次,把 userId 格式化到日志中,使用 %X{userId} 可以取到 MDC 中用户标识。

"%d{yyyy-MM-dd HH:mm:ss.SSS} %t %-5level %X{userId} %logger{30}.%method:%L - %msg%n"

问题二:操作日志如何和系统日志区分开

通过配置 Log 的配置文件,把有关操作日志的 Log 单独放到一日志文件中。

//不同业务日志记录到不同的文件

    logs/business.log
    true
    
        INFO
        ACCEPT
        DENY
    
    
        logs/业务A.%d.%i.log
        90
        
            10MB
        
    
    
        "%d{yyyy-MM-dd HH:mm:ss.SSS} %t %-5level %X{userId} %logger{30}.%method:%L - %msg%n"
        UTF-8
    

        

    

然后在 Java 代码中单独的记录业务日志。

//记录特定日志的声明
private final Logger businessLog = LoggerFactory.getLogger("businessLog");
 
//日志存储
businessLog.info("修改了配送地址");

问题三:如何生成可读懂的日志文案

可以采用 LogUtil 的方式,也可以采用切面的方式生成日志模板,后续内容将会进行介绍。这样就可以把日志单独保存在一个文件中,然后通过日志收集可以把日志保存在 Elasticsearch 或者数据库中,接下来看下如何生成可读的操作日志。

2.3 通过 LogUtil 的方式记录日志

  LogUtil.log(orderNo, "订单创建", "小明")模板
  LogUtil.log(orderNo, "订单创建,订单号"+"NO.11089999",  "小明")
  String template = "用户%s修改了订单的配送地址:从“%s”修改到“%s”"
  LogUtil.log(orderNo, String.format(tempalte, "小明", "金灿灿小区", "银盏盏小区"),  "小明")

这里解释下为什么记录操作日志的时候都绑定了一个 OrderNo,因为操作日志记录的是:某一个“时间”“谁”对“什么”做了什么“事情”。当查询业务的操作日志的时候,会查询针对这个订单的的所有操作,所以代码中加上了 OrderNo,记录操作日志的时候需要记录下操作人,所以传了操作人“小明”进来。

上面看起来问题并不大,在修改地址的业务逻辑方法中使用一行代码记录了操作日志,接下来再看一个更复杂的例子:

private OnesIssueDO updateAddress(updateDeliveryRequest request) {
    DeliveryOrder deliveryOrder = deliveryQueryService.queryOldAddress(request.getDeliveryOrderNo());
    // 更新派送信息,电话,收件人,地址
    doUpdate(request);
    String logContent = getLogContent(request, deliveryOrder);
    LogUtils.logRecord(request.getOrderNo(), logContent, request.getOperator);
    return onesIssueDO;
}

private String getLogContent(updateDeliveryRequest request, DeliveryOrder deliveryOrder) {
    String template = "用户%s修改了订单的配送地址:从“%s”修改到“%s”";
    return String.format(tempalte, request.getUserName(), deliveryOrder.getAddress(), request.getAddress);
}

可以看到上面的例子使用了两个方法代码,外加一个 getLogContent 的函数实现了操作日志的记录。当业务变得复杂后,记录操作日志放在业务代码中会导致业务的逻辑比较繁杂,最后导致 LogUtils.logRecord() 方法的调用存在于很多业务的代码中,而且类似 getLogContent() 这样的方法也散落在各个业务类中,对于代码的可读性和可维护性来说是一个灾难。下面介绍下如何避免这个灾难。

2.4 方法注解实现操作日志

为了解决上面问题,一般采用 AOP 的方式记录日志,让操作日志和业务逻辑解耦,接下来看一个简单的 AOP 日志的例子。

@LogRecord(content="修改了配送地址")
public void modifyAddress(updateDeliveryRequest request){
    // 更新派送信息 电话,收件人、地址
    doUpdate(request);
}

我们可以在注解的操作日志上记录固定文案,这样业务逻辑和业务代码可以做到解耦,让我们的业务代码变得纯净起来。可能有同学注意到,上面的方式虽然解耦了操作日志的代码,但是记录的文案并不符合我们的预期,文案是静态的,没有包含动态的文案,因为我们需要记录的操作日志是: 用户%s修改了订单的配送地址,从“%s”修改到“%s”。接下来,我们介绍一下如何优雅地使用 AOP 生成动态的操作日志。

3. 优雅地支持 AOP 生成动态的操作日志

3.1 动态模板

一提到动态模板,就会涉及到让变量通过占位符的方式解析模板,从而达到通过注解记录操作日志的目的。模板解析的方式有很多种,这里使用了 SpEL(Spring Expression Language,Spring表达式语言)来实现。我们可以先写下期望的记录日志的方式,然后再看下能否实现这样的功能。

@LogRecord(content = "修改了订单的配送地址:从“#oldAddress”, 修改到“#request.address”")
public void modifyAddress(updateDeliveryRequest request, String oldAddress){
    // 更新派送信息 电话,收件人、地址
    doUpdate(request);
}

通过 SpEL 表达式引用方法上的参数,可以让变量填充到模板中达到动态的操作日志文本内容。 但是现在还有几个问题需要解决: * 操作日志需要知道是哪个操作人修改的订单配送地址。 * 修改订单配送地址的操作日志需要绑定在配送的订单上,从而可以根据配送订单号查询出对这个配送订单的所有操作。 * 为了在注解上记录之前的配送地址是什么,在方法签名上添加了一个和业务无关的 oldAddress 的变量,这样就不优雅了。

为了解决前两个问题,我们需要把期望的操作日志使用形式改成下面的方式:

@LogRecord(
     content = "修改了订单的配送地址:从“#oldAddress”, 修改到“#request.address”",
     operator = "#request.userName", bizNo="#request.deliveryOrderNo")
public void modifyAddress(updateDeliveryRequest request, String oldAddress){
    // 更新派送信息 电话,收件人、地址
    doUpdate(request);
}

修改后的代码在注解上添加两个参数,一个是操作人,一个是操作日志需要绑定的对象。但是,在普通的 Web 应用中用户信息都是保存在一个线程上下文的静态方法中,所以 operator 一般是这样的写法(假定获取当前登陆用户的方式是 UserContext.getCurrentUser())。

operator = "#{T(com.meituan.user.UserContext).getCurrentUser()}"

这样的话,每个 @LogRecord 的注解上的操作人都是这么长一串。为了避免过多的重复代码,我们可以把注解上的 operator 参数设置为非必填,这样用户可以填写操作人。但是,如果用户不填写我们就取 UserContext 的 user(下文会介绍如何取 user )。最后,最简单的日志变成了下面的形式:

@LogRecord(content = "修改了订单的配送地址:从“#oldAddress”, 修改到“#request.address”", 
           bizNo="#request.deliveryOrderNo")
public void modifyAddress(updateDeliveryRequest request, String oldAddress){
    // 更新派送信息 电话,收件人、地址
    doUpdate(request);
}

接下来,我们需要解决第三个问题:为了记录业务操作记录添加了一个 oldAddress 变量,不管怎么样这都不是一个好的实现方式,所以接下来,我们需要把 oldAddress 变量从修改地址的方法签名上去掉。但是操作日志确实需要 oldAddress 变量,怎么办呢?

要么和产品经理 PK 一下,让产品经理把文案从“修改了订单的配送地址:从 xx 修改到 yy” 改为 “修改了订单的配送地址为:yy”。但是从用户体验上来看,第一种文案更人性化一些,显然我们不会 PK 成功的。那么我们就必须要把这个 oldAddress 查询出来然后供操作日志使用了。还有一种解决办法是:把这个参数放到操作日志的线程上下文中,供注解上的模板使用。我们按照这个思路再改下操作日志的实现代码。

@LogRecord(content = "修改了订单的配送地址:从“#oldAddress”, 修改到“#request.address”",
        bizNo="#request.deliveryOrderNo")
public void modifyAddress(updateDeliveryRequest request){
    // 查询出原来的地址是什么
    LogRecordContext.putVariable("oldAddress", DeliveryService.queryOldAddress(request.getDeliveryOrderNo()));
    // 更新派送信息 电话,收件人、地址
    doUpdate(request);
}

这时候可以看到,LogRecordContext 解决了操作日志模板上使用方法参数以外变量的问题,同时避免了为了记录操作日志修改方法签名的设计。虽然已经比之前的代码好了些,但是依然需要在业务代码里面加了一行业务逻辑无关的代码,如果有“强迫症”的同学还可以继续往下看,接下来我们会讲解自定义函数的解决方案。下面再看另一个例子:

@LogRecord(content = "修改了订单的配送员:从“#oldDeliveryUserId”, 修改到“#request.userId”",
        bizNo="#request.deliveryOrderNo")
public void modifyAddress(updateDeliveryRequest request){
    // 查询出原来的地址是什么
    LogRecordContext.putVariable("oldDeliveryUserId", DeliveryService.queryOldDeliveryUserId(request.getDeliveryOrderNo()));
    // 更新派送信息 电话,收件人、地址
    doUpdate(request);
}

这个操作日志的模板最后记录的内容是这样的格式:修改了订单的配送员:从 “10090”,修改到 “10099”,显然用户看到这样的操作日志是不明白的。用户对于用户 ID 是 10090 还是 10099 并不了解,用户期望看到的是:修改了订单的配送员:从“张三(18910008888)”,修改到“小明(13910006666)”。用户关心的是配送员的姓名和电话。但是我们方法中传递的参数只有配送员的 ID,没有配送员的姓名可电话。我们可以通过上面的方法,把用户的姓名和电话查询出来,然后通过 LogRecordContext 实现。

但是,“强迫症”是不期望操作日志的代码嵌入在业务逻辑中的。接下来,我们考虑另一种实现方式:自定义函数。如果我们可以通过自定义函数把用户 ID 转换为用户姓名和电话,那么就能解决这一问题,按照这个思路,我们把模板修改为下面的形式:

@LogRecord(content = "修改了订单的配送员:从“{deliveryUser{#oldDeliveryUserId} }”, 修改到“{deveryUser{#request.userId} }”",
        bizNo="#request.deliveryOrderNo")
public void modifyAddress(updateDeliveryRequest request){
    // 查询出原来的地址是什么
    LogRecordContext.putVariable("oldDeliveryUserId", DeliveryService.queryOldDeliveryUserId(request.getDeliveryOrderNo()));
    // 更新派送信息 电话,收件人、地址
    doUpdate(request);
}

其中 deliveryUser 是自定义函数,使用大括号把 Spring 的 SpEL 表达式包裹起来,这样做的好处:一是把 SpEL(Spring Expression Language,Spring表达式语言)和自定义函数区分开便于解析;二是如果模板中不需要 SpEL 表达式解析可以容易的识别出来,减少 SpEL 的解析提高性能。这时候我们发现上面代码还可以优化成下面的形式:

@LogRecord(content = "修改了订单的配送员:从“{queryOldUser{#request.deliveryOrderNo()} }”, 修改到“{deveryUser{#request.userId} }”",
        bizNo="#request.deliveryOrderNo")
public void modifyAddress(updateDeliveryRequest request){
    // 更新派送信息 电话,收件人、地址
    doUpdate(request);
}

这样就不需要在 modifyAddress 方法中通过 LogRecordContext.putVariable() 设置老的快递员了,通过直接新加一个自定义函数 queryOldUser() 参数把派送订单传递进去,就能查到之前的配送人了,只需要让方法的解析在 modifyAddress() 方法执行之前运行。这样的话,我们让业务代码又变得纯净了起来,同时也让“强迫症”不再感到难受了。

4. 代码实现解析

4.1 代码结构

上面的操作日志主要是通过一个 AOP 拦截器实现的,整体主要分为 AOP 模块、日志解析模块、日志保存模块、Starter 模块;组件提供了4个扩展点,分别是:自定义函数、默认处理人、业务保存和查询;业务可以根据自己的业务特性定制符合自己业务的逻辑。

4.2 模块介绍

有了上面的分析,已经得出一种我们期望的操作日志记录的方式,那么接下来看看如何实现上面的逻辑。实现主要分为下面几个步骤: * AOP 拦截逻辑 * 解析逻辑 * 模板解析 * LogContext 逻辑 * 默认的 operator 逻辑 * 自定义函数逻辑 * 默认的日志持久化逻辑 * Starter 封装逻辑

4.2.1 AOP 拦截逻辑

这块逻辑主要是一个拦截器,针对 @LogRecord 注解分析出需要记录的操作日志,然后把操作日志持久化,这里把注解命名为 @LogRecordAnnotation。接下来,我们看下注解的定义:

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface LogRecordAnnotation {
    String success();

    String fail() default "";

    String operator() default "";

    String bizNo();

    String category() default "";

    String detail() default "";

    String condition() default "";
}

注解中除了上面提到参数外,还增加了 fail、category、detail、condition 等参数,这几个参数是为了满足特定的场景,后面还会给出具体的例子。

参数名描述是否必填
success操作日志的文本模板
fail操作日志失败的文本版本
operator操作日志的执行人
bizNo操作日志绑定的业务对象标识
category操作日志的种类
detail扩展参数,记录操作日志的修改详情
condition记录日志的条件

为了保持简单,组件的必填参数就两个。业务中的 AOP 逻辑大部分是使用 @Aspect 注解实现的,但是基于注解的 AOP 在 Spring boot 1.5 中兼容性是有问题的,组件为了兼容 Spring boot1.5 的版本我们手工实现 Spring 的 AOP 逻辑。

切面选择 AbstractBeanFactoryPointcutAdvisor 实现,切点是通过 StaticMethodMatcherPointcut 匹配包含 LogRecordAnnotation 注解的方法。通过实现 MethodInterceptor 接口实现操作日志的增强逻辑。

下面是拦截器的切点逻辑:

public class LogRecordPointcut extends StaticMethodMatcherPointcut implements Serializable {
    // LogRecord的解析类
    private LogRecordOperationSource logRecordOperationSource;
    
    @Override
    public boolean matches(@NonNull Method method, @NonNull Class targetClass) {
          // 解析 这个 method 上有没有 @LogRecordAnnotation 注解,有的话会解析出来注解上的各个参数
        return !CollectionUtils.isEmpty(logRecordOperationSource.computeLogRecordOperations(method, targetClass));
    }

    void setLogRecordOperationSource(LogRecordOperationSource logRecordOperationSource) {
        this.logRecordOperationSource = logRecordOperationSource;
    }
}

切面的增强逻辑主要代码如下:

@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
    Method method = invocation.getMethod();
    // 记录日志
    return execute(invocation, invocation.getThis(), method, invocation.getArguments());
}

private Object execute(MethodInvocation invoker, Object target, Method method, Object[] args) throws Throwable {
    Class targetClass = getTargetClass(target);
    Object ret = null;
    MethodExecuteResult methodExecuteResult = new MethodExecuteResult(true, null, "");
    LogRecordContext.putEmptySpan();
    Collection operations = new ArrayList<>();
    Map functionNameAndReturnMap = new HashMap<>();
    try {
        operations = logRecordOperationSource.computeLogRecordOperations(method, targetClass);
        List spElTemplates = getBeforeExecuteFunctionTemplate(operations);
        //业务逻辑执行前的自定义函数解析
        functionNameAndReturnMap = processBeforeExecuteFunctionTemplate(spElTemplates, targetClass, method, args);
    } catch (Exception e) {
        log.error("log record parse before function exception", e);
    }
    try {
        ret = invoker.proceed();
    } catch (Exception e) {
        methodExecuteResult = new MethodExecuteResult(false, e, e.getMessage());
    }
    try {
        if (!CollectionUtils.isEmpty(operations)) {
            recordExecute(ret, method, args, operations, targetClass,
                    methodExecuteResult.isSuccess(), methodExecuteResult.getErrorMsg(), functionNameAndReturnMap);
        }
    } catch (Exception t) {
        //记录日志错误不要影响业务
        log.error("log record parse exception", t);
    } finally {
        LogRecordContext.clear();
    }
    if (methodExecuteResult.throwable != null) {
        throw methodExecuteResult.throwable;
    }
    return ret;
}

拦截逻辑的流程:

可以看到,操作日志的记录持久化是在方法执行完之后执行的,当方法抛出异常之后会先捕获异常,等操作日志持久化完成后再抛出异常。在业务的方法执行之前,会对提前解析的自定义函数求值,解决了前面提到的需要查询修改之前的内容。

4.2.2 解析逻辑

模板解析

Spring 3 提供了一个非常强大的功能:Spring EL,SpEL 在 Spring 产品中是作为表达式求值的核心基础模块,它本身是可以脱离 Spring 独立使用的。举个例子:

public static void main(String[] args) {
        SpelExpressionParser parser = new SpelExpressionParser();
        Expression expression = parser.parseExpression("#root.purchaseName");
        Order order = new Order();
        order.setPurchaseName("张三");
        System.out.println(expression.getValue(order));
}

这个方法将打印 “张三”。LogRecord 解析的类图如下:

解析核心类LogRecordValueParser 里面封装了自定义函数和 SpEL 解析类 LogRecordExpressionEvaluator

public class LogRecordExpressionEvaluator extends CachedExpressionEvaluator {

    private Map expressionCache = new ConcurrentHashMap<>(64);

    private final Map targetMethodCache = new ConcurrentHashMap<>(64);

    public String parseExpression(String conditionExpression, AnnotatedElementKey methodKey, EvaluationContext evalContext) {
        return getExpression(this.expressionCache, methodKey, conditionExpression).getValue(evalContext, String.class);
    }
}

LogRecordExpressionEvaluator 继承自 CachedExpressionEvaluator 类,这个类里面有两个 Map,一个是 expressionCache 一个是 targetMethodCache。在上面的例子中可以看到,SpEL 会解析成一个 Expression 表达式,然后根据传入的 Object 获取到对应的值,所以 expressionCache 是为了缓存方法、表达式和 SpEL 的 Expression 的对应关系,让方法注解上添加的 SpEL 表达式只解析一次。 下面的 targetMethodCache 是为了缓存传入到 Expression 表达式的 Object。核心的解析逻辑是上面最后一行代码。

getExpression(this.expressionCache, methodKey, conditionExpression).getValue(evalContext, String.class);

getExpression 方法会从 expressionCache 中获取到 @LogRecordAnnotation 注解上的表达式的解析 Expression 的实例,然后调用 getValue 方法,getValue 传入一个 evalContext 就是类似上面例子中的 order 对象。其中 Context 的实现将会在下文介绍。

日志上下文实现

下面的例子把变量放到了 LogRecordContext 中,然后 SpEL 表达式就可以顺利的解析方法上不存在的参数了,通过上面的 SpEL 的例子可以看出,要把方法的参数和 LogRecordContext 中的变量都放到 SpEL 的 getValue 方法的 Object 中才可以顺利的解析表达式的值。下面看下如何实现:

@LogRecord(content = "修改了订单的配送员:从“{deveryUser{#oldDeliveryUserId} }”, 修改到“{deveryUser{#request.getUserId()} }”",
            bizNo="#request.getDeliveryOrderNo()")
public void modifyAddress(updateDeliveryRequest request){
    // 查询出原来的地址是什么
    LogRecordContext.putVariable("oldDeliveryUserId", DeliveryService.queryOldDeliveryUserId(request.getDeliveryOrderNo()));
    // 更新派送信息 电话,收件人、地址
    doUpdate(request);
}

在 LogRecordValueParser 中创建了一个 EvaluationContext,用来给 SpEL 解析方法参数和 Context 中的变量。相关代码如下:


EvaluationContext evaluationContext = expressionEvaluator.createEvaluationContext(method, args, targetClass, ret, errorMsg, beanFactory);

在解析的时候调用 getValue 方法传入的参数 evalContext,就是上面这个 EvaluationContext 对象。下面是 LogRecordEvaluationContext 对象的继承体系:

LogRecordEvaluationContext 做了三个事情: * 把方法的参数都放到 SpEL 解析的 RootObject 中。 * 把 LogRecordContext 中的变量都放到 RootObject 中。 * 把方法的返回值和 ErrorMsg 都放到 RootObject 中。

LogRecordEvaluationContext 的代码如下:

public class LogRecordEvaluationContext extends MethodBasedEvaluationContext {

    public LogRecordEvaluationContext(Object rootObject, Method method, Object[] arguments,
                                      ParameterNameDiscoverer parameterNameDiscoverer, Object ret, String errorMsg) {
       //把方法的参数都放到 SpEL 解析的 RootObject 中
       super(rootObject, method, arguments, parameterNameDiscoverer);
       //把 LogRecordContext 中的变量都放到 RootObject 中
        Map variables = LogRecordContext.getVariables();
        if (variables != null && variables.size() > 0) {
            for (Map.Entry entry : variables.entrySet()) {
                setVariable(entry.getKey(), entry.getValue());
            }
        }
        //把方法的返回值和 ErrorMsg 都放到 RootObject 中
        setVariable("_ret", ret);
        setVariable("_errorMsg", errorMsg);
    }
}

下面是 LogRecordContext 的实现,这个类里面通过一个 ThreadLocal 变量保持了一个栈,栈里面是个 Map,Map 对应了变量的名称和变量的值。

public class LogRecordContext {

    private static final InheritableThreadLocal>> variableMapStack = new InheritableThreadLocal<>();
   //其他省略....
}

上面使用了 InheritableThreadLocal,所以在线程池的场景下使用 LogRecordContext 会出现问题,如果支持线程池可以使用阿里巴巴开源的 TTL 框架。那这里为什么不直接设置一个 ThreadLocal> 对象,而是要设置一个 Stack 结构呢?我们看一下这么做的原因是什么。

@LogRecord(content = "修改了订单的配送员:从“{deveryUser{#oldDeliveryUserId} }”, 修改到“{deveryUser{#request.getUserId()} }”",
        bizNo="#request.getDeliveryOrderNo()")
public void modifyAddress(updateDeliveryRequest request){
    // 查询出原来的地址是什么
    LogRecordContext.putVariable("oldDeliveryUserId", DeliveryService.queryOldDeliveryUserId(request.getDeliveryOrderNo()));
    // 更新派送信息 电话,收件人、地址
    doUpdate(request);
}

上面代码的执行流程如下:

看起来没有什么问题,但是使用 LogRecordAnnotation 的方法里面嵌套了另一个使用 LogRecordAnnotation 方法的时候,流程就变成下面的形式:

可以看到,当方法二执行了释放变量后,继续执行方法一的 logRecord 逻辑,此时解析的时候 ThreadLocal>的 Map 已经被释放掉,所以方法一就获取不到对应的变量了。方法一和方法二共用一个变量 Map 还有个问题是:如果方法二设置了和方法一相同的变量两个方法的变量就会被相互覆盖。所以最终 LogRecordContext 的变量的生命周期需要是下面的形式:

LogRecordContext 每执行一个方法都会压栈一个 Map,方法执行完之后会 Pop 掉这个 Map,从而避免变量共享和覆盖问题。

默认操作人逻辑

在 LogRecordInterceptor 中 IOperatorGetService 接口,这个接口可以获取到当前的用户。下面是接口的定义:

public interface IOperatorGetService {

    /**
     * 可以在里面外部的获取当前登陆的用户,比如 UserContext.getCurrentUser()
     *
     * @return 转换成Operator返回
     */
    Operator getUser();
}

下面给出了从用户上下文中获取用户的例子:

public class DefaultOperatorGetServiceImpl implements IOperatorGetService {

    @Override
    public Operator getUser() {
    //UserUtils 是获取用户上下文的方法
         return Optional.ofNullable(UserUtils.getUser())
                        .map(a -> new Operator(a.getName(), a.getLogin()))
                        .orElseThrow(()->new IllegalArgumentException("user is null"));
        
    }
}

组件在解析 operator 的时候,就判断注解上的 operator 是否是空,如果注解上没有指定,我们就从 IOperatorGetService 的 getUser 方法获取了。如果都获取不到,就会报错。

String realOperatorId = "";
if (StringUtils.isEmpty(operatorId)) {
    if (operatorGetService.getUser() == null  StringUtils.isEmpty(operatorGetService.getUser().getOperatorId())) {
        throw new IllegalArgumentException("user is null");
    }
    realOperatorId = operatorGetService.getUser().getOperatorId();
} else {
    spElTemplates = Lists.newArrayList(bizKey, bizNo, action, operatorId, detail);
}

自定义函数逻辑

自定义函数的类图如下:

下面是 IParseFunction 的接口定义:executeBefore 函数代表了自定义函数是否在业务代码执行之前解析,上面提到的查询修改之前的内容。

public interface IParseFunction {

  default boolean executeBefore(){
    return false;
  }

  String functionName();

  String apply(String value);
}

ParseFunctionFactory 的代码比较简单,它的功能是把所有的 IParseFunction 注入到函数工厂中。

public class ParseFunctionFactory {
  private Map allFunctionMap;

  public ParseFunctionFactory(List parseFunctions) {
    if (CollectionUtils.isEmpty(parseFunctions)) {
      return;
    }
    allFunctionMap = new HashMap<>();
    for (IParseFunction parseFunction : parseFunctions) {
      if (StringUtils.isEmpty(parseFunction.functionName())) {
        continue;
      }
      allFunctionMap.put(parseFunction.functionName(), parseFunction);
    }
  }

  public IParseFunction getFunction(String functionName) {
    return allFunctionMap.get(functionName);
  }

  public boolean isBeforeFunction(String functionName) {
    return allFunctionMap.get(functionName) != null && allFunctionMap.get(functionName).executeBefore();
  }
}

DefaultFunctionServiceImpl 的逻辑就是根据传入的函数名称 functionName 找到对应的 IParseFunction,然后把参数传入到 IParseFunction 的 apply 方法上最后返回函数的值。

public class DefaultFunctionServiceImpl implements IFunctionService {

  private final ParseFunctionFactory parseFunctionFactory;

  public DefaultFunctionServiceImpl(ParseFunctionFactory parseFunctionFactory) {
    this.parseFunctionFactory = parseFunctionFactory;
  }

  @Override
  public String apply(String functionName, String value) {
    IParseFunction function = parseFunctionFactory.getFunction(functionName);
    if (function == null) {
      return value;
    }
    return function.apply(value);
  }

  @Override
  public boolean beforeFunction(String functionName) {
    return parseFunctionFactory.isBeforeFunction(functionName);
  }
}

4.2.3 日志持久化逻辑

同样在 LogRecordInterceptor 的代码中引用了 ILogRecordService,这个 Service 主要包含了日志记录的接口。

public interface ILogRecordService {
    /**
     * 保存 log
     *
     * @param logRecord 日志实体
     */
    void record(LogRecord logRecord);

}

业务可以实现这个保存接口,然后把日志保存在任何存储介质上。这里给了一个 2.2 节介绍的通过 log.info 保存在日志文件中的例子,业务可以把保存设置成异步或者同步,可以和业务放在一个事务中保证操作日志和业务的一致性,也可以新开辟一个事务,保证日志的错误不影响业务的事务。业务可以保存在 Elasticsearch、数据库或者文件中,用户可以根据日志结构和日志的存储实现相应的查询逻辑。

@Slf4j
public class DefaultLogRecordServiceImpl implements ILogRecordService {

    @Override
//    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void record(LogRecord logRecord) {
        log.info("【logRecord】log={}", logRecord);
    }
}

4.2.4 Starter 逻辑封装

上面逻辑代码已经介绍完毕,那么接下来需要把这些组件组装起来,然后让用户去使用。在使用这个组件的时候只需要在 Springboot 的入口上添加一个注解 @EnableLogRecord(tenant = “com.mzt.test”)。其中 tenant 代表租户,是为了多租户使用的。

@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
@EnableTransactionManagement
@EnableLogRecord(tenant = "com.mzt.test")
public class Main {

    public static void main(String[] args) {
        SpringApplication.run(Main.class, args);
    }
}

再看下 EnableLogRecord 的代码,代码中 Import 了 LogRecordConfigureSelector.class,在 LogRecordConfigureSelector 类中暴露了 LogRecordProxyAutoConfiguration 类。

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import(LogRecordConfigureSelector.class)
public @interface EnableLogRecord {

    String tenant();
    
    AdviceMode mode() default AdviceMode.PROXY;
}

LogRecordProxyAutoConfiguration 就是装配上面组件的核心类了,代码如下:

@Configuration
@Slf4j
public class LogRecordProxyAutoConfiguration implements ImportAware {

  private AnnotationAttributes enableLogRecord;


  @Bean
  @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
  public LogRecordOperationSource logRecordOperationSource() {
    return new LogRecordOperationSource();
  }

  @Bean
  @ConditionalOnMissingBean(IFunctionService.class)
  public IFunctionService functionService(ParseFunctionFactory parseFunctionFactory) {
    return new DefaultFunctionServiceImpl(parseFunctionFactory);
  }

  @Bean
  public ParseFunctionFactory parseFunctionFactory(@Autowired List parseFunctions) {
    return new ParseFunctionFactory(parseFunctions);
  }

  @Bean
  @ConditionalOnMissingBean(IParseFunction.class)
  public DefaultParseFunction parseFunction() {
    return new DefaultParseFunction();
  }


  @Bean
  @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
  public BeanFactoryLogRecordAdvisor logRecordAdvisor(IFunctionService functionService) {
    BeanFactoryLogRecordAdvisor advisor =
            new BeanFactoryLogRecordAdvisor();
    advisor.setLogRecordOperationSource(logRecordOperationSource());
    advisor.setAdvice(logRecordInterceptor(functionService));
    return advisor;
  }

  @Bean
  @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
  public LogRecordInterceptor logRecordInterceptor(IFunctionService functionService) {
    LogRecordInterceptor interceptor = new LogRecordInterceptor();
    interceptor.setLogRecordOperationSource(logRecordOperationSource());
    interceptor.setTenant(enableLogRecord.getString("tenant"));
    interceptor.setFunctionService(functionService);
    return interceptor;
  }

  @Bean
  @ConditionalOnMissingBean(IOperatorGetService.class)
  @Role(BeanDefinition.ROLE_APPLICATION)
  public IOperatorGetService operatorGetService() {
    return new DefaultOperatorGetServiceImpl();
  }

  @Bean
  @ConditionalOnMissingBean(ILogRecordService.class)
  @Role(BeanDefinition.ROLE_APPLICATION)
  public ILogRecordService recordService() {
    return new DefaultLogRecordServiceImpl();
  }

  @Override
  public void setImportMetadata(AnnotationMetadata importMetadata) {
    this.enableLogRecord = AnnotationAttributes.fromMap(
            importMetadata.getAnnotationAttributes(EnableLogRecord.class.getName(), false));
    if (this.enableLogRecord == null) {
      log.info("@EnableCaching is not present on importing class");
    }
  }
}

这个类继承 ImportAware 是为了拿到 EnableLogRecord 上的租户属性,这个类使用变量 logRecordAdvisor 和 logRecordInterceptor 装配了 AOP,同时把自定义函数注入到了 logRecordAdvisor 中。

对外扩展类:分别是IOperatorGetServiceILogRecordServiceIParseFunction。业务可以自己实现相应的接口,因为配置了 @ConditionalOnMissingBean,所以用户的实现类会覆盖组件内的默认实现。

5. 总结

这篇文章介绍了操作日志的常见写法,以及如何让操作日志的实现更加简单、易懂;通过组件的四个模块,介绍了组件的具体实现。对于上面的组件介绍,大家如果有疑问,也欢迎在文末留言,我们会进行答疑。

6. 作者简介

站通,2020年加入美团,基础研发平台/研发质量及效率部工程师。

7. 参考资料

8. 招聘信息

美团研发质量及效率部 ,致力于建设业界一流的持续交付平台,现招聘基础组件方向相关的工程师,坐标北京/上海。欢迎感兴趣的同学加入。可投递简历至:chao.yu@meituan.com(邮件主题请注明:美团研发质量及效率部)。

新一代CTR预测服务的GPU优化实践

1 背景

CTR(Click-Through-Rate)即点击通过率,是指网络广告的点击到达率,即该广告的实际点击次数除以广告的展现量。为CTR指标服务的打分模型,一般称为CTR模型。我们可以将此概念进一步扩展到互联网应用中各种预估转化率的模型。CTR模型在推荐、搜索、广告等场景被广泛应用。相对于CV(计算机视觉)、NLP(自然语音处理)场景的模型,CTR模型的历史结构比较简单,计算量较小。美团的CTR模型一直沿用CPU推理的方式。随着近几年深度神经网络的引入,CTR模型结构逐渐趋于复杂,计算量也越来越大,CPU开始不能满足模型对于算力的需求。

而GPU拥有几千个计算核心,可以在单机内提供密集的并行计算能力,在CV、NLP等领域展示了强大的能力。通过CUDA[1]及相关API,英伟达建立了完整的GPU生态。基于此,美团基础研发平台通过一套方案将CTR模型部署到GPU上。单从模型预测阶段看,我们提供的基于英伟达T4的GPU深度优化方案,在相同成本约束下,对比CPU,提升了10倍的吞吐能力。同时,在典型的搜索精排场景中,从端到端的维度来看,整体吞吐能力提升了一倍以上。

除了提高吞吐、降低成本外,GPU方案还为CTR模型的应用带来了额外的可能。例如,在某搜索框自动补全的场景,由于天然的交互属性,时延要求非常苛刻,一般来说无法使用复杂的模型。而在GPU能力的加持下,某复杂模型的平均响应时间从15毫秒降低至6~7毫秒,已经达到了上线要求。

接下来,本文将与大家探讨美团机器学习平台提供的新一代CTR预测服务的GPU优化思路、效果、优势与不足,希望对从事相关工作的同学有所帮助或者启发。

2 CTR模型GPU推理的挑战

2.1 应用层的挑战

  1. CTR模型结构多变,包含大量业务相关的结构,同时新的SOTA模型也层出不穷,硬件供应商由于人力受限,会重点优化常用的经典结构,如ResNet。对于没有收敛的结构,官方没有端到端的优化工具可以支持。
  2. CTR模型中通常包含较大的Embedding表结构,要考虑到Embedding表存在显存放不下的情况。
  3. 在典型的推荐场景中,为了达到更快的POI曝光的目的,模型的时效性要求很高,在线模型服务需要提供增量更新模型的能力。

2.2 框架层的挑战

  1. 算子层面:目前主流的深度学习框架,如TensorFlow和PyTorch,可以说是深度学习第二代框架,它们首先要解决第一代框架Caffe的问题,Caffe有一个明显问题就是Layer的粒度过粗,导致那个时代的算法开发者都必须有“自己写自定义层”的能力。TensorFlow和PyTorch都把模型表达能力放在较高的优先级,导致算子粒度比较小,无论是对CPU还是GPU架构,都会带来很大的额外开销。
  2. 框架层面:TensorFlow和PyTorch本质都是训练框架,对算法开发者比较友好,但非部署友好。其中隐含了很多为了方便分布式训练做的设计,比如TensorFlow为了方便将Variable拆到不同的PS上,内置了Partitioned_Variable的设计。在基于GPU单机预测的场景下,这些结构也会带来额外的开销。

2.3 硬件层的挑战

第一,TensorFlow的算子粒度划分较细,导致一个模型通常由几千个算子构成,这些算子在GPU上的执行转变为对应的GPU kernel的执行。kernel是GPU上并行执行的函数。

GPU kernel大体上可以划分为传输数据、kernel启动、kernel计算等几个阶段,其中每个kernel的启动需要约10𝞵𝘀左右。大量的小算子导致每个kernel的执行时间很短,kernel启动的耗时占了大部分。相邻的kernel之间需要通过读写显存进行数据的传输,产生大量的访存开销。而GPU的访存吞吐远远低于计算吞吐,导致性能低下,GPU利用率并不高。

第二,GPU卡上包含多个计算单元,理论上,不同计算单元是可以跑不同kernel的,但实际上为了编程简单,CUDA默认假设在同一时刻一个Stream里跑同一个kernel。虽然可以通过多Stream的方式跑,但是多Steam之间又缺少细粒度的协同机制。

在经过充分调研与讨论后,我们决定第一期重点关注TensorFlow框架下如何解决常见CTR模型结构在英伟达GPU上执行效率不高的问题,我们先将问题收敛为以下两个子问题: 1. 算子粒度过细,GPU执行效率低下。 2. 模型结构多变,手工优化投入大,通用性差。

3 优化手段

为了解决上面的问题,我们对业界深度学习加速器进行了一些调研。业界比较成熟的推理优化方案主要是TensorRT/XLA/TVM。TensorRT采用手工优化,对一些定制的模型结构进行算子融合,并对计算密集型算子(如卷积)进行了高效调优。XLA是TensorFlow内置的编译优化工具,主要针对访存密集型结构,通过编译手段,实现算子的融合。TVM[2]具备较全面的优化能力,使用编译手段进行算子的融合,同时可以通过机器学习的方式实现计算密集型算子的自动调优。

经过广泛的调研和对比,我们最终选择了TVM作为优化工具。TVM通过编译手段,可以较好地应对多变的模型结构,解决了手工优化通用性差的问题。但TVM应用在业务模型也存在一系列问题:支持的算子数较少,而且目前对动态Shape的支持还不够好。针对这两个问题,我们将TVM和TensorFlow结合起来,结合CTR模型的结构特点与GPU的硬件特性,开发一系列流程,实现了对CTR模型的优化。

3.1 算子融合

通过将多个小算子融合为一个语义等价的大算子,可以有效减少GPU上的kernel数量。一方面,kernel数量减少直接降低了kernel发射的开销;另一方面,融合后的大kernel执行的计算量增加,避免了多个kernel间数据传输导致的频繁访存,提高了计算的访存比。

可以看到,上图中的左右等价结构,左侧的21个算子执行的运算,可以在1个等价算子中完成。反映到GPU的活动上,左侧至少有21个GPU kernel以及21次显存的读写,而右侧只需要执行1个kernel以及1次显存读写。对于每个融合后的算子,需要有对应的kernel实现。然而,模型的算子组合是无穷的,对每种融合后算子手工实现kernel是不现实的。TVM通过编译手段,可以自动进行算子的融合以及设备代码生成,避免了逐一手写kernel的负担。

3.1.1 TF-TVM自动切图优化

TensorFlow模型中,如果包含TVM不支持的算子,会导致无法执行TVM转换。我们的思路是将可以用TVM优化的部分切出来,转为TVM的engine,其他部分依然使用TensorFlow的算子。在XLA和TRT转换的时候也有类似问题,我们分析了TF-XLA和TF-TRT二者的实现:

  1. TF-XLA的实现方案,在Grappler[4]优化图之后,有一个POST_REWRITE_FOR_EXEC(通过这个关键字可以在源码中搜索到)阶段,在这个阶段,会执行三个针对Graph的Pass,分别是用来标记算子,封装子图,改写子图并构建LaunchOp。
  2. TF-TRT的实现方案,TF-TRT在Grappler中注册了一个优化器,在这个优化器中,找到连通子图,并将其替换为TRT Engine。

在最终方案实现上,我们参考了TF-TRT的设计。这个设计对比XLA的优势在于XLA切图方案与TensorFlow源码紧耦合,直接将XLA的三个Pass嵌入到了启动Session的主流程中。而切图策略,优化策略后续会有非常频繁的迭代,我们不希望与TensorFlow的源码太过耦合。我们扩展了TF-TVM的方案,在实际使用中我们把这个切图过程为一个独立流程。在模型部署或更新时,自动触发。

在推理阶段,优化过的子图使用TVM执行,其余的计算图使用TensorFlow原生实现执行,将两者结合共同完成模型的推理。由于TVM和TensorFlow的Runtime各自使用独立的内存管理,数据在不同框架间传输会导致额外的性能开销。为了降低这部分开销,我们打通了两个框架的底层数据结构,尽可能避免额外的数据拷贝。

3.1.2 计算图等价替换

TensorFlow模型中过多的不被TVM支持的算子会导致TF-TVM切图零碎,影响最终的优化效果。为了让TF-TVM切图尽量大且完整,以及让TVM优化过程中的融合力度更大,我们对模型中的一些复杂结构进行检测,替换为执行更高效或更易于融合的等价结构。

例如,TensorFlow原生EmbeddingLookup结构,为了支持分布式训练,会对Embedding表进行切分,产生DynamicPartition和ParallelDynamicStitch等动态算子。这些动态算子不被TVM支持,导致TF-TVM图切分过于细碎。为了让TF-TVM切图更完整,我们通过图替换,对这种结构进行修改,通过将Embedding分表提前合并,得到简化的EmbeddingLookup结构。

3.2 CPU-GPU数据传输优化

TVM优化后的子图被替换为一个节点,该节点在GPU上执行,通常有几十甚至几百个输入,该节点的前置输入(如Placeholder)通常是在CPU上执行,会涉及多次的CPU-GPU传输。频繁的小数据量传输,无法充分利用带宽。为了解决这个问题,我们对模型结构进行修改,在计算图中添加合并与拆分节点,控制切图的位置,减少数据传输的次数。

一种可能的合并方式是,对这些输入按相同的Shape和Dtype进行合并,后续进行拆分,将拆分节点切入TVM的子图一起优化。这种方式会导致一些问题,如部分子图的算子融合效果不佳;另一方面,GPU kernel函数的参数传递内存限制在4KB,对于TVM节点输入非常多的情况(如超过512个),会遇到生成代码不合法的情况。

3.3 高频子图手工优化

对于TVM无法支持的子图,我们对业务中高频使用的结构进行抽象,采用手写自定义算子的方式,进行了高效GPU实现。

例如,模型中有部分时序特征使用String类型输入,将输入的字符串转为补齐的数字Tensor,将int类型的Tensor作为下标进行Embedding操作。这部分子图的语义如图,以下简称SE结构(StringEmbedding):

这一部分结构,TensorFlow的原生实现只有基于CPU的版本,在数据量较大且并行度较高的情景下,性能下降严重,成为整个模型的瓶颈。为了优化这部分结构的性能,我们在GPU上实现了高效的等价操作。

如图所示,PadString算子在CPU端将多个字符串按最大长度进行补齐,拼接成一个内存连续的uint8类型Tensor,以便一次性传输到GPU。StringEmbedding接收到补齐后的字符串后,利用GPU并行计算的特性,协同大量线程完成字符串的切分与查表操作。在涉及规约求和、求前缀和等关键过程中,使用了GPU上的Reduce/Scan算法,编码过程使用warp_shuffle指令,不同线程通过寄存器交换数据,避免了频繁访存的开销,获得了很好的性能。

GPU Scan算法示意,一个8个元素的前缀和操作,只需要3个迭代周期。在一个有几十路类似操作的模型中,手工优化前后的GPU timeline对比如下图,可以看到H2D + StringEmbedding这部分结构的耗时有很大的缩减,从42毫秒缩减到1.83毫秒。

除了StringEmbedding结构,我们对StringSplit + ToNumber + SparseSegmentSqrt、多路并行StringEmbedding等结构都进行了高效融合实现,在优化流程中通过结构匹配进行相应的替换。

3.4 CPU-GPU分流

实际线上的RPC请求,每个请求内的样本数(下文称Batch)是在[1,MaxValue]范围内变化的,MaxValue受上游业务系统,其他基础系统能力等多方面因素制约,相对固定。如上图所示,以某个搜索服务为例,我们统计了线上的Batch数值分布,Batch=MaxValue的请求占比约45%,Batch=45占比7.4%,Batch=1占比2.3%。其余的Batch占比从0.5%到1%不等。对于GPU来说,提高单个请求的Batch能更好地利用硬件资源,发挥GPU的并行计算能力,表现出相对CPU更优的延迟和吞吐;当Batch较小时,GPU相对CPU的优势就不明显了(下图是我们测试同样的模型在固定压力下,CPU/GPU上延迟的变化)。

大部分请求都由GPU在做了,CPU资源有较多空余,我们将一些小Batch的碎请求放在CPU运行,这样可以让整个Worker的资源利用更加均衡,提高系统整体的性能。我们根据测试设定了一个Batch阈值,以及计算图在异构硬件上区别执行的判断逻辑:对于小Batch的情况,直接在CPU上执行计算图,只有Batch超过阈值的请求才会在GPU上推理。从线上的统计数据来看,整体流量的77%跑在GPU上,23%跑在CPU上。

在GPU的一系列优化策略和动作中,Batch大小是很重要的信息,不同Batch下优化出的kernel实现可能是不同的,以达到对应workload下最优的计算性能;由于线上的流量特点,发送到GPU的请求Batch分布比较细碎,如果我们针对每个Batch都优化一个模型的kernel实现显然是不够经济和通用的。因此,我们设计了一个Batch分桶策略,生成N个固定Batch的优化模型,在实际请求到来时找到Batch距离最近的一个Bucket,将请求向上Padding到对应的Batch计算,从而提高了GPU的利用效率。

4 压测性能分析

我们选取一个模型进行线上性能压测分析。

  • CPU模型测试环境为16核Intel® Xeon® Gold 5218 CPU @ 2.30GHz,16G内存。
  • GPU模型测试环境为8核Intel® Xeon® Gold 5218 CPU @ 2.30GHz,Tesla T4 GPU,16G内存。

下图对比了在不同的QPS下(x轴),GPU模型在各BatchSize下的推理时延(y轴)。GPU模型在BatchSize=128以下,推理耗时差异不明显,较大的BatchSize更有利于吞吐;对比BatchSize=256的GPU模型与BatchSize为25的CPU模型,在QPS低于64的情况下,二者推理耗时基本持平;QPS超过64的情况下,GPU的推理时延低于CPU。GPU的吞吐相比CPU提升了10倍。

同时,我们可以看到不同曲线的陡峭程度,CPU在QPS高出64后,时延会迅速上升,GPU则依然保持平稳,直到QPS超过128才会有明显上升,但仍旧比CPU更平稳。

5 整体架构

针对CTR模型的结构特点,我们抽象出了一套平台化的通用优化流程。通过对模型结构的分析,自动应用合适的优化策略,通过性能评估和一致性校验,保证模型的优化效果。

6 不足之处与未来规划

在易用性层面,目前的方案形式是提供了一套在线优化脚本,用户提交模型后,自动优化部署。由于涉及对计算图结构的分析、编辑以及TVM的编译等过程,目前的模型优化耗时较长,大部分模型优化耗时在20分钟左右。后续需要考虑加速TVM编译的效率。

在通用性层面,从我们的实际应用情况来看,TVM编译优化和高性能手写算子是最主要的收益来源。手工优化很考验开发同学对业务模型的理解和GPU编程的能力。编写一个高性能的融合算子已经不太容易,要做到有一定的迁移能力和扩展性则更有难度。

总的来说,CTR模型推理在GPU上未来需要考虑的问题还有很多。除了要基于业务理解提供更好的性能外,还要考虑模型规模巨大后无法完整放入显存的问题以及支持在线模型更新的问题。

作者简介

伟龙、小卓、文魁、駃飞、小新等,均来自美团基础研发平台-机器学习预测引擎组。

参考资料

[1] CUDA C++ Programming Guide [2] TVM Documentation [3] Accelerating Inference In TF-TRT User Guide [4] TensorFlow graph optimization with Grappler

招聘信息

美团机器学习平台大量岗位持续招聘中,实习、社招均可,坐标北京/上海,欢迎感兴趣的同学加入我们,构建多领域公司级机器学习平台,帮大家吃得更好,生活更好。简历可投递至:wangxin66@meituan.com。

美团商品知识图谱的构建及应用

背景

美团大脑

近年来,人工智能正在快速地改变人们的生活,背后其实有两大技术驱动力:深度学习知识图谱。我们将深度学习归纳为隐性的模型,它通常是面向某一个具体任务,比如说下围棋、识别猫、人脸识别、语音识别等等。通常而言,在很多任务上它能够取得很优秀的结果,同时它也有一些局限性,比如说它需要海量的训练数据,以及强大的计算能力,难以进行跨任务的迁移,并且不具有较好的可解释性。在另一方面,知识图谱作为显式模型,同样也是人工智能的一大技术驱动力,它能够广泛地适用于不同的任务。相比深度学习,知识图谱中的知识可以沉淀,具有较强的可解释性,与人类的思考更加贴近,为隐式的深度模型补充了人类的知识积累,和深度学习互为补充。因此,全球很多大型的互联网公司都在知识图谱领域积极进行布局。

图1 人工智能两大驱动力

美团连接了数亿用户和数千万商户,背后也蕴含着丰富的日常生活相关知识。2018年,美团知识图谱团队开始构建美团大脑,着力于利用知识图谱技术赋能业务,进一步改善用户体验。具体来说,美团大脑会对美团业务中涉及到的千万级别商家、亿级别的菜品/商品、数十亿的用户评论,以及背后百万级别的场景进行深入的理解和结构化的知识建模,构建人、店、商品、场景之间的知识关联,从而形成生活服务领域大规模的知识图谱。现阶段,美团大脑已覆盖了数十亿实体,数百亿三元组,在餐饮、外卖、酒店、金融等场景中验证了知识图谱的有效性。

图2 美团大脑

在新零售领域的探索

美团逐步突破原有边界,在生活服务领域探索新的业务,不仅局限于通过外卖、餐饮帮大家“吃得更好”,近年来也逐步拓展到零售、出行等其他领域,帮大家“生活更好”。在零售领域中,美团先后落地了美团闪购、美团买菜、美团优选、团好货等一系列相应的业务,逐步实现“万物到家”的愿景。为了更好地支持美团的新零售业务,我们需要对背后的零售商品建立知识图谱,积累结构化数据,深入对零售领域内商品、用户、属性、场景等的理解,以便能更好地为用户提供零售商品领域内的服务。

相比于围绕商户的餐饮、外卖、酒店的等领域,零售商品领域对于知识图谱的建设和应用提出了更大的挑战。一方面,商品数量更加庞大,覆盖的领域范围也更加宽广。另一方面,商品本身所具有的显示信息往往比较稀疏,很大程度上需要结合生活中的常识知识来进行推理,方可将隐藏在背后的数十维的属性进行补齐,完成对商品完整的理解。在下图的例子中,“乐事黄瓜味”这样简单的商品描述其实就对应着丰富的隐含信息,只有对这些知识进行了结构化提取和相应的知识推理后,才能够更好的支持下游搜索、推荐等模块的优化。

图3 商品结构化信息的应用

商品图谱建设的目标

我们针对美团零售业务的特点,制定了多层级、多维度、跨业务的零售商品知识图谱体系。

图4 商品知识图谱体系

多层级

在不同业务的不同应用场景下,对于“商品”的定义会有所差别,需要对各个不同颗粒度的商品进行理解。因此,在我们的零售商品知识图谱中,建立了五层的层级体系,具体包括: - L1-商品SKU/SPU:对应业务中所售卖的商品颗粒度,是用户交易的对象,往往为商户下挂的商品,例如“望京家乐福所售卖的蒙牛低脂高钙牛奶250ml盒装”。这一层级也是作为商品图谱的最底层的基石,将业务商品库和图谱知识进行打通关联。 - L2-标准商品:描述商品本身客观事实的颗粒度,例如“蒙牛低脂高钙牛奶250ml盒装”,无论通过什么渠道在什么商户购买,商品本身并没有任何区别。商品条形码则是在标准商品这层的客观依据。在这一层级上,我们可以建模围绕标准商品的客观知识,例如同一个标准商品都会具有同样的品牌、口味、包装等属性。 - L3-抽象商品:进一步我们将标准商品向上抽象的商品系列,例如“蒙牛低脂高钙牛奶”。在这一层级中,我们不再关注商品具体的包装、规格等,将同系列的商品聚合为抽象商品,承载了用户对于商品的主观认知,包括用户对商品系列的别名俗称、品牌认知、主观评价等。 - L4-主体品类:描述商品主体的本质品类,列如“鸡蛋”、“奶油草莓”、“台式烤肠”等。这一层作为商品图谱的后台类目体系,以客观的方式对商品领域的品类进行建模,承载了用户对于商品的需求,例如各品牌各产地的鸡蛋都能够满足用户对于鸡蛋这个品类的需求。 - L5-业务类目:相比于主体品类的后台类目体系,业务类目作为前台类目体系会依据业务当前的发展阶段进行人工定义和调整,各个业务会根据当前业务阶段的特点和需求建立对应的前台类目体系。

多维度

  • 商品属性视角:围绕商品本身,我们需要有海量的属性维度来对商品进行描述。商品属性维度主要分为两类:一类是通用的属性维度,包括品牌、规格、包装、产地等;另一类是品类特有的属性维度,例如对于牛奶品类我们会关注脂肪含量(全脂/低脂/脱脂牛奶)、存储方式(常温奶、冷藏奶)等。商品属性主要是刻画了商品的客观知识,往往会建立在标准商品这一层级上。
  • 用户认知视角:除了客观的商品属性维度以外,用户往往对于商品会有一系列的主观认知,例如商品的别名俗称(“小黑瓶”、“快乐水”)、对于商品的评价(“香甜可口”、“入口即化”、“性价比高”)、商品的清单/榜单(“进口食品榜单”、“夏季消暑常备”)等维度。这些主观认知往往会建立在抽象商品这一层级上。
  • 品类/类目视角:从品类/类目的视角来看,不同品类/类目也会有各自不同的关注点。在这一层级上,我们会建模各个品类/类目下有哪些典型的品牌、用户关注哪些典型属性、不同品类的复购周期是多长时间等。

跨业务

美团大脑商品知识图谱的目标是希望能够对客观世界中的商品知识进行建模,而非局限于单个业务之中。在商品图谱的五层体系中,标准商品、抽象商品、品类体系都是与业务解耦的,围绕着客观商品所建立的,包括围绕这些层级建立的各维度数据也均是刻画了商品领域的客观知识。

在应用于各个业务当中时,我们将客观的图谱知识向上关联至业务前台类目,向下关联至业务商品SPU/SKU,则可以完成各个业务数据的接入,实现各个业务数据和客观知识之间的联通,提供更加全面的跨业务的全景数据视角。利用这样的数据,在用户方面我们可以更加全面的建模、分析用户对于业务、品类的偏好,对于价格、品质等的敏感程度,在商品方面我们可以更准确的建模各品类的复购周期、地域/季节/节日偏好等。

商品图谱建设的挑战

商品知识图谱的构建的挑战主要来源于以下三个方面:

  1. 信息来源质量低:商品本身所具有的信息比较匮乏,往往以标题和图片为主。尤其在美团闪购这样LBS的电商场景下,商户需要上传大量的商品数据,对于商品信息的录入存在很多信息不完整的情况。在标题和图片之外,商品详情虽然也蕴含着大量的知识信息,但是其质量往往参差不齐,并且结构各异,从中进行知识挖掘难度极高。
  2. 数据维度多:在商品领域有众多的数据维度需要进行建设。以商品属性部分为例,我们不仅需要建设通用属性,诸如品牌、规格、包装、口味等维度,同时还要覆盖各个品类/类目下特定关注的属性维度,诸如脂肪含量、是否含糖、电池容量等,整体会涉及到上百维的属性维度。因此,数据建设的效率问题也是一大挑战。
  3. 依赖常识/专业知识:人们在日常生活中因为有很丰富的常识知识积累,可以通过很简短的描述获取其背后隐藏的商品信息,例如在看到“乐事黄瓜”这样一个商品的时候知道其实是乐事黄瓜味的薯片、看到“唐僧肉”的时候知道其实这不是一种肉类而是一种零食。因此,我们也需要探索结合常识知识的语义理解方法。同时,在医药、个护等领域中,图谱的建设需要依赖较强的专业知识,例如疾病和药品之间的关系,并且此类关系对于准确度的要求极高,需要做到所有知识都准确无误,因此也需要较好的专家和算法相结合的方式来进行高效的图谱构建。

商品图谱建设

在了解了图谱建设的目标和挑战后,接下来我们将介绍商品图谱数据建设的具体方案。

层级体系建设

品类体系建设

本质品类描述了商品本质所属的最细类别,它聚合了一类商品,承载了用户最终的消费需求,如“高钙牛奶”、“牛肉干”等。本质品类与类目也是有一定的区别,类目是若干品类的集合,它是抽象后的品类概念,不能够明确到具体的某类商品品类上,如“乳制品”、“水果”等。

品类打标:对商品图谱的构建来说,关键的一步便是建立起商品和品类之间的关联,即对商品打上品类标签。通过商品和品类之间的关联,我们可以建立起商品库中的商品与用户需求之间的关联,进而将具体的商品展示到用户面前。下面简单介绍下品类打标方法:

  1. 品类词表构建:品类打标首先需要构建一个初步的商品品类词表。首先,我们通过对美团的各个电商业务的商品库、搜索日志、商户标签等数据源进行分词、NER、新词发现等操作,获得初步的商品候选词。然后,通过标注少量的样本进行二分类模型的训练(判断一个词是否是品类)。此外,我们通过结合主动学习的方法,从预测的结果中挑选出难以区分的样本,进行再次标注,继续迭代模型,直到模型收敛。
  2. 品类打标:首先,我们通过对商品标题进行命名实体识别,并结合上一步中的品类词表来获取商品中的候选品类,如识别“蒙牛脱脂牛奶 500ml”中的“脱脂牛奶”、“牛奶”等。然后,在获得了商品以及对应的品类之后,我们利用监督数据训练品类打标的二分类模型,输入商品的SPU_ID和候选品类TAG构成的Pair,即,对它进行是否匹配的预测。具体的,我们一方面利用结合业务中丰富的半结构化语料构建围绕标签词的统计特征,另一方面利用命名实体识别、基于BERT的语义匹配等模型产出高阶相关性特征,在此基础上,我们将上述特征输入到终判模型中进行模型训练。
  3. 品类标签后处理:在这一步中,我们对模型打上的品类进行后处理的一些策略,如基于图片相关性、结合商品标题命名实体识别结果等的品类清洗策略。

通过上述的三个步骤,我们便可以建立起商品与品类之间的联系。

品类体系:品类体系由品类和品类间关系构成。常见的品类关系包括同义词和上下位等。在构建品类体系的过程中,常用的以下几种方法来进行关系的补全。我们主要使用下面的一些方法: 1. 基于规则的品类关系挖掘。在百科等通用语料数据中,有些品类具有固定模式的描述,如“玉米又名苞谷、苞米棒子、玉蜀黍、珍珠米等”、“榴莲是著名热带水果之一”,因此,可以使用规则从中提取同义词和上下位。 2. 基于分类的品类关系挖掘。类似于上文中提到的品类打标方法,我们将同义词和上下位构建为的样本,通过在商品库、搜索日志、百科数据、UGC中挖掘的统计特征以及基于Sentence-BERT得到的语义特征,使用二分类模型进行品类关系是否成立的判断。对于训练得到的分类模型,我们同样通过主动学习的方式,选出结果中的难分样本,进行二次标注,进而不断迭代数据,提高模型性能。 3. 基于图的品类关系推理。在获得了初步的同义词、上下位关系之后,我们使用已有的这些关系构建网络,使用GAE、VGAE等方法对网络进行链路预测,从而进行图谱边关系的补全。

图5 商品图谱品类体系的构建

标准/抽象商品

标准商品是描述商品本身客观事实的颗粒度,和销售渠道和商户无关,而商品条形码是标准商品这层的客观依据。标品关联即将同属于某个商品条形码的业务SKU/SPU,都正确关联到该商品条形码上,从而在标准商品层级上建模相应的客观知识,例如标准商品对应的品牌、口味和包装等属性。 下面通过一个案例来说明标品关联的具体任务和方案。

案例:下图是一个公牛三米插线板的标准商品。商家录入信息的时候,会把商品直接关联到商品条码上。通过商户录入数据完成了一部分的标品关联,但这部分比例比较少,且存在大量的链接缺失,链接错误的问题。另外,不同的商家对于同样的标品,商品的标题的描述是千奇百怪的。我们的目标是补充缺失的链接,将商品关联到正确的标品上。

图6 商品图谱标品关联任务

针对标品关联任务,我们构建了商品领域的同义词判别模型:通过远监督的方式利用商户已经提供的少量有关联的数据,作为已有的知识图谱构造远监督的训练样本。在模型中,正例是置信度比较高的标品码;负例是原始数据中商品名或者图像类似但不属于同一标品的SPU。构造准确率比较高的训练样本之后,通过BERT模型进行同义词模型训练。最后,通过模型自主去噪的方式,使得最终的准确率能够达到99%以上。总体能做到品牌,规格,包装等维度敏感。

图7 商品图谱标品关联方法

抽象商品是用户认知的层面,作为用户所评论的对象,这一层对用户偏好建模更加有效。同时,在决策信息的展示上,抽象商品粒度也更符合用户认知。例如下图所示冰淇淋的排行榜中,罗列了用户认知中抽象商品对应的SKU,然后对应展示不同抽象商品的特点、推荐理由等。抽象商品层整体的构建方式,和标准商品层比较类似,采用标品关联的模型流程,并在数据构造部分进行规则上的调整。

图8 商品图谱抽象商品聚合

属性维度建设

对一个商品的全面理解,需要涵盖各个属性维度。例如“乐事黄瓜味薯片”,需要挖掘它对应的品牌、品类、口味、包装规格、标签、产地以及用户评论特色等属性,才能在商品搜索、推荐等场景中精准触达用户。商品属性挖掘的源数据主要包含商品标题、商品图片和半结构化数据三个维度。

图9 商品图谱属性建设

商品标题包含了对于商品最重要的信息维度,同时,商品标题解析模型可以应用在查询理解中,对用户快速深入理解拆分,为下游的召回排序也能提供高阶特征。因此,这里我们着重介绍一下利用商品标题进行属性抽取的方法。

商品标题解析整体可以建模成文本序列标注的任务。例如,对于商品标题“乐事黄瓜薯片”,目标是理解标题文本序列中各个成分,如乐事对应品牌,黄瓜对应口味,薯片是品类,因此我们使用命名实体识别(NER)模型进行商品标题解析。然而商品标题解析存在着三大挑战:(1)上下文信息少;(2)依赖常识知识;(3)标注数据通常有较多的噪音。为了解决前两个挑战,我们首先尝试在模型中引入了图谱信息,主要包含以下三个维度:

  • 节点信息:将图谱实体作为词典,以Soft-Lexicon方式接入,以此来缓解NER的边界切分错误问题。
  • 关联信息:商品标题解析依赖常识知识,例如在缺乏常识的情况下,仅从标题“乐事黄瓜薯片”中,我们无法确认“黄瓜”是商品品类还是口味属性。因此,我们引入知识图谱的关联数据缓解了常识知识缺失的问题:在知识图谱中,乐事和薯片之间存在着“品牌-售卖-品类”的关联关系,但是乐事跟黄瓜之间则没有直接的关系,因此可以利用图结构来缓解NER模型常识知识缺少的问题。具体来说,我们利用Graph Embedding的技术对图谱进行的嵌入表征,利用图谱的图结构信息对图谱中的单字,词进行表示,然后将包含了图谱结构信息的嵌入表示和文本语义的表征进行拼接融合,再接入到NER模型之中,使得模型能够既考虑到语义,也考虑到常识知识的信息。
  • 节点类型信息:同一个词可以代表不同的属性,比如“黄瓜”既可以作为品类又可以作为属性。因此,对图谱进行Graph Embedding建模的时候,我们根据不同的类型对实体节点进行拆分。在将图谱节点表征接入NER模型中时,再利用注意力机制根据上下文来选择更符合语义的实体类型对应的表征 ,缓解不同类型下词语含义不同的问题,实现不同类型实体的融合。

图10 商品图谱标题解析

接下来我们探讨如何缓解标注噪音的问题。在标注过程中,少标漏标或错标的问题无法避免,尤其像在商品标题NER这种标注比较复杂的问题上,尤为显著。对于标注数据中的噪音问题,采用以下方式对噪音标注优化:不再采取原先非0即1的Hard的训练方式,而是采用基于置信度数据的Soft训练方式,然后再通过Bootstrapping的方式迭代交叉验证,然后根据当前的训练集的置信度进行调整。我们通过实验验证,使用Soft训练+Bootstrapping多轮迭代的方式,在噪声比例比较大的数据集上,模型效果得到了明显提升。具体的方法可参见我们在NLPCC 2020比赛中的论文《Iterative Strategy for Named Entity Recognition with Imperfect Annotations》。

图11 基于噪音标注的NER优化

效率提升

知识图谱的构建往往是针对于各个领域维度的数据单独制定的挖掘方式。这种挖掘方式重人工,比较低效,针对每个不同的领域、每个不同的数据维度,我们都需要定制化的去建设任务相关的特征及标注数据。在商品场景下,挖掘的维度众多,因此效率方面的提高也是至关重要的。我们首先将知识挖掘任务建模为三类分类任务,包括节点建模、关系建模以及节点关联。在整个模型的训练过程中,最需要进行效率优化的其实就是上述提到的两个步骤:(1)针对任务的特征提取;(2)针对任务的数据标注。

图12 知识挖掘任务建模

针对特征提取部分,我们摒弃了针对不同挖掘任务做定制化特征挖掘的方式,而是尝试将特征和任务解耦,构建跨任务通用的图谱挖掘特征体系,利用海量的特征库来对目标的节点/关系/关联进行表征,并利用监督训练数据来进行特征的组合和选择。具体的,我们构建的图谱特征体系主要由四个类型的特征组构成: 1. 规则模板型特征主要是利用人工先验知识,融合规则模型能力。 2. 统计分布型特征,可以充分利用各类语料,基于不同语料不同层级维度进行统计。 3. 句法分析型特征则是利用NLP领域的模型能力,引入分词、词性、句法等维度特征。 4. 嵌入表示型特征,则是利用高阶模型能力,引入BERT等语义理解模型的能力。

图13 知识挖掘特征体系

针对数据标注部分,我们主要从三个角度来提升效率。 1. 通过半监督学习,充分的利用未标注的数据进行预训练。 2. 通过主动学习技术,选择对于模型来说能够提供最多信息增益的样本进行标注。 3. 利用远程监督方法,通过已有的知识构造远监督样本进行模型训练,尽可能的发挥出已有知识的价值。

人机结合-专业图谱建设

当前医药健康行业结构性正在发生变化,消费者更加倾向于使用在线医疗解决方案和药品配送服务,因此医药业务也逐渐成为了美团的重要业务之一。相比于普通商品知识图谱的建设,药品领域知识具有以下两个特点:(1)具有极强的专业性,需要有相关背景知识才能判断相应的属性维度,例如药品的适用症状等。(2)准确度要求极高,对于强专业性知识不允许出错,否则更容易导致严重后果。因此我们采用将智能模型和专家知识结合的方式来构建药品知识图谱。

药品图谱中的知识可以分为弱专业知识和强专业知识两类,弱专业知识即一般人能够较容易获取和理解的知识,例如药品的使用方法、适用人群等;而强专业知识则是需要具有专业背景的人才能够判断的知识,例如药品的主治疾病、适应症状等。由于这两类数据对专家的依赖程度不同,因此我们分别采取不同的挖掘链路:

  • 弱专业知识:对于药品图谱的弱专业知识挖掘,我们从说明书、百科知识等数据源中提取出相应的信息,并结合通过专家知识沉淀出来的规则策略,借助通用语义模型从中提取相应的知识,并通过专家的批量抽检,完成数据的建设。
  • 强专业知识:对于药品图谱的强专业知识挖掘,为了确保相关知识百分百准确,我们通过模型提取出药品相关属性维度的候选后,将这些候选知识给到专家进行全量质检。在这里,我们主要是通过算法的能力,尽可能减少专业药师在基础数据层面上的精力花费,提高专家从半结构化语料中提取专业知识的效率。

在药品这类专业性强的领域,专业知识的表述和用户习惯往往存在差异。因此我们除了挖掘强弱专业知识外,还需要填补专业知识和用户之间的差异,才能将药品图谱更好的与下游应用结合。为此,我们从用户行为日志以及领域日常对话等数据源中,挖掘了疾病、症状和功效的别名数据,以及药品通用名的俗称数据,来打通用户习惯和专业表述之间的通路。

图14 人机结合的专业知识挖掘

商品图谱的落地应用

自从谷歌将知识图谱应用于搜索引擎,并显著提升了搜索质量与用户体验,知识图谱在各垂直领域场景都扮演起了重要的角色。在美团商品领域中,我们也将商品图谱有效的应用在围绕商品业务的搜索、推荐、商家端、用户端等多个下游场景当中,接下来我们举几个典型的案例进行介绍。

结构化召回

商品图谱的数据,对于商品的理解很有帮助。例如,在商品搜索中,如用户在搜索头疼腰疼时,通过结构化的知识图谱,才能知道什么药品是有止疼功效的;用户在搜索可爱多草莓、黄瓜薯片时,需要依赖图谱的常识知识来理解用户真正需求是冰淇淋和薯片,而不是草莓和黄瓜。

图15 基于图谱的结构化召回

排序模型泛化性

图谱的类目信息、品类信息、属性信息,一方面可以作为比较强有力的相关性的判断方法和干预手段,另一方面可以提供不同粗细粒度的商品聚合能力,作为泛化性特征提供到排序模型,能有效地提升排序模型的泛化能力,对于用户行为尤为稀疏的商品领域来说则具有着更高的价值。具体的特征使用方式则包括: 1. 通过各颗粒度进行商品聚合,以ID化特征接入排序模型。 2. 在各颗粒度聚合后进行统计特征的建设。 3. 通过图嵌入表示的方式,将商品的高维向量表示和排序模型结合。

图16 基于图谱的排序优化

多模态图谱嵌入

现有的研究工作已经在多个领域中证明了,将知识图谱的数据进行嵌入表示,以高维向量表示的方式和排序模型结合,可以有效地通过引入外部知识达到缓解排序/推荐场景中数据稀疏以及冷启动问题的效果。然而,传统的图谱嵌入的工作往往忽视了知识图谱中的多模态信息,例如商品领域中我们有商品的图片、商品的标题、商家的介绍等非简单的图谱节点型的知识,这些信息的引入也可以进一步提升图谱嵌入对推荐/排序的信息增益。

图17 基于多模态图谱的推荐-背景

现有的图谱嵌入方法在应用到多模态图谱表征的时候会存在一些问题,因为在多模态场景下,图谱中边的含义不再是单纯的语义推理关系,而是存在多模态的信息补充的关系,因此我们也针对多模态图谱的特点,提出了MKG Entity Encoder和MKG Attention Layer来更好的建模多模态知识图谱,并将其表征有效的接入至推荐/排序模型中,具体方法可以参考我们在CIKM 2020发表了的论文《Multi-Modal Knowledge Graphs for Recommender Systems》。

图18 基于图谱的排序优化-模型

用户/商家端优化

商品图谱在用户端提供显式化的可解释性信息,辅助用户进行决策。具体的呈现形式包括筛选项、特色标签、榜单、推荐理由等。筛选项的维度受当前查询词对应品类下用户关注的属性类别决定,例如,当用户搜索查询词为薯片时,用户通常关注的是它的口味、包装、净含量等,我们将会根据供给数据在这些维度下的枚举值展示筛选项。商品的特色标签则来源于标题、商品详情页信息与评论数据的提取,以简洁明了的结构化数据展示商品特色。商品的推荐理由通过评论抽取与文本生成两种渠道获得,与查询词联动,以用户视角给出商品值得买的原因,而榜单数据则更为客观,以销量等真实数据,反应商品品质。

在商家端,即商家发布侧,商品图谱则提供了基于商品标题的实时预测能力,帮助商家进行类目的挂载、属性信息的完善。例如,商家填写标题“德国进口德亚脱脂纯牛奶12盒”后,商品图谱提供的在线类目预测服务可将其挂载到“食品饮料-乳制品-纯牛奶”类目,并通过实体识别服务,得到商品的“产地-德国”,“是否进口-进口”,“品牌-德亚”,“脂肪含量-脱脂”,“规格-12盒”的属性信息,预测完成后,由商家确认发布,降低商家对商品信息的维护成本,并提升发布商品的信息质量。

作者简介

雪智,凤娇,姿雯,匡俊,林森,武威等,均来自美团平台搜索与NLP部NLP中心。

招聘信息

美团大脑知识图谱团队大量岗位持续招聘中,实习、校招、社招均可,坐标北京/上海,欢迎感兴趣的同学加入我们,利用自然语言和知识图谱技术,帮大家吃得更好,生活更好。简历可投递至:caoxuezhi@meituan.com。

美团外卖实时数仓建设实践

实时数仓以端到端低延迟、SQL标准化、快速响应变化、数据统一为目标。美团外卖数据智能组总结的最佳实践是:一个通用的实时生产平台跟一个通用交互式实时分析引擎相互配合,同时满足实时和准实时业务场景。两者合理分工,互相补充,形成易开发、易维护且效率高的流水线,兼顾开发效率与生产成本,以较好的投入产出比满足业务的多样性需求。

01 实时场景

实时数据在美团外卖的场景是非常多的,主要有以下几个方面:

  • 运营层面:比如实时业务变化,实时营销效果,当日营业情况以及当日分时业务趋势分析等。
  • 生产层面:比如实时系统是否可靠,系统是否稳定,实时监控系统的健康状况等。
  • C端用户:比如搜索推荐排序,需要实时行为、特点等特征变量的生产,给用户推荐更加合理的内容。
  • 风控侧:实时风险识别、反欺诈、异常交易等,都是大量应用实时数据的场景。

02 实时技术及架构

1. 实时计算技术选型

目前,市面上已经开源的实时技术还是很多的,比较通用的有Storm、Spark Streaming以及Flink,技术同学在做选型时要根据公司的具体业务来进行部署。

美团外卖依托于美团整体的基础数据体系建设,从技术成熟度来讲,公司前几年主要用的是Storm。当时的Storm,在性能稳定性、可靠性以及扩展性上也是无可替代的。但随着Flink越来越成熟,从技术性能上以及框架设计优势上已经超越了Storm,从趋势来讲就像Spark替代MR一样,Storm也会慢慢被Flink替代。当然,从Storm迁移到Flink会有一个过程,我们目前有一些老的任务仍然运行在Storm上,也在不断推进任务迁移。

具体Storm和Flink的对比可以参考上图表格。

2. 实时架构

① Lambda架构

Lambda是比较经典的一款架构,以前实时的场景不是很多,以离线为主,当附加了实时场景后,由于离线和实时的时效性不同,导致技术生态是不一样的。而Lambda架构相当于附加了一条实时生产链路,在应用层面进行一个整合,双路生产,各自独立。在业务应用中,顺理成章成为了一种被采用的方式。

双路生产会存在一些问题,比如加工逻辑Double,开发运维也会Double,资源同样会变成两个资源链路。因为存在以上问题,所以又演进了一个Kappa架构。

② Kappa架构

Kappa从架构设计来讲,比较简单,生产统一,一套逻辑同时生产离线和实时。但是在实际应用场景有比较大的局限性,在业内直接用Kappa架构生产落地的案例不多见,且场景比较单一。这些问题在美团外卖这边同样会遇到,我们也会有自己的一些思考,将会在后面的章节进行阐述。

03 业务痛点

首先,在外卖业务上,我们遇到了一些问题和挑战。在业务早期,为了满足业务需要,一般是Case By Case地先把需求完成。业务对于实时性要求是比较高的,从时效性的维度来说,没有进行中间层沉淀的机会。在这种场景下,一般是拿到业务逻辑直接嵌入,这是能想到的简单有效的方法,在业务发展初期这种开发模式也比较常见。

如上图所示,拿到数据源后,我们会经过数据清洗、扩维,通过Storm或Flink进行业务逻辑处理,最后直接进行业务输出。把这个环节拆开来看,数据源端会重复引用相同的数据源,后面进行清洗、过滤、扩维等操作,都要重复做一遍。唯一不同的是业务的代码逻辑是不一样的,如果业务较少,这种模式还可以接受,但当后续业务量上去后,会出现谁开发谁运维的情况,维护工作量会越来越大,作业无法形成统一管理。而且所有人都在申请资源,导致资源成本急速膨胀,资源不能集约有效利用,因此要思考如何从整体来进行实时数据的建设。

04 数据特点与应用场景

那么如何来构建实时数仓呢?首先要进行拆解,有哪些数据,有哪些场景,这些场景有哪些共同特点,对于外卖场景来说一共有两大类,日志类和业务类。

  • 日志类:数据量特别大,半结构化,嵌套比较深。日志类的数据有个很大的特点,日志流一旦形成是不会变的,通过埋点的方式收集平台所有的日志,统一进行采集分发,就像一颗树,树根非常大,推到前端应用的时候,相当于从树根到树枝分叉的过程(从1到n的分解过程)。如果所有的业务都从根上找数据,看起来路径最短,但包袱太重,数据检索效率低。日志类数据一般用于生产监控和用户行为分析,时效性要求比较高,时间窗口一般是5min或10min,或截止到当前的一个状态,主要的应用是实时大屏和实时特征,例如用户每一次点击行为都能够立刻感知到等需求。
  • 业务类:主要是业务交易数据,业务系统一般是自成体系的,以Binlog日志的形式往下分发,业务系统都是事务型的,主要采用范式建模方式。特点是结构化,主体非常清晰,但数据表较多,需要多表关联才能表达完整业务,因此是一个n到1的集成加工过程。

而业务类实时处理,主要面临的以下几个难点:

  • 业务的多状态性:业务过程从开始到结束是不断变化的,比如从下单->支付->配送,业务库是在原始基础上进行变更的,Binlog会产生很多变化的日志。而业务分析更加关注最终状态,由此产生数据回撤计算的问题,例如10点下单,13点取消,但希望在10点减掉取消单。
  • 业务集成:业务分析数据一般无法通过单一主体表达,往往是很多表进行关联,才能得到想要的信息,在实时流中进行数据的合流对齐,往往需要较大的缓存处理且复杂。
  • 分析是批量的,处理过程是流式的:对单一数据,无法形成分析,因此分析对象一定是批量的,而数据加工是逐条的。

日志类和业务类的场景一般是同时存在的,交织在一起,无论是Lambda架构还是Kappa架构,单一的应用都会有一些问题。因此针对场景来选择架构与实践才更有意义。

05 实时数仓架构设计

1. 实时架构:流批结合的探索

基于以上问题,我们有自己的思考。通过流批结合的方式来应对不同的业务场景。

如上图所示,数据从日志统一采集到消息队列,再到数据流的ETL过程,作为基础数据流的建设是统一的。之后对于日志类实时特征,实时大屏类应用走实时流计算。对于Binlog类业务分析走实时OLAP批处理。

流式处理分析业务的痛点是什么?对于范式业务,Storm和Flink都需要很大的外存,来实现数据流之间的业务对齐,需要大量的计算资源。且由于外存的限制,必须进行窗口的限定策略,最终可能放弃一些数据。计算之后,一般是存到Redis里做查询支撑,且KV存储在应对分析类查询场景中也有较多局限。

实时OLAP怎么实现?有没有一种自带存储的实时计算引擎,当实时数据来了之后,可以灵活的在一定范围内自由计算,并且有一定的数据承载能力,同时支持分析查询响应呢?随着技术的发展,目前MPP引擎发展非常迅速,性能也在飞快提升,所以在这种场景下就有了一种新的可能。这里我们使用的是Doris引擎。

这种想法在业内也已经有实践,且成为一个重要探索方向。阿里基于ADB的实时OLAP方案等。

2. 实时数仓架构设计

从整个实时数仓架构来看,首先考虑的是如何管理所有的实时数据,资源如何有效整合,数据如何进行建设。

从方法论来讲,实时和离线是非常相似的。离线数仓早期的时候也是Case By Case,当数据规模涨到一定量的时候才会考虑如何治理。分层是一种非常有效的数据治理方式,所以在实时数仓如何进行管理的问题上,首先考虑的也是分层的处理逻辑,具体内容如下:

  • 数据源:在数据源的层面,离线和实时在数据源是一致的,主要分为日志类和业务类,日志类又包括用户日志、DB日志以及服务器日志等。
  • 实时明细层:在明细层,为了解决重复建设的问题,要进行统一构建,利用离线数仓的模式,建设统一的基础明细数据层,按照主题进行管理,明细层的目的是给下游提供直接可用的数据,因此要对基础层进行统一的加工,比如清洗、过滤、扩维等。
  • 汇总层:汇总层通过Flink或Storm的简洁算子直接可以算出结果,并且形成汇总指标池,所有的指标都统一在汇总层加工,所有人按照统一的规范管理建设,形成可复用的汇总结果。

总结起来,从整个实时数仓的建设角度来讲,首先数据建设的层次化要先建出来,先搭框架,然后定规范,每一层加工到什么程度,每一层用什么样的方式,当规范定义出来后,便于在生产上进行标准化的加工。由于要保证时效性,设计的时候,层次不能太多,对于实时性要求比较高的场景,基本可以走上图左侧的数据流,对于批量处理的需求,可以从实时明细层导入到实时OLAP引擎里,基于OLAP引擎自身的计算和查询能力进行快速的回撤计算,如上图右侧的数据流。

06 实时平台化建设

架构确定之后,我们后面考虑的是如何进行平台化的建设,实时平台化建设是完全附加于实时数仓管理之上进行的。

首先进行功能的抽象,把功能抽象成组件,这样就可以达到标准化的生产,系统化的保障就可以更深入的建设,对于基础加工层的清洗、过滤、合流、扩维、转换、加密、筛选等功能都可以抽象出来,基础层通过这种组件化的方式构建直接可用的数据结果流。这会产生一个问题,用户的需求多样,为了满足了这个用户,如何兼容其他的用户,因此可能会出现冗余加工的情况。从存储的维度来讲,实时数据不存历史,不会消耗过多的存储,这种冗余是可以接受的,通过冗余的方式可以提高生产效率,是一种以空间换时间思想的应用。

通过基础层的加工,数据全部沉淀到IDL层,同时写到OLAP引擎的基础层,再往上是实时汇总层计算,基于Storm、Flink或Doris,生产多维度的汇总指标,形成统一的汇总层,进行统一的存储分发。

当这些功能都有了以后,元数据管理,指标管理,数据安全性、SLA、数据质量等系统能力也会逐渐构建起来。

1. 实时基础层功能

实时基础层的建设要解决一些问题。首先是一条流重复读的问题,一条Binlog打过来,是以DB包的形式存在的,用户可能只用其中一张表,如果大家都要用,可能存在所有人都要接这个流的问题。解决方案是可以按照不同的业务解构出来,还原到基础数据流层,根据业务的需要做成范式结构,按照数仓的建模方式进行集成化的主题建设。

其次要进行组件的封装,比如基础层的清洗、过滤、扩维等功能,通过一个很简单的表达入口,让用户将逻辑写出来。数据转换环节是比较灵活的,比如从一个值转换成另外一个值,对于这种自定义逻辑表达,我们也开放了自定义组件,可以通过Java或Python开发自定义脚本,进行数据加工。

2. 实时特征生产功能

特征生产可以通过SQL语法进行逻辑表达,底层进行逻辑的适配,透传到计算引擎,屏蔽用户对计算引擎的依赖。就像对于离线场景,目前大公司很少通过代码的方式开发,除非一些特别的Case,所以基本上可以通过SQL化的方式表达。

在功能层面,把指标管理的思想融合进去,原子指标、派生指标,标准计算口径,维度选择,窗口设置等操作都可以通过配置化的方式,这样可以统一解析生产逻辑,进行统一封装。

还有一个问题,同一个源,写了很多SQL,每一次提交都会起一个数据流,比较浪费资源,我们的解决方案是,通过同一条流实现动态指标的生产,在不停服务的情况下可以动态添加指标。

所以在实时平台建设过程中,更多考虑的是如何更有效的利用资源,在哪些环节更能节约化的使用资源,这是在工程方面更多考虑的事情。

3. SLA建设

SLA主要解决两个问题,一个是端到端的SLA,一个是作业生产效率的SLA,我们采用埋点+上报的方式,由于实时流比较大,埋点要尽量简单,不能埋太多的东西,能表达业务即可,每个作业的输出统一上报到SLA监控平台,通过统一接口的形式,在每一个作业点上报所需要的信息,最后能够统计到端到端的SLA。

在实时生产中,由于链路非常长,无法控制所有链路,但是可以控制自己作业的效率,所以作业SLA也是必不可少的。

4. 实时OLAP方案

问题

  • Binlog业务还原复杂:业务变化很多,需要某个时间点的变化,因此需要进行排序,并且数据要存起来,这对于内存和CPU的资源消耗都是非常大的。
  • Binlog业务关联复杂:流式计算里,流和流之间的关联,对于业务逻辑的表达是非常困难的。

解决方案

通过带计算能力的OLAP引擎来解决,不需要把一个流进行逻辑化映射,只需要解决数据实时稳定的入库问题。

我们这边采用的是Doris作为高性能的OLAP引擎,由于业务数据产生的结果和结果之间还需要进行衍生计算,Doris可以利用Unique模型或聚合模型快速还原业务,还原业务的同时还可以进行汇总层的聚合,也是为了复用而设计。应用层可以是物理的,也可以是逻辑化视图。

这种模式重在解决业务回撤计算,比如业务状态改变,需要在历史的某个点将值变更,这种场景用流计算的成本非常大,OLAP模式可以很好的解决这个问题。

07 实时应用案例

最后通过一个案例说明,比如商家要根据用户历史下单数给用户优惠,商家需要看到历史下了多少单,历史T+1的数据要有,今天实时的数据也要有,这种场景是典型的Lambda架构。我们可以在Doris里设计一个分区表,一个是历史分区,一个是今日分区,历史分区可以通过离线的方式生产,今日指标可以通过实时的方式计算,写到今日分区里,查询的时候进行一个简单的汇总。

这种场景看起来比较简单,难点在于商家的量上来之后,很多简单的问题都会变得复杂。后续,我们也会通过更多的业务输入,沉淀出更多的业务场景,抽象出来形成统一的生产方案和功能,以最小化的实时计算资源支撑多样化的业务需求,这也是未来我们需要达到的目的。

小样本学习及其在美团场景中的应用

作者简介

骆颖、徐俊、谢睿、武威等,均来自美团搜索与NLP部/NLP中心。

招聘信息

美团搜索与NLP部/NLP中心是负责美团人工智能技术研发的核心团队,使命是打造世界一流的自然语言处理核心技术和服务能力,依托NLP(自然语言处理)、Deep Learning(深度学习)、Knowledge Graph(知识图谱)等技术,处理美团海量文本数据,为美团各项业务提供智能的文本语义理解服务。

NLP中心长期招聘自然语言处理算法专家/机器学习算法专家,感兴趣的同学可以将简历发送至xujun12@meituan.com。

KDD_2021|美团联合多高校提出多任务学习模型,已应用于联名卡获客场景

论文下载:《Modeling the Sequential Dependence among Audience Multi-step Conversions with Multi-task Learning in Targeted Display Advertising》

源代码:https://github.com/xidongbo/AITM

招聘信息

美团金融智能应用团队算法岗位持续热招中,诚招优秀算法工程师及专家,坐标北京/上海。招聘岗位包括:

营销算法工程师/专家

  • 服务美团金融各业务场景,负责营销获客、留存促活等场景的算法设计与开发,综合机器学习与优化技术,解决金融营销问题;
  • 沉淀算法平台能力,提升算法应用的效率,提供客群挖掘、权益分配、素材匹配、动态创意、运筹规划、精准触达等智能解决方案;
  • 结合美团金融业务场景,对深度学习、强化学习、知识图谱等人工智能前沿技术探索创新,实施创新技术沉淀和落地。

风控算法工程师/专家

  • 通过机器学习模型与策略的开发优化,持续提升对于金融风险行为的识别能力;
  • 深入理解业务,应用机器学习技术提高风控工作的自动化程度,全面提升业务效率;
  • 跟进人工智能的前沿技术,并在金融风控场景中探索落地。

NLP算法工程师/专家

  • 基于美团金融业务场景,结合自然语言处理和机器学习相关技术,落地智能对话机器人到金融营销、风险管理、客服等多个场景;
  • 参与研发对话机器人的相关项目,包括但不限于语义理解、多轮对话管理等相关算法的开发和优化;
  • 持续跟进学术界和工业界相关技术的发展,并快速应用于项目中。

欢迎感兴趣的同学发送简历至:chenzhen06@meituan.com(邮件标题注明:美团金融智能应用团队)。

参考文献

  • [1] Jiaqi Ma, Zhe Zhao, Xinyang Yi, Jilin Chen, Lichan Hong, and Ed H Chi. 2018. Modeling task relationships in multi-task learning with multi-gate mixture-of-experts. In KDD. 1930–1939.
  • [2] Zhen Qin, Yicheng Cheng, Zhe Zhao, Zhe Chen, Donald Metzler, and Jingzheng Qin. 2020. Multitask Mixture of Sequential Experts for User Activity Streams. In KDD. 3083–3091.
  • [3] Hongyan Tang, Junning Liu, Ming Zhao, and Xudong Gong. 2020. Progressive Layered Extraction (PLE): A Novel Multi-Task Learning (MTL) Model for Personalized Recommendations. In RecSys. 269–278.
  • [4] Yixuan Li, Jason Yosinski, Jeff Clune, Hod Lipson, and John E Hopcroft. 2016. Convergent Learning: Do different neural networks learn the same representations? In ICLR.
  • [5] Matthew D Zeiler and Rob Fergus. 2014. Visualizing and understanding convolutional networks. In ECCV. 818–833.
  • [6] Eric Tzeng, Judy Hoffman, Ning Zhang, Kate Saenko, and Trevor Darrell. 2014. Deep domain confusion: Maximizing for domain invariance. arXiv preprint arXiv:1412.3474 (2014).
  • [7] Chen Gao, Xiangnan He, Dahua Gan, Xiangning Chen, Fuli Feng, Yong Li, Tat-Seng Chua, and Depeng Jin. 2019. Neural multi-task recommendation from multi-behavior data. In ICDE. 1554–1557.
  • [8] Chen Gao, Xiangnan He, Danhua Gan, Xiangning Chen, Fuli Feng, Yong Li, Tat-Seng Chua, Lina Yao, Yang Song, and Depeng Jin. 2019. Learning to Recommend with Multiple Cascading Behaviors. TKDE (2019).
  • [9] Xiao Ma, Liqin Zhao, Guan Huang, ZhiWang, Zelin Hu, Xiaoqiang Zhu, and Kun Gai. 2018. Entire space multi-task model: An effective approach for estimating post-click conversion rate. In SIGIR. 1137–1140.
  • [10] Hong Wen, Jing Zhang, Yuan Wang, Fuyu Lv, Wentian Bao, Quan Lin, and Keping Yang. 2020. Entire Space Multi-Task Modeling via Post-Click Behavior Decomposition for Conversion Rate Prediction. In SIGIR. 2377–2386.
  • [11] https://tianchi.aliyun.com/datalab/dataSet.html?dataId=408

Spock单元测试框架介绍以及在美团优选的实践

1. 背景

​XML之父Tim Bray最近在博客里有个好玩的说法:“代码不写测试就像上了厕所不洗手……单元测试是对软件未来的一项必不可少的投资。”具体来说,单元测试有哪些收益呢?

  • 它是最容易保证代码覆盖率达到100%的测试。
  • 可以⼤幅降低上线时的紧张指数。
  • 单元测试能更快地发现问题(见下图左)。
  • 单元测试的性价比最高,因为错误发现的越晚,修复它的成本就越高,而且难度呈指数式增长,所以我们要尽早地进行测试(见下图右)。
  • 编码人员,一般也是单元测试的主要执行者,是唯一能够做到生产出无缺陷程序的人,其他任何人都无法做到这一点。
  • 有助于源码的优化,使之更加规范,快速反馈,可以放心进行重构。
pic2
这张图来自微软的统计数据:Bug在单元测试阶段被发现,平均耗时3.25小时,如果漏到系统测试阶段,要花费11.5小时。这张图,旨在说明两个问题:85%的缺陷都在代码设计阶段产生,而发现Bug的阶段越靠后,耗费成本就越高,指数级别的增高。

尽管单元测试有如此的收益,但在我们日常的工作中,仍然存在不少项目它们的单元测试要么是不完整要么是缺失的。常见的原因总结如下:代码逻辑过于复杂;写单元测试时耗费的时间较长;任务重、工期紧,或者干脆就不写了。

基于以上问题,相较于传统的JUnit单元测试,今天为大家推荐一款名为Spock的测试框架。目前,美团优选物流技术团队绝大部分后端服务已经采用了Spock作为测试框架,在开发效率、可读性和维护性方面取得了不错的收益。

不过网上Spock资料比较简单,甚至包括官网的Demo,无法解决我们项目中复杂业务场景面临的问题,通过深入学习和实践之后,本文会将一些经验分享出来,希望能够帮助大家提高开发测试的效率。

2. Spock是什么?和JUnit、jMock有什么区别?

Spock是一款国外优秀的测试框架,基于BDD(行为驱动开发)思想实现,功能非常强大。Spock结合Groovy动态语言的特点,提供了各种标签,并采用简单、通用、结构化的描述语言,让编写测试代码更加简洁、高效。官方的介绍如下:

What is it? Spock is a testing and specification framework for Java and Groovy applications. What makes it stand out from the crowd is its beautiful and highly expressive specification language. Thanks to its JUnit runner, Spock is compatible with most IDEs, build tools, and continuous integration servers. Spock is inspired from JUnit, RSpec, jMock, Mockito, Groovy, Scala, Vulcans, and other fascinating life forms.

Spock是一个Java和Groovy`应用的测试和规范框架。之所以能够在众多测试框架中脱颖而出,是因为它优美而富有表现力的规范语言。Spock的灵感来自JUnit、RSpec、jMock、Mockito、Groovy、Scala、Vulcans。

简单来讲,Spock主要特点如下:

  • 让测试代码更规范,内置多种标签来规范单元测试代码的语义,测试代码结构清晰,更具可读性,降低后期维护难度。
  • 提供多种标签,比如:givenwhenthenexpectwherewiththrown……帮助我们应对复杂的测试场景。
  • 使用Groovy这种动态语言来编写测试代码,可以让我们编写的测试代码更简洁,适合敏捷开发,提高编写单元测试代码的效率。
  • 遵从BDD(行为驱动开发)模式,有助于提升代码的质量。
  • IDE兼容性好,自带Mock功能。

为什么使用Spock? Spock和JUnit、jMock、Mockito的区别在哪里?

总的来说,JUnit、jMock、Mockito都是相对独立的工具,只是针对不同的业务场景提供特定的解决方案。其中JUnit单纯用于测试,并不提供Mock功能。

我们的服务大部分是分布式微服务架构。服务与服务之间通常都是通过接口的方式进行交互。即使在同一个服务内也会分为多个模块,业务功能需要依赖下游接口的返回数据,才能继续后面的处理流程。这里的下游不限于接口,还包括中间件数据存储比如Squirrel、DB、MCC配置中心等等,所以如果想要测试自己的代码逻辑,就必须把这些依赖项Mock掉。因为如果下游接口不稳定可能会影响我们代码的测试结果,让下游接口返回指定的结果集(事先准备好的数据),这样才能验证我们的代码是否正确,是否符合逻辑结果的预期。

尽管jMock、Mockito提供了Mock功能,可以把接口等依赖屏蔽掉,但不能对静态方法Mock。虽然PowerMock、jMockit能够提供静态方法的Mock,但它们之间也需要配合(JUnit + Mockito PowerMock)使用,并且语法上比较繁琐。工具多了就会导致不同的人写出的单元测试代码“五花八门”,风格相差较大。

Spock通过提供规范性的描述,定义多种标签(givenwhenthenwhere等),去描述代码“应该做什么”,“输入条件是什么”,“输出是否符合预期”,从语义层面规范了代码的编写。

Spock自带Mock功能,使用简单方便(也支持扩展其他Mock框架,比如PowerMock),再加上Groovy动态语言的强大语法,能写出简洁高效的测试代码,同时能方便直观地验证业务代码的行为流转,增强工程师对代码执行逻辑的可控性。

3. 使用Spock解决单元测试开发中的痛点

如果在(if/else)分支很多的复杂场景下,编写单元测试代码的成本会变得非常高,正常的业务代码可能只有几十行,但为了测试这个功能覆盖大部分的分支场景,编写的测试代码可能远不止几十行。

之前有遇到过某个功能上线很久一直都很正常,没有出现过问题,但后来有个调用请求的数据不一样,走到了代码中一个不常用的逻辑分支时,出现了Bug。当时写这段代码的同学也认为只有很小几率才能走到这个分支,尽管当时写了单元测试,但因为时间比较紧张,分支又多,就漏掉了这个分支的测试。

尽管使用JUnit的@Parametered参数化注解或者DataProvider方式可以解决多数据分支问题,但不够直观,而且如果其中某一次分支测试Case出错了,它的报错信息也不够详尽。

这就需要一种编写测试用例高效、可读性强、占用工时少、维护成本低的测试框架。首先不能让业务人员排斥编写单元测试,更不能让工程师觉得写单元测试是在浪费时间。而且使用JUnit做测试工作量不算小。据初步统计,采用JUnit的话,它的测试代码行和业务代码行能到3:1。如果采用Spock作为测试框架的话,它的比例可缩减到1:1,能够大大提高编写测试用例的效率。

下面借用《编程珠玑》中一个计算税金的例子。

public double calc(double income) {
        BigDecimal tax;
        BigDecimal salary = BigDecimal.valueOf(income);
        if (income <= 0) {
            return 0;
        }
        if (income > 0 && income <= 3000) {
            BigDecimal taxLevel = BigDecimal.valueOf(0.03);
            tax = salary.multiply(taxLevel);
        } else if (income > 3000 && income <= 12000) {
            BigDecimal taxLevel = BigDecimal.valueOf(0.1);
            BigDecimal base = BigDecimal.valueOf(210);
            tax = salary.multiply(taxLevel).subtract(base);
        } else if (income > 12000 && income <= 25000) {
            BigDecimal taxLevel = BigDecimal.valueOf(0.2);
            BigDecimal base = BigDecimal.valueOf(1410);
            tax = salary.multiply(taxLevel).subtract(base);
        } else if (income > 25000 && income <= 35000) {
            BigDecimal taxLevel = BigDecimal.valueOf(0.25);
            BigDecimal base = BigDecimal.valueOf(2660);
            tax = salary.multiply(taxLevel).subtract(base);
        } else if (income > 35000 && income <= 55000) {
            BigDecimal taxLevel = BigDecimal.valueOf(0.3);
            BigDecimal base = BigDecimal.valueOf(4410);
            tax = salary.multiply(taxLevel).subtract(base);
        } else if (income > 55000 && income <= 80000) {
            BigDecimal taxLevel = BigDecimal.valueOf(0.35);
            BigDecimal base = BigDecimal.valueOf(7160);
            tax = salary.multiply(taxLevel).subtract(base);
        } else {
            BigDecimal taxLevel = BigDecimal.valueOf(0.45);
            BigDecimal base = BigDecimal.valueOf(15160);
            tax = salary.multiply(taxLevel).subtract(base);
        }
        return tax.setScale(2, BigDecimal.ROUND_HALF_UP).doubleValue();
    }

能够看到上面的代码中有大量的if-else语句,Spock提供了where标签,可以让我们通过表格的方式来测试多种分支。

@Unroll
def "个税计算,收入:#income, 个税:#result"() {
  expect: "when + then 的组合"
  CalculateTaxUtils.calc(income) == result

  where: "表格方式测试不同的分支逻辑"
  income  result
  -1      0
  0       0
  2999    89.97
  3000    90.0
  3001    90.1
  11999   989.9
  12000   990.0
  12001   990.2
  24999   3589.8
  25000   3590.0
  25001   3590.25
  34999   6089.75
  35000   6090.0
  35001   6090.3
  54999   12089.7
  55000   12090
  55001   12090.35
  79999   20839.65
  80000   20840.0
  80001   20840.45
}

上图中左边使用Spock写的单元测试代码,语法简洁,表格方式测试覆盖分支场景更加直观,开发效率高,更适合敏捷开发。

单元测试代码的可读性和后期维护

我们微服务场景很多时候需要依赖其他接口返回的结果,才能验证自己的代码逻辑。Mock工具是必不可少的。但jMock、Mockito的语法比较繁琐,再加上单元测试代码不像业务代码那么直观,又不能完全按照业务流程的思路写单元测试,这就让不少同学对单元测试代码可读性不够重视,最终导致测试代码难以阅读,维护起来更是难上加难。甚至很多同学自己写的单元测试,过几天再看也一样觉得“云里雾里”的。也有改了原来的代码逻辑导致单元测试执行失败的;或者新增了分支逻辑,单元测试没有覆盖到的;最终随着业务的快速迭代单元测试代码越来越难以维护。

Spock提供多种语义标签,如:givenwhenthenexpectwherewithand等,从行为上规范了单元测试代码,每一种标签对应一种语义,让单元测试代码结构具有层次感,功能模块划分更加清晰,也便于后期的维护。

Spock自带Mock功能,使用上简单方便(Spock也支持扩展第三方Mock框架,比如PowerMock)。我们可以再看一个样例,对于如下的代码逻辑进行单元测试:

public StudentVO getStudentById(int id) {
        List students = studentDao.getStudentInfo();
        StudentDTO studentDTO = students.stream().filter(u -> u.getId() == id).findFirst().orElse(null);
        StudentVO studentVO = new StudentVO();
        if (studentDTO == null) {
            return studentVO;
        }
        studentVO.setId(studentDTO.getId());
        studentVO.setName(studentDTO.getName());
        studentVO.setSex(studentDTO.getSex());
        studentVO.setAge(studentDTO.getAge());
        // 邮编
        if ("上海".equals(studentDTO.getProvince())) {
            studentVO.setAbbreviation("沪");
            studentVO.setPostCode("200000");
        }
        if ("北京".equals(studentDTO.getProvince())) {
            studentVO.setAbbreviation("京");
            studentVO.setPostCode("100000");
        }
        return studentVO;
    }

比较明显,左边的JUnit单元测试代码冗余,缺少结构层次,可读性差,随着后续的迭代,势必会导致代码的堆积,维护成本会变得越来越高。右边的单元测试代码Spock会强制要求使用givenwhenthen这样的语义标签(至少一个),否则编译不通过,这样就能保证代码更加规范,结构模块化,边界范围清晰,可读性强,便于扩展和维护。而且使用了自然语言描述测试步骤,让非技术人员也能看懂测试代码(given表示输入条件,when触发动作,then验证输出结果)。

Spock自带的Mock语法也非常简单:dao.getStudentInfo() >> [student1, student2]

两个右箭头>>表示模拟getStudentInfo接口的返回结果,再加上使用的Groovy语言,可以直接使用[]中括号表示返回的是List类型。

单元测试不仅仅是为了统计代码覆盖率,更重要的是验证业务代码的健壮性、业务逻辑的严谨性以及设计的合理性

在项目初期阶段,可能为了追赶进度而没有时间写单元测试,或者这个时期写的单元测试只是为了达到覆盖率的要求(比如为了满足新增代码行或者分支覆盖率统计要求)。

很多工程师写的单元测试基本都是采用Java这种强类型语言编写,各种底层接口的Mock写起来不仅繁琐而且耗时。这时的单元测试代码可能就写得比较粗糙,有粒度过大的,也有缺少单元测试结果验证的。这样的单元测试对代码的质量帮助不大,更多是为了测试而测试。最后时间没少花,可效果却没有达到。

针对有效测试用例方面,我们测试基础组件组开发了一些检测工具(作为抓手),比如去扫描大家写的单元测试,检测单元测试的断言有效性等。另外在结果校验方面,Spock表现也是十分优异的。我们可以来看接下来的场景:void方法,没有返回结果,如何写测试这段代码的逻辑是否正确?

如何确保单元测试代码是否执行到了for循环里面的语句,循环里面的打折计算又是否正确呢?

  public void calculatePrice(OrderVO order){
        BigDecimal amount = BigDecimal.ZERO;
        for (SkuVO sku : order.getSkus()) {
            Integer skuId = sku.getSkuId();
            BigDecimal skuPrice = sku.getSkuPrice();
            BigDecimal discount = BigDecimal.valueOf(discountDao.getDiscount(skuId));
            BigDecimal price = skuPrice * discount;
            amount = amount.add(price);
        }
        order.setAmount(amount.setScale(2, BigDecimal.ROUND_HALF_DOWN));
    }

如果用Spock写的话,就会方便很多,如下图所示:

这里,2 * discountDao.getDiscount(_) >> 0.95 >> 0.8for循环中一共调用了2次,第一次返回结果0.95,第二次返回结果0.8,最后再进行验证,类似于JUnit中的Assert断言。

这样的收益还是比较明显的,不仅提高了单元测试的可控性,而且方便验证业务代码的逻辑正确性和合理性,这也是BDD思想的一种体现。

4. Mock模拟

考虑如下场景,代码如下:

@Service
public class StudentService {
    @Autowired
    private StudentDao studentDao;
    public StudentVO getStudentById(int id) {
        List students = studentDao.getStudentInfo();
        StudentDTO studentDTO = students.stream().filter(u -> u.getId() == id).findFirst().orElse(null);
        StudentVO studentVO = new StudentVO();
        if (studentDTO == null) {
            return studentVO;
        }
        studentVO.setId(studentDTO.getId());
        studentVO.setName(studentDTO.getName());
        studentVO.setSex(studentDTO.getSex());
        studentVO.setAge(studentDTO.getAge());
        // 邮编
        if ("上海".equals(studentDTO.getProvince())) {
            studentVO.setAbbreviation("沪");
            studentVO.setPostCode("200000");
        }
        if ("北京".equals(studentDTO.getProvince())) {
            studentVO.setAbbreviation("京");
            studentVO.setPostCode("100000");
        }
        return studentVO;
    }
}

其中studentDao是使用Spring注入的实例对象,我们只有拿到了返回的students,才能继续下面的逻辑(根据id筛选学生,DTOVO转换,邮编等)。所以正常的做法是把studentDaogetStudentInfo()方法Mock掉,模拟一个指定的值,因为我们真正关心的是拿到students后自己代码的逻辑,这是需要重点验证的地方。按照上面的思路使用Spock编写的测试代码如下:

class StudentServiceSpec extends Specification {
    def studentDao = Mock(StudentDao)
    def tester = new StudentService(studentDao: studentDao)

    def "test getStudentById"() {
        given: "设置请求参数"
        def student1 = new StudentDTO(id: 1, name: "张三", province: "北京")
        def student2 = new StudentDTO(id: 2, name: "李四", province: "上海")

        and: "mock studentDao返回值"
        studentDao.getStudentInfo() >> [student1, student2]

        when: "获取学生信息"
        def response = tester.getStudentById(1)

        then: "结果验证"
        with(response) {
            id == 1
            abbreviation == "京"
            postCode == "100000"
        }
    }
}

这里主要讲解Spock的代码(从上往下)。

def studentDao = Mock(StudentDao) 这一行代码使用Spock自带的Mock方法,构造一个studentDao的Mock对象,如果要模拟studentDao方法的返回,只需studentDao.方法名() >> "模拟值"的方式,两个右箭头的方式即可。test getStudentById方法是单元测试的主要方法,可以看到分为4个模块:givenandwhenthen,用来区分不同单元测试代码的作用:

  • given:输入条件(前置参数)。
  • when:执行行为(Mock接口、真实调用)。
  • then:输出条件(验证结果)。
  • and:衔接上个标签,补充的作用。

每个标签后面的双引号里可以添加描述,说明这块代码的作用(非强制),如when:"获取信息"。因为Spock使用Groovy作为单元测试开发语言,所以代码量上比使用Java写的会少很多,比如given模块里通过构造函数的方式创建请求对象。

实际上StudentDTO.java 这个类并没有3个参数的构造方法,是Groovy帮我们实现的。Groovy默认会提供一个包含所有对象属性的构造方法。而且调用方式上可以指定属性名,类似于key:value的语法,非常人性化,方便在属性多的情况下构造对象,如果使用Java写,可能就要调用很多的setXxx()方法,才能完成对象初始化的工作。

这个就是Spock的Mock用法,当调用studentDao.getStudentInfo()方法时返回一个ListList的创建也很简单,中括号[]即表示List,Groovy会根据方法的返回类型,自动匹配是数组还是List,而List里的对象就是之前given块里构造的user对象,其中 >> 就是指定返回结果,类似Mockitowhen().thenReturn()语法,但更简洁一些。

如果要指定返回多个值的话,可以使用3个右箭头>>>,比如:studentDao.getStudentInfo() >>> [[student1,student2],[student3,student4],[student5,student6]]

也可以写成这样:studentDao.getStudentInfo() >> [student1,student2] >> [student3,student4] >> [student5,student6]

每次调用studentDao.getStudentInfo()方法返回不同的值。

public List getStudentInfo(String id){
    List students = new ArrayList<>();
    return students;
}

这个getStudentInfo(String id)方法,有个参数id,这种情况下如果使用Spock的Mock模拟调用的话,可以使用下划线_匹配参数,表示任何类型的参数,多个逗号隔开,类似于Mockitoany()方法。如果类中存在多个同名方法,可以通过 _ as参数类型 的方式区别调用,如下面的语法:

// _ 表示匹配任意类型参数
List students = studentDao.getStudentInfo(_);

// 如果有同名的方法,使用as指定参数类型区分
List students = studentDao.getStudentInfo(_ as String);

when模块里是真正调用要测试方法的入口tester.getStudentById()then模块作用是验证被测方法的结果是否正确,符合预期值,所以这个模块里的语句必须是boolean表达式,类似于JUnit的assert断言机制,但不必显示地写assert,这也是一种约定优于配置的思想。then块中使用了Spock的with功能,可以验证返回结果response对象内部的多个属性是否符合预期值,这个相对于JUnit的assertNotNullassertEquals的方式更简单一些。

强大的Where

上面的业务代码有2个if判断,是对邮编处理逻辑:

  // 邮编
  if ("上海".equals(studentDTO.getProvince())) {
       studentVO.setAbbreviation("沪");
       studentVO.setPostCode("200000");
   }
   if ("北京".equals(studentDTO.getProvince())) {
       studentVO.setAbbreviation("京");
       studentVO.setPostCode("100000");
   }

如果要完全覆盖这2个分支就需要构造不同的请求参数,多次调用被测试方法才能走到不同的分支。在前面,我们介绍了Spock的where标签可以很方便的实现这种功能,代码如下所示:

   @Unroll
   def "input 学生id:#id, 返回的邮编:#postCodeResult, 返回的省份简称:#abbreviationResult"() {
        given: "Mock返回的学生信息"
        studentDao.getStudentInfo() >> students

        when: "获取学生信息"
        def response = tester.getStudentById(id)

        then: "验证返回结果"
        with(response) {
            postCode == postCodeResult
            abbreviation == abbreviationResult
        }
        where: "经典之处:表格方式验证学生信息的分支场景"
        id  students                     postCodeResult  abbreviationResult
        1   getStudent(1, "张三", "北京")  "100000"        "京"
        2   getStudent(2, "李四", "上海")  "200000"        "沪"
    }

    def getStudent(def id, def name, def province) {
        return [new StudentDTO(id: id, name: name, province: province)]
    }

where模块第一行代码是表格的列名,多个列使用单竖线隔开,双竖线区分输入和输出变量,即左边是输入值,右边是输出值。格式如下:

输入参数1 输入参数2 输出结果1 输出结果2

而且IntelliJ IDEA支持format格式化快捷键,因为表格列的长度不一样,手动对齐比较麻烦。表格的每一行代表一个测试用例,即被测方法执行了2次,每次的输入和输出都不一样,刚好可以覆盖全部分支情况。比如idstudents都是输入条件,其中students对象的构造调用了getStudent方法,每次测试业务代码传入不同的student值,postCodeResultabbreviationResult表示对返回的response对象的属性判断是否正确。第一行数据的作用是验证返回的邮编是否是100000,第二行是验证邮编是否是200000。这个就是where+with的用法,更符合我们实际测试的场景,既能覆盖多种分支,又可以对复杂对象的属性进行验证,其中在定义的测试方法名,使用了Groovy的字面值特性:

即把请求参数值和返回结果值的字符串动态替换掉,#id#postCodeResult#abbreviationResult#号后面的变量是在方法内部定义的,实现占位符的功能。

@Unroll注解,可以把每一次调用作为一个单独的测试用例运行,这样运行后的单元测试结果更加直观:

而且如果其中某行测试结果不对,Spock的错误提示信息也很详细,方便进行排查(比如我们把第1条测试用例返回的邮编改成100001):

可以看出,第1条测试用例失败,错误信息是postCodeResult的预期结果和实际结果不符,业务代码逻辑返回的邮编是100000,而我们预期的邮编是100001,这样就可以排查是业务代码逻辑有问题,还是我们的断言不对。

5. 异常测试

我们再看下异常方面的测试,例如下面的代码:

 public void validateStudent(StudentVO student) throws BusinessException {
        if(student == null){
            throw new BusinessException("10001", "student is null");
        }
        if(StringUtils.isBlank(student.getName())){
            throw new BusinessException("10002", "student name is null");
        }
        if(student.getAge() == null){
            throw new BusinessException("10003", "student age is null");
        }
        if(StringUtils.isBlank(student.getTelephone())){
            throw new BusinessException("10004", "student telephone is null");
        }
        if(StringUtils.isBlank(student.getSex())){
            throw new BusinessException("10005", "student sex is null");
        }
    }

BusinessException是封装的业务异常,主要包含codemessage属性:

/**
 * 自定义业务异常
 */
public class BusinessException extends RuntimeException {
    private String code;
    private String message;

    setXxx...
    getXxx...
}

这个大家应该都很熟悉,针对这种抛出多个不同错误码和错误信息的异常。如果使用JUnit的方式测试,会比较麻烦。如果是单个异常还好,如果是多个的话,测试代码就不太好写。

    @Test
    public void testException() {
        StudentVO student = null;
        try {
            service.validateStudent(student);
        } catch (BusinessException e) {
            Assert.assertEquals(e.getCode(), "10001");
            Assert.assertEquals(e.getMessage(), "student is null");
        }

        student = new StudentVO();
        try {
            service.validateStudent(student);
        } catch (BusinessException e) {
            Assert.assertEquals(e.getCode(), "10002");
            Assert.assertEquals(e.getMessage(), "student name is null");
        }
    }

当然可以使用JUnit的ExpectedException方式:

@Rule
public ExpectedException exception = ExpectedException.none();
exception.expect(BusinessException.class); // 验证异常类型
exception.expectMessage("xxxxxx"); //验证异常信息

或者使用@Test(expected = BusinessException.class) 注解,但这两种方式都有缺陷。

@Test方式不能指定断言的异常属性,比如codemessageExpectedException的方式也只提供了expectMessage的API,对自定义的code不支持,尤其像上面的有很多分支抛出多种不同异常码的情况。接下来我们看下Spock是如何解决的。Spock内置thrown()方法,可以捕获调用业务代码抛出的预期异常并验证,再结合where表格的功能,可以很方便地覆盖多种自定义业务异常,代码如下:

    @Unroll
    def "validate student info: #expectedMessage"() {
        when: "校验"
        tester.validateStudent(student)

        then: "验证"
        def exception = thrown(expectedException)
        exception.code == expectedCode
        exception.message == expectedMessage

        where: "测试数据"
        student            expectedException  expectedCode  expectedMessage
        getStudent(10001)  BusinessException  "10001"       "student is null"
        getStudent(10002)  BusinessException  "10002"       "student name is null"
        getStudent(10003)  BusinessException  "10003"       "student age is null"
        getStudent(10004)  BusinessException  "10004"       "student telephone is null"
        getStudent(10005)  BusinessException  "10005"       "student sex is null"
    }

    def getStudent(code) {
        def student = new StudentVO()
        def condition1 = {
            student.name = "张三"
        }
        def condition2 = {
            student.age = 20
        }
        def condition3 = {
            student.telephone = "12345678901"
        }
        def condition4 = {
            student.sex = "男"
        }

        switch (code) {
            case 10001:
                student = null
                break
            case 10002:
                student = new StudentVO()
                break
            case 10003:
                condition1()
                break
            case 10004:
                condition1()
                condition2()
                break
            case 10005:
                condition1()
                condition2()
                condition3()
                break
        }
        return student
    }

then标签里用到了Spock的thrown()方法,这个方法可以捕获我们要测试的业务代码里抛出的异常。thrown()方法的入参expectedException,是我们自己定义的异常变量,这个变量放在where标签里就可以实现验证多种异常情况的功能(Intellij Idea格式化快捷键,可以自动对齐表格)。expectedException类型调用validateUser方法里定义的BusinessException异常,可以验证它所有的属性,codemessage是否符合预期值。

6. Spock静态方法测试

接下来,我们一起看下Spock如何扩展第三方PowerMock对静态方法进行测试。

Spock的单元测试代码继承自Specification基类,而Specification又是基于JUnit的注解@RunWith()实现的,代码如下:

PowerMock的PowerMockRunner也是继承自JUnit,所以使用PowerMock的@PowerMockRunnerDelegate()注解,可以指定Spock的父类Sputnik去代理运行PowerMock,这样就可以在Spock里使用PowerMock去模拟静态方法、final方法、私有方法等。其实Spock自带的GroovyMock可以对Groovy文件的静态方法Mock,但对Java代码支持不完整,只能Mock当前Java类的静态方法,官方给出的解释如下:

如下代码:

 public StudentVO getStudentByIdStatic(int id) {
        List students = studentDao.getStudentInfo();

        StudentDTO studentDTO = students.stream().filter(u -> u.getId() == id).findFirst().orElse(null);
        StudentVO studentVO = new StudentVO();
        if (studentDTO == null) {
            return studentVO;
        }
        studentVO.setId(studentDTO.getId());
        studentVO.setName(studentDTO.getName());
        studentVO.setSex(studentDTO.getSex());
        studentVO.setAge(studentDTO.getAge());

        // 静态方法调用
        String abbreviation = AbbreviationProvinceUtil.convert2Abbreviation(studentDTO.getProvince());
        studentVO.setAbbreviation(abbreviation);
        studentVO.setPostCode(studentDTO.getPostCode());

        return studentVO;
    }

上面使用了AbbreviationProvinceUtil.convert2Abbreviation()静态方法,对应的测试用例代码如下:

@RunWith(PowerMockRunner.class)
@PowerMockRunnerDelegate(Sputnik.class)
@PrepareForTest([AbbreviationProvinceUtil.class])
@SuppressStaticInitializationFor(["example.com.AbbreviationProvinceUtil"])
class StudentServiceStaticSpec extends Specification {
    def studentDao = Mock(StudentDao)
    def tester = new StudentService(studentDao: studentDao)

    void setup() {
        // mock静态类
        PowerMockito.mockStatic(AbbreviationProvinceUtil.class)
    }

    def "test getStudentByIdStatic"() {
        given: "创建对象"
        def student1 = new StudentDTO(id: 1, name: "张三", province: "北京")
        def student2 = new StudentDTO(id: 2, name: "李四", province: "上海")

        and: "Mock掉接口返回的学生信息"
        studentDao.getStudentInfo() >> [student1, student2]

        and: "Mock静态方法返回值"
        PowerMockito.when(AbbreviationProvinceUtil.convert2Abbreviation(Mockito.any())).thenReturn(abbreviationResult)

        when: "调用获取学生信息方法"
        def response = tester.getStudentByIdStatic(id)

        then: "验证返回结果是否符合预期值"
        with(response) {
            abbreviation == abbreviationResult
        }
        where:
        id  abbreviationResult
        1   "京"
        2   "沪"
    }
}

StudentServiceStaticSpec类的头部使用@PowerMockRunnerDelegate(Sputnik.class)注解,交给Spock代理执行,这样既可以使用Spock +Groovy的各种功能,又可以使用PowerMock的对静态,final等方法的Mock。@SuppressStaticInitializationFor(["example.com.AbbreviationProvinceUtil"]),这行代码的作用是限制AbbreviationProvinceUtil类里的静态代码块初始化,因为AbbreviationProvinceUtil类在第一次调用时可能会加载一些本地资源配置,所以可以使用PowerMock禁止初始化。然后在setup()方法里对静态类进行Mock设置,PowerMockito.mockStatic(AbbreviationProvinceUtil.class)。最后在test getStudentByIdStatic测试方法里对convert2Abbreviation()方法指定返回默认值:PowerMockito.when(AbbreviationProvinceUtil.convert2Abbreviation(Mockito.any())).thenReturn(abbreviationResult)

运行时在控制台会输出:

Notifications are not supported for behaviour ALL_TESTINSTANCES_ARE_CREATED_FIRST

这是Powermock的警告信息,不影响运行结果。

如果单元测试代码不需要对静态方法、final方法Mock,就没必要使用PowerMock,使用Spock自带的Mock()就足够了。因为PowerMock的原理是在编译期通过ASM字节码修改工具修改代码,然后使用自己的ClassLoader加载,而加载的静态方法越多,测试耗时就会越长。

7. 动态Mock静态方法

考虑场景,让静态方法每次调用返回不同的值。

以下代码:

public List getOrdersBySource(){
        List orderList = new ArrayList<>();
        OrderVO order = new OrderVO();
        if ("APP".equals(HttpContextUtils.getCurrentSource())) {
            if("CNY".equals(HttpContextUtils.getCurrentCurrency())){
                System.out.println("source -> APP, currency -> CNY");
            } else {
                System.out.println("source -> APP, currency -> !CNY");
            }
            order.setType(1);
        } else if ("WAP".equals(HttpContextUtils.getCurrentSource())) {
            System.out.println("source -> WAP");
            order.setType(2);
        } else if ("ONLINE".equals(HttpContextUtils.getCurrentSource())) {
            System.out.println("source -> ONLINE");
            order.setType(3);
        }
        orderList.add(order);
        return orderList;
}

这段代码的if else分支逻辑,主要是依据HttpContextUtils这个工具类的静态方法getCurrentSource()getCurrentCurrency()的返回值来决定流程。这样的业务代码也是我们平时写单元测试时经常遇到的场景,如果能让HttpContextUtils.getCurrentSource()静态方法每次Mock出不同的值,就可以很方便地覆盖if else的全部分支逻辑。Spock的where标签可以方便地和PowerMock结合使用,让PowerMock模拟的静态方法每次返回不同的值,代码如下:

PowerMock的thenReturn方法返回的值是sourcecurrency等2个变量,不是具体的数据,这2个变量对应where标签里的前两列sourcecurrency。这样的写法,就可以在每次测试业务方法时,让HttpContextUtils.getCurrentSource()HttpContextUtils.getCurrentCurrency()返回不同的来源和币种,就能轻松的覆盖ifelse的分支代码。即Spock使用where表格的方式让PowerMock具有了动态Mock的功能。接下来,我们再看一下如何对于final变量进行Mock。

public List convertOrders(List orders){
        List orderList = new ArrayList<>();
        for (OrderDTO orderDTO : orders) {
            OrderVO orderVO = OrderMapper.INSTANCE.convert(orderDTO);
            if (1 == orderVO.getType()) {
                orderVO.setOrderDesc("App端订单");
            } else if(2 == orderVO.getType()) {
                orderVO.setOrderDesc("H5端订单");
            } else if(3 == orderVO.getType()) {
                orderVO.setOrderDesc("PC端订单");
            }
            orderList.add(orderVO);
        }
        return orderList;
}

这段代码里的for循环第一行调用了OrderMapper.INSTANCE.convert()转换方法,将orderDTO转换为orderVO,然后根据type值走不同的分支,而OrderMapper是一个接口,代码如下:

@Mapper
public interface OrderMapper {
    // 即使不用static final修饰,接口里的变量默认也是静态、final的
    static final OrderMapper INSTANCE = Mappers.getMapper(OrderMapper.class);

    @Mappings({})
    OrderVO convert(OrderDTO requestDTO);
}

INSTANCE是接口OrderMapper里定义的变量,接口里的变量默认都是static final的,所以我们要先把这个INSTANCE静态final变量Mock掉,这样才能调用它的方法convert()返回我们想要的值。OrderMapper这个接口是mapstruct工具的用法,mapstruct是做对象属性映射的一个工具,它会自动生成OrderMapper接口的实现类,生成对应的setget方法,把orderDTO的属性值赋给orderVO属性,通常情况下会比使用反射的方式好不少。看下Spock如何写这个单元测试:

@Unroll
def "test convertOrders"() {
  given: "Mock掉OrderMapper的静态final变量INSTANCE,并结合Spock设置动态返回值"
  def orderMapper = Mock(OrderMapper.class)
  Whitebox.setInternalState(OrderMapper.class, "INSTANCE", orderMapper)
  orderMapper.convert(_) >> order

  when: 
  def orders = service.convertOrders([new OrderDTO()])

  then: "验证结果"
  with(orders) {
    it[0].orderDesc == desc
  }

  where: "测试数据"
  order                 desc
  new OrderVO(type: 1)  "App端订单"
  new OrderVO(type: 2)  "H5端订单"
  new OrderVO(type: 3)  "PC端订单"
}
  • 首先使用Spock自带的Mock()方法,将OrderMapper类Mock为一个模拟对象orderMapperdef orderMapper = Mock(OrderMapper.class)
  • 然后使用PowerMock的Whitebox.setInternalState(),对OrderMapper接口的static final常量INSTANCE赋值(Spock不支持静态常量的Mock),赋的值正是使用SpockMock的对象orderMapper
  • 使用Spock的Mock模拟convert()方法调用,orderMapper.convert(_) >> order,再结合where表格,实现动态Mock接口的功能。

主要是这3行代码:

def orderMapper = Mock(OrderMapper.class) // 先使用Spock的Mock
Whitebox.setInternalState(OrderMapper.class, "INSTANCE", orderMapper) // 通过PowerMock把Mock对象orderMapper赋值给静态常量INSTANCE
orderMapper.convert(_) >> order // 结合where模拟不同的返回值

这样就可以使用Spock结合PowerMock测试静态常量,达到覆盖if else不同分支逻辑的功能。

8. 覆盖率

Jacoco是统计单元测试覆盖率的一种工具,当然Spock也自带了覆盖率统计的功能,这里使用第三方Jacoco的原因主要是国内公司使用的比较多一些,包括美团很多技术团队现在使用的也是Jacoco,所以为了兼容就以Jacoco来查看单元测试覆盖率。这里说下如何通过Jacoco确认分支是否完全覆盖到。

在pom文件里引用Jacoco的插件:jacoco-maven-plugin,然后执行mvn package 命令,成功后会在target目录下生成单元测试覆盖率的报告,点开报告找到对应的被测试类查看覆盖情况。

绿色背景表示完全覆盖,黄色是部分覆盖,红色没有覆盖到。比如第34行黄色背景的else if() 判断,提示有二分之一的分支缺失,虽然它下面的代码也被覆盖了(显示为绿色),这种情况跟具体使用哪种单元测试框架没关系,因为这只是分支覆盖率统计的规则,只不过使用Spock的话,解决起来会更简单,只需在where下增加一行针对的测试数据即可。

9. DAO层测试

DAO层的测试有些不太一样,不能再使用Mock,否则无法验证SQL是否正确。对于DAO测试有一般最简的方式是直接使用@SpringBootTest注解启动测试环境,通过Spring创建Mybatis、Mapper实例,但这种方式并不属于单元测试,而是集成测试范畴了,因为当启用@SpringBootTest时,会把整个应用的上下文加载进来。不仅耗时时间长,而且一旦依赖环境上有任何问题,可能会影响启动,进而影响DAO层的测试。最后,需要到数据库尽可能隔离,因为如果大家都使用同一个Test环境的数据的话,一旦测试用例编写有问题,就可能会污染Test环境的数据。

针对以上场景,可采用以下方案: 1. 通过MyBatis的SqlSession启动mapper实例(避免通过Spring启动加载上下文信息)。 2. 通过内存数据库(如H2)隔离大家的数据库连接(完全隔离不会存在互相干扰的现象)。 3. 通过DBUnit工具,用作对于数据库层的操作访问工具。 4. 通过扩展Spock的注解,提供对于数据库Schema创建和数据Data加载的方式。如csv、xml或直接Closure编写等。

在pom文件增加相应的依赖。


     com.h2database
     h2
     1.4.200
     test
 
 
     org.dbunit
     dbunit
     2.5.1
     test
 

增加Groovy的maven插件、资源文件拷贝以及测试覆盖率统计插件。



  org.codehaus.gmavenplus
  gmavenplus-plugin
  1.8.1
  
    
      
        addSources
        addTestSources
        generateStubs
        compile
        generateTestStubs
        compileTests
        removeStubs
        removeTestStubs
      
    
  


  org.apache.maven.plugins
  maven-surefire-plugin
  3.0.0-M3
  
    false
    
      **/*Spec.java
    
    methods
    10
    true
  


  org.apache.maven.plugins
  maven-resources-plugin
  2.6
  
    
      copy-resources
      compile
      
        copy-resources
      
      
        ${basedir}/target/resources
        
          
            ${basedir}/src/main/resources
            true
          
        
      
    
  


  org.jacoco
  jacoco-maven-plugin
  0.8.2
  
    
      prepare-agent
      
        prepare-agent
      
    
    
      report
      prepare-package
      
        report
      
    
    
      post-unit-test
      test
      
        report
      
      
        target/jacoco.exec
        target/jacoco-ut
      
    
  

加入对于Spock扩展的自动处理框架(用于数据SchemaData初始化操作)。

这里介绍一下主要内容,注解@MyDbUnit

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@ExtensionAnnotation(MyDbUnitExtension.class)
@interface MyDbUnit {
    /**
     * 
     * content = {
     *    your_table_name(id: 1, name: 'xxx', age: 21)
     *    your_table_name(id: 2, name: 'xxx', age: 22)
     * })
     
* @return */ Class content() default Closure.class; /** * xml存放路径(相对于测试类) * @return */ String xmlLocation() default ""; /** * csv存放路径(相对于测试类) * @return */ String csvLocation() default ""; }

考虑以下代码的测试:

@Repository("personInfoMapper")
public interface PersonInfoMapper {
    @Delete("delete from person_info where id=#{id}")
    int deleteById(Long id);

    @Select("select count(*) from person_info")
    int count();

    @Select("select * from user_info")
    List selectAll();
}

Demo1 (使用@MyDbUnitcontent指定导入数据内容,格式Closure)。

class Demo1Spec extends MyBaseSpec {

    /**
     * 直接获取待测试的mapper
     */
    def personInfoMapper = MapperUtil.getMapper(PersonInfoMapper.class)

    /**
     * 测试数据准备,通常为sql表结构创建用的ddl,支持多个文件以逗号分隔。
     */
    def setup() {
        executeSqlScriptFile("com/xxx/xxx/xxx/......../schema.sql")
    }
    /**
     * 数据表清除,通常待drop的数据表
     */
    def cleanup() {
        dropTables("person_info")
    }

    /**
     * 直接构造数据库中的数据表,此方法适用于数据量较小的mapper sql测试
     */
    @MyDbUnit(
            content = {
                person_info(id: 1, name: "abc", age: 21)
                person_info(id: 2, name: "bcd", age: 22)
                person_info(id: 3, name: "cde", age: 23)
            }
    )
    def "demo1_01"() {
        when:
        int beforeCount = personInfoMapper.count()
        // groovy sql用于快速执行sql,不仅能验证数据结果,也可向数据中添加数据。
        def result = new Sql(dataSource).firstRow("select * from `person_info`") 
        int deleteCount = personInfoMapper.deleteById(1L)
        int afterCount = personInfoMapper.count()

        then:
        beforeCount == 3
        result.name == "abc"
        deleteCount == 1
        afterCount == 2
    }

    /**
     * 直接构造数据库中的数据表,此方法适用于数据量较小的mapper sql测试
     */
    @MyDbUnit(content = {
        person_info(id: 1, name: 'a', age: 21)
    })
    def "demo1_02"() {
        when:
        int beforeCount = personInfoMapper.count()
        def result = new Sql(dataSource).firstRow("select * from `person_info`")
        int deleteCount = personInfoMapper.deleteById(1L)
        int afterCount = personInfoMapper.count()

        then:
        beforeCount == 1
        result.name == "a"
        deleteCount == 1
        afterCount == 0
    }
}

setup()阶段,把数据库表中的Schema创建好,然后通过下面的@MyDbUnit注解的content属性,把数据导入到数据库中。person_info是表名,idnameage是数据。

通过MapperUtil.getMapper()方法获取mapper实例。

当测试数据量较大时,可以编写相应的数据文件,通过@MyDbUnitxmlLocationcsvLocation加载文件(分别支持csv和xml格式)。

如通过csv加载文件,csvLocation指向csv文件所在文件夹。

 @MyDbUnit(csvLocation = "com/xxx/........./data01")
    def "demo2_01"() {
        when:
        int beforeCount = personInfoMapper.count()
        def result = new Sql(dataSource).firstRow("select * from `person_info`")
        int deleteCount = personInfoMapper.deleteById(1L)
        int afterCount = personInfoMapper.count()

        then:
        beforeCount == 3
        result.name == "abc"
        deleteCount == 1
        afterCount == 2
    }

通过xml加载文件,xmlLocation指向xml文件所在路径。

@MyDbUnit(xmlLocation = "com/xxxx/........./demo3_02.xml")
    def "demo3_02"() {
        when:
        int beforeCount = personInfoMapper.count()
        def result = new Sql(dataSource).firstRow("select * from `person_info`")
        int deleteCount = personInfoMapper.deleteById(1L)
        int afterCount = personInfoMapper.count()

        then:
        beforeCount == 1
        result.name == "a"
        deleteCount == 1
        afterCount == 0
    }

还可以不通过@MyDbUnit而使用API直接加载测试数据文件。

class Demo4Spec extends MyBaseSpec {
    def personInfoMapper = MapperUtil.getMapper(PersonInfoMapper.class)

    /**
     * 数据表清除,通常待drop的数据表
     */
    def cleanup() {
        dropTables("person_info")
    }
    def "demo4_01"() {
        given:
        executeSqlScriptFile("com/xxxx/.........../schema.sql")
        IDataSet dataSet = MyDbUnitUtil.loadCsv("com/xxxx/.........../data01");
        DatabaseOperation.CLEAN_INSERT.execute(MyIDatabaseConnection.getInstance().getConnection(), dataSet);

        when:
        int beforeCount = personInfoMapper.count()
        def result = new Sql(dataSource).firstRow("select * from `person_info`")
        int deleteCount = personInfoMapper.deleteById(1L)
        int afterCount = personInfoMapper.count()

        then:
        beforeCount == 3
        result.name == "abc"
        deleteCount == 1
        afterCount == 2
    }

    def "demo4_02"() {
        given:
        executeSqlScriptFile("com/xxxx/.........../schema.sq")
        IDataSet dataSet = MyDbUnitUtil.loadXml("com/xxxx/.........../demo3_02.xml");
        DatabaseOperation.CLEAN_INSERT.execute(MyIDatabaseConnection.getInstance().getConnection(), dataSet);

        when:
        int beforeCount = personInfoMapper.count()
        def result = new Sql(dataSource).firstRow("select * from `person_info`")
        int deleteCount = personInfoMapper.deleteById(1L)
        int afterCount = personInfoMapper.count()

        then:
        beforeCount == 1
        result.name == "a"
        deleteCount == 1
        afterCount == 0
    }
}

最后为大家梳理了一些文档,供大家参考。

作者简介

建华,美团优选事业部工程师。

ACL_2021__一文详解美团技术团队7篇精选论文

计算语言学协会年会(ACL 2021)于2021年8月1日至6日在泰国曼谷举办(虚拟线上会议)。ACL是计算语言学和自然语言处理领域最重要的顶级国际会议,该会议由国际计算语言学协会组织,每年举办一次。据谷歌学术计算语言学刊物指标显示,ACL影响力位列第一,是CCF-A类推荐会议。今年ACL的主题是“NLP for Social Good”。据官方统计信息,本次会议共收到3350篇有效投稿,共计接收710篇主会论文(接受率为21.3%),493篇Findings论文(接受率为14.9%)。

美团技术团队共有7篇论文(其中6篇长文,1篇短文)被ACL 2021接收,这些论文是美团在事件抽取、实体识别、意图识别、新槽位发现、无监督句子表示、语义解析、文档检索等自然语言处理任务上的一些技术沉淀和应用。

针对于事件抽取,我们显示地利用周边实体的语义级别的论元角色信息,提出了一个双向实体级解码器(BERD)来逐步对每个实体生成论元角色序列;针对于实体识别,我们首次提出了槽间可迁移度的概念,并为此提出了一种槽间可迁移度的计算方式,通过比较目标槽与源任务槽的可迁移度,为不同的目标槽寻找相应的源任务槽作为其源槽,只基于这些源槽的训练数据来为目标槽构建槽填充模型;针对于意图识别,我们提出了一种基于监督对比学习的意图特征学习方法,通过最大化类间距离和最小化类内方差来提升意图之间的区分度;针对于新槽位发现,我们首次定义了新槽位识别(Novel Slot Detection, NSD)任务,与传统槽位识别任务不同的是,新槽位识别任务试图基于已有的域内槽位标注数据去挖掘发现真实对话数据里存在的新槽位,进而不断地完善和增强对话系统的能力。

此外,为解决BERT原生句子表示的“坍缩”现象,我们提出了基于对比学习的句子表示迁移方法—ConSERT,通过在目标领域的无监督语料上Fine-Tune,使模型生成的句子表示与下游任务的数据分布更加适配。我们还提出了一种新的无监督的语义解析方法——同步语义解码(SSD),它可以联合运用复述和语法约束解码同时解决语义鸿沟与结构鸿沟的问题。我们还从改进文档的编码入手来提高文档编码的语义表示能力,既提高了效果也提高了检索效率。

接下来,我们将对这7篇学术论文做一个更加详细的介绍,希望能对那些从事相关研究的同学有所帮助或启发,也欢迎大家在文末评论区留言,一起交流。

01 Capturing Event Argument Interaction via A Bi-Directional Entity-Level Recurrent Decoder

论文下载 论文作者:习翔宇,叶蔚(北京大学),张通(北京大学),张世琨(北京大学),王全修(RICHAI),江会星,武威 论文类型:Main Conference Long Paper(Oral)

事件抽取是信息抽取领域一个重要且富有挑战性的任务,在自动文摘、自动问答、信息检索、知识图谱构建等领域有着广泛的应用,旨在从非结构化的文本中抽取出结构化的事件信息。事件论元抽取对具体事件的描述信息(称之为论元信息)进行抽取,包括事件参与者、事件属性等信息,是事件抽取中重要且难度极大的任务。绝大部分论元抽取方法通常将论元抽取建模为针对实体和相关事件的论元角色分类任务,并且针对一个句子中实体集合的每个实体进行分离地训练与测试,忽略了候选论元之间潜在的交互关系;而部分利用了论元交互信息的方法,都未充分利用周边实体的语义级别的论元角色信息,同时忽略了在特定事件中的多论元分布模式。

针对目前事件论元检测中存在的问题,本文提出显示地利用周边实体的语义级别的论元角色信息。为此,本文首先将论元检测建模为实体级别的解码问题,给定句子和已知事件,论元检测模型需要生成论元角色序列;同时与传统的词级别的Seq2Seq模型不同,本文提出了一个双向实体级解码器(BERD)来逐步对每个实体生成论元角色序列。具体来说,本文设计了实体级别的解码循环单元,能够同时利用当前实例信息和周边论元信息;并同时采用了前向和后向解码器,能够分别从左往右和从右往左地对当前实体进行预测,并在单向解码过程中利用到左侧/右侧的论元信息;最终,本文在两个方向解码完成之后,采用了一个分类器结合双向编码器的特征来进行最终预测,从而能够同时利用左右两侧的论元信息。

本文在公开数据集ACE 2005上进行了实验,并与多种已有模型以及最新的论元交互方法进行对比。实验结果表明该方法性能优于现有的论元交互方法,同时在实体数量较多的事件中提升效果更加显著。

02 Slot Transferability for Cross-domain Slot Filling

论文下载 论文作者:陆恒通(北京邮电大学),韩卓芯(北京邮电大学),袁彩霞(北京邮电大学),王小捷(北京邮电大学),雷书彧,江会星,武威 论文类型:Findings of ACL 2021, Long Paper

槽填充旨在识别用户话语中任务相关的槽信息,是任务型对话系统的关键部分。当某个任务(或称为领域)具有较多训练数据时,已有的槽填充模型可以获得较好的识别性能。但是,对于一个新任务,往往只有很少甚至没有槽标注语料,如何利用一个或多个已有任务(源任务)的标注语料来训练新任务(目标任务)中的槽填充模型,这对于任务型对话系统应用的快速扩展有着重要的意义。

针对该问题的现有研究主要分为两种,第一种通过建立源任务槽信息表示与目标任务槽信息表示之间的隐式语义对齐,来将用源任务数据训练的模型直接用于目标任务,这些方法将槽描述、槽值样本等包含槽信息的内容与词表示以一定方式进行交互得到槽相关的词表示,之后进行基于“BIO”的槽标注。第二种思路采用两阶段策略进行,将所有槽值看作实体,首先用源任务数据训练一个通用实体识别模型识别目标任务所有候选槽值,之后将候选槽值通过与目标任务槽信息的表示进行相似度对比来分类到目标任务的槽上。

现有的工作,大多关注于构建利用源-目标任务之间关联信息的跨任务迁移模型,模型构建时一般使用所有源任务的数据。但是,实际上,并不是所有的源任务数据都会对目标任务的槽识别具有可迁移的价值,或者不同源任务数据对于特定目标任务的价值可能是很不相同的。例如:机票预定任务和火车票预定任务相似度高,前者的槽填充训练数据会对后者具有帮助,而机票预定任务和天气查询任务则差异较大,前者的训练数据对后者没有或只具有很小的借鉴价值,甚至起到干扰作用。

再进一步,即使源任务和目标任务很相似,但是并不是每个源任务的槽的训练数据都会对目标任务的所有槽都有帮助,例如,机票预定任务的出发时间槽训练数据可能对火车票预定任务的出发时间槽填充有帮助,但是对火车类型槽就没有帮助,反而起到干扰作用。因此,我们希望可以为目标任务中的每一个槽找到能提供有效迁移信息的一个或多个源任务槽,基于这些槽的训练数据构建跨任务迁移模型,可以更为有效地利用源任务数据。

为此,我们首先提出了槽间可迁移度的概念,并为此提出了一种槽间可迁移度的计算方式,基于可迁移度的计算,我们提出了一种为目标任务选择出源任务中能够提供有效迁移信息的槽的方法。通过比较目标槽与源任务槽的可迁移度,为不同的目标槽寻找相应的源任务槽作为其源槽,只基于这些源槽的训练数据来为目标槽构建槽填充模型。具体来说,可迁移度融合了目标槽和源槽之间的槽值表示分布相似度,以及槽值上下文表示分布相似度作为两个槽之间的可迁移度,然后对源任务槽依据其与目标槽之间的可迁移度高低进行排序,用可迁移度最高的槽所对应训练语料训练一个槽填充模型,得到其在目标槽验证集上的性能,依据按照可迁移度排序加入新的源任务槽对应训练语料训练模型并得到对应的验证集性能,选取性能最高的点对应的源任务槽及可迁移度高于该槽的源任务槽作为其源槽。利用选择出来的源槽构建目标槽槽填充模型。

槽填充模型依据槽值信息及槽值的上下文信息对槽值进行识别,所以我们在计算槽间可迁移度时,首先对槽值表示分布与上下文表示分布上的相似性进行了度量,然后我们借鉴了F值对于准确率及召回率的融合方式,对槽值表示分布相似性及槽值上下文表示分布相似性进行了融合,最后利用Tanh将所得到的值归一化到0-1之间,再用1减去所得到的值,为了符合计算得到的值越大,可迁移度越高的直观认知。下式是我们所提出的槽间可迁移度的计算方式:

$sim(p_v(s_a),p_v(s_b))$和$sim(p_c(s_a),p_c(s_b))$分别表示槽a与槽b在槽值表示分布与上下文表示分布上的相似性,我们采用最大均值差异(MMD)来衡量分布之间的相似度。

我们并没有提出新的模型,但是我们提出的源槽选择方法可以与所有的已知模型进行结合,在多个已有模型及数据集上的实验表明,我们提出的方法能为目标任务槽填充模型带来一致性的性能提升(ALL所在列表示已有模型原始的性能,STM1所在列表示用我们的方法选出的数据训练的模型性能。)

03 Modeling Discriminative Representations for Out-of-Domain Detection with Supervised Contrastive Learning

论文下载 论文作者:曾致远(北京邮电大学),何可清,严渊蒙(北京邮电大学),刘子君(北京邮电大学),吴亚楠(北京邮电大学),徐红(北京邮电大学),江会星,徐蔚然(北京邮电大学) 论文类型:Main Conference Short Paper (Poster)

在实际的任务型对话系统中,异常意图检测(Out-of-Domain Detection)是一个关键的环节,其负责识别用户输入的异常查询,并给出拒识的回复。与传统的意图识别任务相比,异常意图检测面临着语义空间稀疏、标注数据匮乏的难题。现有的异常意图检测方法可以分为两类:一类是有监督的异常意图检测,是指训练过程中存在有监督的OOD意图数据,此类方法的优势是检测效果较好,但缺点是依赖于大量有标注的OOD数据,这在实际中并不可行。另一类是无监督的异常意图检测,是指仅仅利用域内的意图数据去识别域外意图样本,由于无法利用有标注OOD样本的先验知识,无监督的异常意图检测方法面临着更大的挑战。因此,本文主要是研究无监督的异常意图检测。

无监督异常意图检测的一个核心问题是,如何通过域内意图数据学习有区分度的语义表征,我们希望同一个意图类别下的样本表征互相接近,同时不同意图类别下的样本互相远离。基于此,本文提出了一种基于监督对比学习的意图特征学习方法,通过最大化类间距离和最小化类内方差来提升特征的区分度。

具体来说,我们使用一个BiLSTM/BERT的上下文编码器获取域内意图表示,然后针对意图表示使用了两种不同的目标函数:一种是传统的分类交叉熵损失,另一种是监督对比学习(Supervised Contrastive Learning)损失。监督对比学习是在对比学习的基础上,改进了原始的对比学习仅有一个Positive Anchor的缺点,使用同类样本互相作为正样本,不同类样本作为负样本,最大化正样本之间的相关性。同时,为了提高样本表示的多样性,我们使用对抗攻击的方法来进行虚拟数据增强(Adversarial Augmentation),通过给隐空间增加噪声的方式来达到类似字符替换、插入删除、回译等传统数据增强的效果。模型结构如下:

我们在两个公开的数据集上验证模型的效果,实验结果表明我们提出的方法可以有效的提升无监督异常意图检测的性能,如下表所示。

04 Novel Slot Detection: A Benchmark for Discovering Unknown Slot Types in the Task-Oriented Dialogue System

论文下载 论文作者:吴亚楠(北京邮电大学),曾致远(北京邮电大学),何可清,徐红(北京邮电大学),严渊蒙(北京邮电大学),江会星,徐蔚然(北京邮电大学) 论文类型:Main Conference Long Paper(Oral)

槽填充(Slot Filling)是对话系统中一个重要的模块,负责识别用户输入中的关键信息。现有的槽填充模型只能识别预先定义好的槽类型,但是实际应用里存在大量域外实体类型,这些未识别的实体类型对于对话系统的优化至关重要。

在本文中,我们首次定义了新槽位识别(Novel Slot Detection, NSD)任务,与传统槽位识别任务不同的是,新槽位识别任务试图基于已有的域内槽位标注数据去挖掘发现真实对话数据里存在的新槽位,进而不断地完善和增强对话系统的能力,如下图所示:

对比现有的OOV识别任务和域外意图检测任务,本文提出的NSD任务具有显著的差异性:一方面,与OOV识别任务相比,OOV识别的对象是训练集中未出现过的新槽值,但这些槽值所属的实体类型是固定的,而NSD任务不仅要处理OOV的问题,更严峻的挑战是缺乏未知实体类型的先验知识,仅仅依赖域内槽位信息来推理域外实体信息;另一方面,和域外意图检测任务相比,域外意图检测仅需识别句子级别的意图信息,而NSD任务则面临着域内实体和域外实体之间上下文的影响,以及非实体词对于新槽位的干扰。整体上来看,本文提出的新槽位识别(Novel Slot Detection, NSD)任务与传统的槽填充任务、OOV识别任务以及域外意图检测任务有很大的差异,并且面临着更多的挑战,同时也给对话系统未来的发展提供了一个值得思考和研究的方向。

基于现有的槽填充公开数据集ATIS和Snips,我们构建了两个新槽位识别数据集ATIS-NSD和Snips-NSD。具体来说,我们随机抽取训练集中部分的槽位类型作为域外类别,保留其余类型作为域内类别,针对于一个句子中同时出现域外类别和域内类别的样例,我们采用了直接删除整个样本的策略,以避免O标签引入的bias,保证域外实体的信息仅仅出现在测试集中,更加的贴近实际场景。同时,我们针对于NSD任务提出了一系列的基线模型,整体的框架如下图所示。模型包含两个阶段:

  • 训练阶段:基于域内的槽标注数据,我们训练一个BERT-based的序列标注模型(多分类或者是二分类),以获取实体表征。
  • 测试阶段:首先使用训练的序列标注模型进行域内实体类型的预测,同时基于得到的实体表征,使用MSP或者GDA算法预测一个词是否属于Novel Slot,也即域外类型,最后将两种输出结果进行合并得到最终的输出。

我们使用实体识别的F1作为评价指标,包括Span-F1和Token-F1,二者的区别在于是否考虑实体边界,实验结果如下:

我们通过大量的实验和分析来探讨新槽位识别面临的挑战:1. 非实体词与新实体之间混淆;2. 不充分的上下文信息;3. 槽位之间的依赖关系;4. 开放槽(Open Vocabulary Slots)。

05 ConSERT: A Contrastive Framework for Self-Supervised Sentence Representation Transfer

论文下载 论文作者:严渊蒙,李如寐,王思睿,张富峥,武威,徐蔚然(北京邮电大学) 论文类型:Main Conference Long Paper(Poster)

句向量表示学习在自然语言处理(NLP)领域占据重要地位,许多NLP任务的成功离不开训练优质的句子表示向量。特别是在文本语义匹配(Semantic Textual Similarity)、文本向量检索(Dense Text Retrieval)等任务上,模型通过计算两个句子编码后的embedding在表示空间的相似度来衡量这两个句子语义上的相关程度,从而决定其匹配分数。尽管基于BERT的模型在诸多NLP任务上取得了不错的性能(通过有监督的Fine-Tune),但其自身导出的句向量(不经过Fine-Tune,对所有词向量求平均)质量较低,甚至比不上Glove的结果,因而难以反映出两个句子的语义相似度。

为解决BERT原生句子表示这种“坍缩”现象,本文提出了基于对比学习的句子表示迁移方法—ConSERT,通过在目标领域的无监督语料上fine-tune,使模型生成的句子表示与下游任务的数据分布更加适配。同时,本文针对NLP任务提出了对抗攻击、打乱词序、裁剪、Dropout四种不同的数据增强方法。在句子语义匹配(STS)任务的实验结果显示,同等设置下ConSERT 相比此前的 SOTA (BERT-Flow)大幅提升了8%,并且在少样本场景下仍表现出较强的性能提升。

在无监督实验中,我们直接基于预训练的BERT在无标注的STS数据上进行Fine-Tune。结果显示,我们的方法在完全一致的设置下大幅度超过之前的SOTA—BERT-Flow,达到了8%的相对性能提升。

06 From Paraphrasing to Semantic Parsing: Unsupervised Semantic Parsing via Synchronous Semantic Decoding

论文下载 论文作者:吴杉(中科院软件所),陈波(中科院软件所),辛春蕾(中科院软件所),韩先培(中科院软件所),孙乐(中科院软件所),张伟鹏,陈见耸,杨帆,蔡勋梁 论文类型:Main Conference Long Paper

语义解析(Semantic Parsing)是自然语言处理中的核心任务之一,它的目标是把自然语言转换为计算机语言,从而使得计算机真正理解自然语言。目前语义解析面临的一大挑战是标注数据的缺乏。神经网络方法大都十分依赖监督数据,而语义解析的数据标注非常费时费力。因此,如何在无监督的情况下学习语义解析模型成为非常重要的问题,同时也是有挑战性的问题,它的挑战在于,语义解析需要在无标注数据的情况下,同时跨越自然语言和语义表示间的语义鸿沟和结构鸿沟。之前的方法一般使用复述作为重排序或者重写方法以减少语义上的鸿沟。与之前的方法不同,我们提出了一种新的无监督的语义解析方法——同步语义解码(SSD),它可以联合运用复述和语法约束解码同时解决语义鸿沟与结构鸿沟。

语义同步解码的核心思想是将语义解析转换为复述问题。我们将句子复述成标准句式,同时解析出语义表示。其中,标准句式和逻辑表达式存在一一对应关系。为了保证生成有效的标准句式和语义表示,标准句式和语义表示在同步文法的限制中解码生成。

我们通过复述模型在受限的同步文法上解码,利用文本生成模型对标准句式的打分,找到得分最高的标准句式(如上所述,空间同时受文法限制)。本文给出了两种不同的算法:Rule-Level Inference以语法规则为搜索单元和Word-Level Inference使用词作为搜索单元。

我们使用GPT2.0和T5在复述数据集上训练序列到序列的复述模型,之后只需要使用同步语义解码算法就可以完成语义解析任务。为了减少风格偏差影响标准句式的生成,我们提出了适应性预训练和句子重排序方法。

我们在三个数据集上进行了实验:Overnight(λ-DCS)、GEO(FunQL)和GEOGranno。数据覆盖不同的领域和语义表示。实验结果表明,在不使用有监督语义解析数据的情况下,我们的模型在各数据集上均能取得最好的效果。

07 Improving Document Representations by Generating Pseudo Query Embeddings for Dense Retrieval

论文下载 论文作者:唐弘胤,孙兴武,金蓓弘(中科院软件所),王金刚,张富峥,武威 论文类型:Main Conference Long Paper(Oral)

文档检索任务的目标是在海量的文本库中检索出和给定查询语义近似的文本。在实际场景应用中,文档文档库的数量会非常庞大,为了提高检索效率,检索任务一般会分成两个阶段,即初筛和精排阶段。在初筛阶段中,模型通过一些检索效率高的方法筛选出一部分候选文档,作为后续精排阶段的输入。在精排阶段,模型使用高精度排序方法来对候选文档进行排序,得到最终的检索结果。

随着预训练模型的发展和应用,很多工作开始将查询和文档同时送入预训练进行编码,并输出匹配分数。然而,由于预训练模型的计算复杂度较高,对每个查询和文档都进行一次计算耗时较长,这种应用方式通常只能在精排阶段使用。为了加快检索速率,一些工作开始使用预训练模型单独编码文档和查询,在查询前提前将文档库中的文档编码成向量形式,在查询阶段,仅需利用查询编码和文档编码进行相似度计算,减少了时间消耗。由于这种方式会将文档和查询编码为稠密向量形式,因此这种检索也称作“稠密检索”(Dense Retrival)。

一个基本的稠密检索方法会将文档和查询编码成为一个向量。然而由于文档包含的信息较多,容易造成信息丢失。为了改进这一点,有些工作开始对查询和文档的向量表示进行改进,目前已有的改进方法大致可分为三种,如下图所示:

我们的工作从改进文档的编码入手来提高文档编码的语义表示能力。首先,我们认为稠密检索的主要瓶颈在于编码时,文档编码器并不知道文档中的哪部分信息可能会被查询,在编码过程中,很可能造成不同的信息互相影响,造成信息被改变或者丢失。因此,我们在编码文档的过程中,对每个文档构建了多个“伪查询向量”(Pseudo Query Embeddings),每个伪查询向量对应每个文档可能被提问的信息。

具体而言,我们通过聚类算法,将BERT编码的Token向量进行聚类,对每个文档保留Top-k个聚类向量,这些向量包含了多个文档Token向量中的显著语义。另外,由于我们对每个文档保留多个伪查询向量,在相似度计算时可能造成效率降低。我们使用Argmax操作代替Softmax,来提高相似度计算的效率。在多个大规模文档检索数据集的实验表明,我们的方法既可以提高效果也提高了检索效率。

写在后面

以上这些论文是美团技术团队与各高校、科研机构通力合作,在事件抽取、实体识别、意图识别、新槽位发现、无监督句子表示、语义解析、文档检索等领域所做的一些科研工作。论文是我们在实际工作场景中遇到并解决具体问题的一种体现,希望对大家能够有所帮助或启发。

美团科研合作致力于搭建美团各部门与高校、科研机构、智库的合作桥梁和平台,依托美团丰富的业务场景、数据资源和真实的产业问题,开放创新,汇聚向上的力量,围绕人工智能、大数据、物联网、无人驾驶、运筹优化、数字经济、公共事务等领域,共同探索前沿科技和产业焦点宏观问题,促进产学研合作交流和成果转化,推动优秀人才培养。面向未来,我们期待能与更多高校和科研院所的老师和同学们进行合作,欢迎大家跟我们联系(meituan.oi@meituan.com)。