作者:太子长琴,算法工程师
本文是一个转行四年的「菜鸟」的成长心得,围绕真实工作场景中,AI算法工程师是如何从头到尾完成一个产品需求的。
这几年基本能碰的都折腾过了,另一方面也走了非常多的弯路。终于感觉到自己有了质的突破。有点像炼气期满筑基,奋斗之路刚刚开始,是有此文。
00 背景
我们先假定已经接到一个明确的需求,假定它是一个比较简单的分类任务,数据和标注都有,而且样本分布也没有极度不均衡。
面对这种情况,以前的我一般都会毫不犹豫地拿出最新的、最有效果的模型往上怼:效果好就直接用,效果不好再说;如果性能不好,就考虑使用压缩、蒸馏等瘦身方法。这种搞法不能说一定有问题,但我觉得至少考虑问题不够全面,也不够细致。
接下来,我们就以此为例讨论完成一个产品需求的全流程。
01 数据准备
在需求清晰的前提下,第一步要做的是拿到数据和标注。有些标注是可以通过日志或埋点记录直接获取到的,而有些则需要组织人工进行标注。如果是组织人工标注,主要需要注意两个方面问题:第一,要让标注人员充分理解 Label 的含义,不能有模棱两可的情况出现;第二,明白标注一定会有错误。对于错误的标注,可以先跑模型然后预测数据集,将标错的样本按置信度排序后,重新标注。这样重复几个来回,错误的标注就会越来越少。
拿到数据后第一件事就是确定数据的分布,如果是均匀的那还好,如果是极度不均衡的那就要准备采样方案。有些任务可能需要在已标注数据的基础上重新构造数据集,这种情况需要准备构造方案。比如句子对模型,我们实际拿到的标注数据可能是句子加某个 Label,同一个 Label 的表示句子相似,不同 Label 表示不相似。
这一步不同的任务或不同的公司可能都不相同,比如有些公司可能没有埋点,甚至日志记录都不完整。还有些公司可能没有标注工具,只能使用 Excel 这种传统的方法。作为一名工程师,如果遇到类似的情况就应该开始考虑如何去构建这些基本组件和服务。
02 数据分析
数据构造完成后首先要做的是熟悉任务的数据。因为有时候数据并不是我们想象中的样子,比如用户的 query、或者 ASR 之后的文本、或者来自于网页的内容,不同的数据有不同的特色。
用户的 query 可能会有错误,这些错误可能往往是音近或形近导致的,也有可能有拼音输入;ASR 之后的文本可能有很多无效的口气词,当然错误是免不了的,可能更多的是发音不准导致的错误;而网页的文本则一般比较规范,不像 ASR 那么口语化,其中的错误往往也是句法、语义层面的。不同的数据会影响后续的处理以及模型的选择,所以熟悉当前任务的数据是第一步要做的。
接下来要分析数据与 Label 之间的关联性。以分类任务为例,数据样本和每个 Label 之间的关联体现在什么层级?是一些字词就能区分,还是需要字词的顺序?还是需要理解整句语义?是不是需要先进行纠错,不纠错会不会对结果产生影响?
只有清楚了这些才有可能选择适合该任务的模型,训练完之后也能够对预期做出检验。比如假设只要单纯地使用字词就可以区分 Label,那 TextCNN 也许就完全足够了;如果句子本身可能有一些无效词,那 TextCNN 的效果也许比 Bert 还好。这也很容易理解,因为 Bert 的预训练是建立在正常文本上的,而且是从整体考虑的,对非正常文本这有时候反而会失效。
所以,熟悉数据、熟悉任务是非常关键的一步。那实际中有什么好的方法吗?这个除了经验外,基本也只能自己去看数据了,而且即便是有经验也最好再熟悉一次,比如不同的 ASR 产品也许犯错的种类不一样呢。实际过程中可以对样本按类别进行均匀采样,然后去观察数据。
03 测试方案
很多人以为分析完数据就可以直接开干了,当然这也没问题,但我更觉得在开始之前应该先想清楚测试方案。至少应该包括以下内容:
- 正常的精准、召回、AUC。根据关注精准还是召回,如何权衡。是否有任务需要特别关注的指标?比如推荐要评估多样性、覆盖率等。
- 如何测试模型的有效性?即如何评估模型真正学到了期望学到的东西。这对于之后的改进有非常重要的意义。
- 如何测试模型的性能?能否满足目标,比如多少并发下的平均响应时间。
- 如何做回归测试?主要针对系统不同组件更新对其他部分和整体的影响。
- 如何做线上 A/B 测试?主要测试组件更新后线上是否真的有效。
第一个属于常识,也是最基本的测试方法,我们需要注意的是,多种方法(比如规则+模型)配合使用时,如何确定阈值以实现整个系统效果最优。这里需要重点关注的是尽量让不同的方法互补,交叉最少。
第二个其实是从系统使用的角度考虑的。比如句子对相似度模型,我们在实际使用时往往并不是直接给一个句子对,然后判断它们是不是相似。往往会有其他一些应用场景,比如在 QA 时用来查询最相似的 Answer。这种情况我们应该测试的是召回方面的效果。
第三个是需要特别强调的。有时候我们会上来就用一个当前效果最好的模型(我以前就经常),但很少思考为什么这个模型可以。这个模型的哪个结构或配置起了作用?为什么会起作用?是不是和我们预期(分析数据阶段)的相符?无论我们使用什么模型,都应该深入探究起内部机理,比如 TextCNN,我们可以把 MaxPooling 的位置给标记出来,不同的 Kernel 该位置的分布是什么样的,这些位置能否代表整句话输出对应的 Label。只有明确了这些机理,做到了心中有数,才有可能根据不同的任务和数据调整模型的结构。
后面几个基本都到系统级别了,涉及到与当次任务组件相关的其他模块。性能是保证线上服务稳定的基本,在设计模型之前就应该予以考虑,这里的考虑不止是性能的目标,还应该充分考虑公司能提供的资源。如果最终服务没有 GPU 服务器,那 GPU 并发的推理就不予考虑;如果要部署到边缘设备,那还要考虑支持哪些算子,是否需要单独开发;如果是微服务方式,则需要考虑服务间通信、缓存等因素。总之,最好能在构建模型之前就对最终的使用方式有一定的了解,这样也便于构建和使用最适合的模型。
大部分情况下,我们面临的都是一个系统,模型负责的任务可能只是其中的一个个组件。很自然地,当某个组件发生变化时,很有可能会对依赖它的其他组件产生影响。比如召回和精排,如果召回模块出了问题,后面的精排模块再怎么样结果都可能是有问题的。所以,我们在上线新的版本或使用新的模型时,一定要考虑到它可能对系统整体造成的影响。具体就是在模型发布在测试环境后要跑回归测试,确定模块之间衔接符合预期。
A/B 测试是从产品整体的角度进行评估,一般情况下我们都会让新发布的版本和之前的版本分别在不同的分组上运行一段时间,然后评估最终效果是否提升。需要特别注意分组最好能够保证在各个维度上的随机性。
科学界有句名言:“没有测量就没有科学”,放在软件开发领域依然适用,且无比正确。因为无论我们做什么任务、用什么模型,最终都是要作为产品的一部分给客户使用的。作为一名工程师,我们理应时刻站在整个系统的角度考虑问题。
04 模型算法
这部分内容应该是算法工程师最核心的工作了,尤其是对于专门从事算法研究的同学。抛开研究不谈,从工业的角度来看,如果前面数据阶段已经搞得很清楚了,这一部分的工作反倒会容易很多。因为我们现在几乎所有的模型,尤其是有监督训练的模型,本质上都只是在 “记忆” 训练数据,只不过记忆的是特征。不同的模型归根结底只是记忆特征的方式不同罢了。这也就是为什么会有 “数据决定上界,模型只是不断逼近这个上界” 的说法。
另外,根据实际经验,模型训练这一过程所花费的时间往往并不多,可能还不到 25%,但绝对不到 50%。而且大多数时候都是把模型跑起来然后就去干其他的了,也不用一直盯着它看。尤其是前面都搞清楚后,这里只需要等模型跑完看下结果,验证一下是否符合我们的预期即可。
当然,关于模型训练我们依然要考虑一些因素:
资源。最直观的是机器资源,有条件的可能使用 TPU,没条件的可能只能用普通的 CPU,或者要去租赁 GPU。资源的不同除了训练模型的代码不同外,其实更重要的是训练策略的不同。比如有几乎无限制的资源时,我们可能会多跑几组模型,多试几组参数,甚至会重新预训练大模型,自己做蒸馏压缩。当然除了机器资源,还有人力配备、时间节点等等都要予以考虑。
多模型。当面对一个任务,尤其是复杂任务时,我们经常会有不止一个想法(模型结构)需要验证,自然需要跑多组模型看效果。即便不是这样,最好也能尝试多个侧重点不一样的模型,这样更容易验证自己的想法。比如,我们可以同时跑 CNN 和 LSTM+CNN 判断时间顺序到底有没有起作用。
预处理。这块内容和任务强相关,但总的来说中文任务一般包括以下几个方面:
- 字级别还是词级别的 Token。字相比词没有那么稀疏,词表也比较小,缺点是损失了部分语义。因为中文的基本构成单位是词,尤其是涉及到实体,拆开单独看可能完全是另一种意思。而词相比字,除了词表很大外,还会有未登录词的问题,另外,还需要单独做分词任务。具体使用字还是词要看任务确定。
- 数据清洗。主要是去掉一些无关的文本,比如超链接、图片、特殊符号等等。
- 数据归一。这个主要是处理具体的、特定的文本。比如句子级别的任务中,实体(包括人名、地名、时间、方位等)可能并没有太多意义;部分任务中具体的数字可能需要规范到不同的范围。3. 调参。俗称炼丹,主要是对同一个模型使用不同的超参数,比如 Embedding 维度,TextCNN 的 Kernel Size、Filter Size,Bert 的层数,各种 hidden size、激活函数等等。除此之外,还应该包括部分组件的调整,比如增加归一化、使用 Dropout等等。使用不同参数前依然应该遵守分析数据的方法,假定参数调整了结果会咋样,然后通过实际结果去验证我们的假设。
05 模型部署
这里主要指将一个或多个深度学习模型部署为微服务的情况。这是我个人比较喜欢的方式,主要有以下好处:
- 部署使用分离。所有的推理都通过 RPC 或 RestFul 接口实现,与模型部署无关,甚至与模型本身也无关,服务模块只关心输入和输出。这样当我们需要更新模型时,只需将新模型放到对应位置即可,代码层面不用做任何改动。
- 合理利用资源。所有的模型可以统一部署到一个服务下,共享同一个服务器资源。因为模型一般是用 GPU 服务器,而普通的服务一般是用 CPU 服务器,这样的部署方式能够更合理地利用资源。
- 统一管理监控。因为所有的模型逻辑上都在一起,所以无论日常的管理还是数据的监控,实施起来都比较方便。
在部署时,推荐使用容器化部署方案,使用 k8s 或类似的集群框架对服务进行管控。这不是我们要赶潮流,主要是考虑到以下几个优点:
- 部署方便。完全不用考虑不同环境可能造成的冲突,所有的服务相互隔离。部署时通过 YAML 配置服务,实现一键全自动部署。
- 便于扩展。水平扩展可以直接添加实例,垂直扩展修改资源限制,所有配置均可通过配置文件完成,完全实现资源配置化。而集群资源不够时,直接添加节点主机即可。
- 便于管控。通过 Istio 等组件非常容易实现流量和服务管控。比如可以很容易地配置实现灰度发布,进行线上 A/B 测试,而且这些功能都是和业务解耦的。
- 节约资源。因为集群的服务其实是共享节点资源的,所以高峰时期服务会自动多占用资源(当然不会超过配置的限制),低谷时期就自动释放资源。这样其实最大限度地利用了可利用的资源,节约了成本。
- 管理方便。从管理机器变成管理服务,只要配置好相应的服务,机器只是无状态的节点,多一个少一个挂一个重启一个对服务没有影响。而且集群还支持非常细粒度的权限控制,使用权限可以按需下发到部门或个人。
当然,使用这种方案本身是有一定学习曲线的,不可能一下子掌握。另外,也要根据公司的实际情况,分清楚使用场合。
06 工程开发
工程开发是一个工程师最核心的能力,我们常说算法工程师他首先得是个工程师,就意味着算法工程师必须要具备不错的工程能力。这块其实是编码能力,说起来很大,这里也主要谈一下自己对工程开发的一些浅显认识。
写好测试
注意我并没有说 “测试驱动”,因为这个往往说的人多但能做到或做好的少;而且这个并不是规范,更不是银弹。不过测试的重要性毋庸置疑(我也不大会相信会有工程师在写完工程后再补上测试代码)。所以,即便不是测试驱动,也强烈建议在开发的同时完成测试代码,甚至可以在功能完成后把测试代码写好。这其实是对自己代码的一种自测,是代码清晰、系统稳定的基本保证,好处至少包括:
- 代码更鲁棒。主要体现在边界和非法输入测试环节,它会强迫我们去考虑各种可能错误的输入。
- 代码更清晰。比如有个函数同时做了几件事,在写测试的时候就会发现很不好写,因为几件事可能互相有影响导致输出不同的结果,这就能逼迫你重构代码。
- 代码更系统。主要是指多个函数(或组件)组合或管道完成一项功能时,结果不符合预期的情况。此时如果某个函数(或组件)有完整的测试,那我们就很清楚地知道该函数(组件)一定没问题,问题肯定出在它之前或之后。
- 出错更容易排查。只要发现 Bug,很容易就知道问题出在哪里,因为报错信息一般能提示到具体的方法或函数,根据报错信息结合已知的测试范围很容易就确定到底是什么原因导致的错误。
只要写一次,就会一直有效。不考虑重构的话,测试简直就是一劳永逸的事,无论代码怎么改,只要功能和输入输出不变,测试就一直有效。还有比这更美好的事吗?
至于怎么写,总的建议是每个函数都应该有测试,且至少应该包括:正常功能测试、边界测试、非法输入测试,底线是核心代码必须要有测试。另外,测试也是要随着代码的演进不断重构和优化的,很多时候甚至是 Bug 让我们的测试更加完善。
写好注释
首先需要澄清的是,这里并不是指 “代码即注释”,“代码即注释” 应该是作为工程师群体的基本共识,不需要再多做讨论。这里主要是要解释为什么这样做,或者记录设计思路。以我的实际经验看,如果没有注释,对于稍微复杂的设计或思路,过几个月再看基本上是看不懂了,或者要想很久。
所以说,注释首先是给自己看的,可以想象,自己写的代码过一阵子都看不明白,那又怎么能期望别人(尤其是接你活儿的人)能看懂呢。至于形式我觉得反倒是其次,但如果能按规范注释,当代码完成时我们可以顺便得到一份自文档,这对于爱偷懒的人绝对是福音。
写好文档
这恐怕是绝大部分程序员最不爱干的事,不过还是需要澄清一下,这里指的是 “设计文档”,而不是接口或使用文档,后者我一般会倾向于使用自文档。设计文档至少应该编写两次,第一次是在项目或任务开始前,第二次是在结束后。它主要记录项目的目的、整体的构思、所使用的的技术、架构等,它是战略层面的指导方针,编写该文档的过程其实是理清自己思路的过程。如果发现自己无法完成该文档的编写,那很有可能是有些地方根本没想清楚,或者目标或需求很不明确。
模块化
与此相类似的还有组件化、微服务化,不过模块化是针对代码功能层面的。模块化有不少优点,比如:系统整体结构清晰,功能结构一目了然;模块之间相互解耦,便于开发和维护;模块可复用程度高,减少代码重复。感觉就像 “代码即注释” 一样,程序员天然就会在不知不觉地模块化。
DRY
对这条原则的基本感悟是:如果代码重复第二次,就应该考虑将其变为独立功能的函数;如果重复第三次,涉及到的所有代码应该已经很好地被重构了。其实我在这里更加想表达的是,如果有能更省事的方法,就尽量尝试去用,比如泛型编程、宏、模板。
分层和抽象
这应该算是一种基本的设计方法,其实我们在软件开发中会经常使用,比如分词可能是一个底层服务,情感分类模型可能就是上一层的服务,对话机器人可能是更高一层的服务。不同层的抽象程度不同,底层服务相比高层服务往往更加抽象,因为它们可能被多个不同的上层调用。这样设计的主要目的还是清晰、隔离、解耦、重用,服务彼此独立且又相互依赖,通过组合形成完整的系统。
08 运维监控
这块内容其实往往比预想重要,打个形象点的比喻,运维就是我们的感官,系统现在的 “状态” 如何全靠它来展示。这里的状态主要可以分为两个方面:数据流转和服务负载。
数据流转是指流程中各个节点的输入和输出情况,节点可能是某一个服务,也可能是一个组件,甚至是一个模型,关注的是流转的内容是否正常。服务负载是指流程中各个节点的流量情况,关注的是流量的大小。这块内容之前涉及的很少,只是简单地用过一下 Istio,暂时也没有太多的感受。先记录几个自己的直观认知。
- 配置化。主要针对运维,理想状态下每个模块都有对应的配置文件,整个系统的组织就是对配置文件的组织。这在 k8s 里面是自然的,我们可以通过配置文件很方便地指定环境变量、资源配置、服务策略等,然后可以用命令行工具一键(或自动)启动或更新。与此相反的是人工手动操作或代码里写死,要尽量避免类似的行为,如果需要通过外部变量影响模块内部行为或模式,最好将这些变量映射到外部配置文件。
- 系统化。主要针对监控,当我们设计监控系统时,不仅要考虑关注的节点,还要考虑这些节点状态信息如何传输、存储、读取、展示,以及数据的流动是否会影响系统性能等多个方面。可以做个很简单的实验,一个简单的循环操作,每个循环打印结果和不打印结果性能会相差数十倍,如果要写信息,性能相差可能会更大。一个反面的例子就是无脑打印日志,不仅充斥着大量乱七八糟的无效信息,还严重影响性能。
09 更新迭代
软件工程不同于传统的工程项目,它在实施完成后依然需要频繁地改动,这也是软件工程更加复杂的一个方面。这里除了代码层面的可扩展性,还包括版本管理和热更新,此处重点说说后两个。
版本管理
我们平常一般会碰到两种不良的方式:第一种,将一大堆功能堆到一个版本下面;第二种,有一个改动就生成一个版本。这一般是粗放式发展下的结果,正常情况下,版本管理应该至少考虑以下几个方面:
- 需求推动。需求往往来自于三个方面:业务方、运营方和技术规划。无论哪种需求,我们一般都会放在需求池中,然后根据优先级规划不同的版本。版本的迭代可以按周、双周或月,不过个人不太推荐太长的迭代周期。一来容易懈怠,二来可能有新的重要需求不断出现,三来回退麻烦。比较推荐双周一版本,时间分配上,一周用来开发,一周用来规划、测试。
- 语义化版本。这个本应该是软件开发的常识,其主要思想是将软件的变动记录为 主版本号.次版本号.修订号 的形式,主版本号主要是针对不兼容的改动;次版本号是针对向下兼容的功能新增;修订号是针对向下兼容的功能修正。具体可参考:语义化版本 2.0.0 | Semantic Versioning。这其实是一种基于约定的方法,作为开发人员只要一看版本号就能掌握关于该版本的基本信息。
热更新
集群环境下热更新比较方便,我们这里主要说的是模型的更新。具体又包括以下几个方面。
- 模型全量更新。主要是指模型整体升级,比较推荐 tensorflow/serving: A flexible, high-performance serving system for machine learning models,不仅性能优秀,而且可以通过监控模型文件的变化自动升级到新的版本。同时,还支持 RPC 和 RestFul 两种接口,支持版本控制,支持多个模型,简直是业界良心。
- 模型在线学习。主要指模型根据线上数据实时或准实时更新模型的情况。这块目前工作几乎还未涉及,日后更新。
- 更新毛刺。主要指模型更新前后线上请求出现的延迟抖动现象。一般在规模很大时才会出现。爱奇艺团队针对 Tensorflow Serving 有过不错的改进尝试,被 Tensorflow 官方公号发表,具体参见:社区分享 | TensorFlow Serving 模型更新毛刺的完全优化实践。
以上就是完成一个产品需求的全流程了。但在实际工作中,有时候需求并不是完全清晰的,还可能是伪需求,这部分如果有伙伴感兴趣,会写一写AI工程师面对不确定需求的沟通方法。
想看下篇的伙伴点赞三连↓