徐土豆
认证:优质创作者
作者动态
【论文极速读】引入复读负样本,一种打破LLM复读问题的方法
2天前
指令微调BLIP:一种对指令微调敏感的Q-Former设计
3星期前
Kosmos-2: 在多模态大语言模型中引入基准和指代能力
04-25 09:15
Kosmos-1: 通用接口架构下的多模态大语言模型
04-24 08:25
ERNIE VIL 2.0,多模态模型的一种多视角预训练范式
04-23 11:16

再论系统复杂度控制:错误控制与复盘

控制复杂度笔者曾经在[1]中将工作内容大致分为了两大类型,如下所示。

复杂(Complexity)问题:该类型的问题有多个子问题级联、并联而成,每个子问题都是较为简单的问题(比如数据清洗、整理和采集等等),多个子问题的彼此关联、依赖导致了整个问题变得复杂,一个前置子问题的错误很容易积累,最终导致整个任务的失败。这类型的问题,典型的比如合理的数据采集、整理规范,代码规范,开发流程规范等等,都可能被后续多个模块和任务所依赖,特别是数据,需要细致入微进行考虑,方能将事故扼制于未然。困难(Complication)问题:该类型的问题大多属于业界前沿技术问题,亦或是需要对整个业务场景有深入的理解方能解决,其涉及的模块会比复杂问题更少,但是解决困难问题更需要领域专家的长时间投入、研究,比如火爆全网的ChatGPT、Sora等。控制复杂度能保证系统的高效迭代,解决困难度能保证系统的领先性。然而在实际工作场景中,复杂问题和困难问题并不总是泾渭分明的,一些复杂问题中可能某些子问题属于困难问题,而一些大规模的困难问题需要领域专家去设计一个合理的框架,其中也会涉及到大量模块的设计和迭代,此处又将带来复杂性。本文还是将着重讨论复杂性问题。

1、复杂问题涉及模块众多。

2、复杂问题涉及模块之间存在级联、并联等关系。

3、复杂问题涉及模块具有不稳定性,经常会被迭代。

复杂性问题的第三点意味着在频繁迭代中,难免会出现错误,笔者称之为难以避免的错误(Inevitable mistake),这种错误有可能很低级,比如某个字段被错误地截断了,但是由于复杂问题的第二点和第一点特性,会导致一个很低级的错误不断地被放大,直至不可控制和难以溯源。我们必须认识到,人总是会犯错误的,我们不能去责怪人性,而要在认识到这个问题的基础上,尽可能提供一些机制去矫正、复盘难以避免的错误。以此,笔者提出2个日常处理复杂问题的基本原则:

提供复盘机制(Make it traceable):应该提供一定的机制确保当前工作的产出是可追溯的,这意味着在未来,你或他人都可复盘当前工作中包含的重要步骤。提供扩展性(Make it extensible):应该提供一定的机制保证你的当前工作具有一定的扩展性,这意味着你的工作并不是一个孤立的存在,而是作为一整个线条中关键的一部分。这2个基本原则是对 [1] 的深度思考和坚持后总结得出的,贯彻在笔者这三年工作中,对数据、代码、模型管理过程的实践,为笔者解决复杂问题提供了可遵循且有效的方法论。笔者愿在此与各位读者分享、交流。

提供复盘机制我们一旦认识到错误难以避免,就应当去思考采用一定的机制去及时发现错误并改正、总结教训,一个合适的复盘机制,无论对于数据还是代码甚至是项目管理都是必不可少的。具体来说,一个良好的复盘机制应该能提供足够多的代码和数据的现场信息,使得未来的自己或者他人可以从中发现之前隐藏着的错误(或者是其他关键信息),由于算法工程师的工作会有一大部分集中在模型训练,因此土豆建议,复盘机制应该尽可能集成在代码框架中,因此一个良好的代码框架就是一个复盘机制的具象化。让我们更进一步,去看看一些土豆在日常工作中采用的具体方法。

首先,一个良好的代码框架应该遵守之前[1]中介绍的这些原则:『代码的版本管理』、『代码架构功能分离』、『合理类继承』。别小看此处的『合理类继承』,这不光是一些代码编写习惯的问题,让我举个例子。之前土豆所在组的代码都是过了很多同事的手的,其中训练模型是一套代码,模型推理又是另一套代码,而这两套代码在同一个实验中,参数配置和模型配置大部分都是相同的,这两套代码的差别很多时候仅仅是数据读取字段不同。之前同事将这两个本不应该分开的流程,分裂出两个代码库的行为可能只是为了方便,然而如果后人对模型进行了一些结构修改,而这个修改因某些原因(可能忘记了,可能交接过程中未提及等等)未同步到模型推理代码库,那么后续的模型性能指标将是不置信的,而且除非推理结果变差得非常离谱,否则很难驱使你去回过头去检查推理代码,因此你有可能永远都无法发现这个问题。

而在采用了合适的继承机制后,如Fig 1所示,将不同阶段中可能共用的逻辑抽象成为base model和base reader,而保证修改都出现在基类中,就能尽可能避免出现以上提到的难以发现的错误。读者是否觉得这个和『提供复盘机制』有和关系呢?土豆不这样觉得,提供复盘机制的前提是整个代码框架的错误是可追踪的和容易追踪的。在基于模块继承的方法构建了代码后,我们可能需要复盘的东西无非是:

数据:在某次的实验中究竟采用了哪些数据进行训练,所采用的数据将会以filelist的形式在.conf中呈现模型和训练策略:在某次实验中采用的模型结构和训练策略如何?具体会以代码的形式呈现配置:在某次实验中所采用的模型配置如何?超参数将会以.conf的形式呈现实验过程:实验过程一般会关注各种损失函数和关键指标(如精准率、准确率、召回率等),同时也可以使用tensorboard和visualdl等可视化工具,将可视化结果进行落盘。为了方便,土豆一般会同时落盘文本日志和可视化日志。交付过程:如果存在交付过程,请在文档中记录下交付时候文件的md5码,方便后面复盘是否正确交付这些过程如果体现在代码中,说起来很简单,就是保证在入口脚本中对所有涉及到的文件都进行自动备份。最终可参考的代码框架的结构如下:

unified_framework_project/
| -- README.md
| -- models/
|    | -- base_model.py
|    | -- train_model.py
|    | -- infer_model.py
| -- modules/
| -- playground/
| -- tools/
| -- utils/
| -- configs/
|    | -- confs/
|    | -- filelists/
|    |    | -- train_filelists
|    |    | -- infer_filelists
|    | -- base_config.py
|    | -- train_config.py
|    | -- infer_config.py
| -- readers/
|    | -- base_reader.py
|    | -- train_reader.py
|    | -- infer_reader.py
| -- process/
|    | -- base_process.py
|    | -- train_process.py
|    | -- infer_process.py
| -- optim/
|    | -- optimization.py
| -- run_task.sh
| -- backup_me.sh # 提供代码和配置的备份,方便后续复盘

Fig 1. 合理采用继承的方法,减少因修改未同步导致的难以发现的错误。

其中run_task.sh是每次运行代码的入口,而我们只需要保证在其中每次都会运行backup_me.sh即可保证每次都备份了关键文件,一种可参考的backup_me.sh如下:

mkdir -p ./${project_name}/backup_home
cp -r ./*.py ./${project_name}/backup_home
cp -r ./*.sh ./${project_name}/backup_home
cp -r models/*.py  ./${project_name}/backup_home
cp -r readers/*.py  ./${project_name}/backup_home
cp -r configs/*  ./${project_name}/backup_home
cp -r process/*.py  ./${project_name}/backup_home
...

Fig 2. 纯文本日志和可视化日志的示意图。

在训练中产出的目录中,应该同时具有以下几种类型的文件,读者可能会有疑问,既然有了文本形式的日志job.log.x(其中的x表示第x个GPU卡上的日志),为何仍要多此一举弄出可视化日志visual_log/呢?这主要是这两种日志关注的指标各有不同,文本日志除了常见的模型训练指标外,还有一些训练环境相关指标,比如训练速度,当前读取的文件名和大小,当前试验采用的模型配置等等,这些有助于我们回过头去发现一些模型训练的性能问题,对代码debug也非常有帮助。可视化日志主要还是为了监控模型业务指标设计的,有助于我们发现模型本身的业务问题,因此这两种日志的侧重点各有不同。Fig 2.就是一个典型的文本日志和可视化日志的示意。

exp_0_debug.log.output
| -- job.log.0 # 训练过程的所有文本日志,在rank=0机器上
| -- job.log.1 # 训练过程的所有文本日志,在rank=1机器上
| -- output_ckpts # 输出的ckpt
| -- backup_home # 备份路径
| -- visual_log/ # 可视化结果的路径
| -- md5_home/ # 交付ckpt的md5校验码

我们在交付模型的时候,应该以以上产出为最小交付单元,因此被交付方和自己都有办法去复盘整个过程的合理性,而不至于陷入错误而无足够的现场信息以调试。后续土豆打算在github开源一个demo项目,提供更多细节去阐述这个代码框架的思考。

我们以上都在讨论代码角度的可复盘机制,但其实数据的可复盘机制也是非常重要的。在进行数据组织的时候,尽可能规范化数据管理,这一点可以从数据的命名方式开始考虑,如[1]中所述,同时尽可能纪录下数据的交付方以及时间等信息,方便后续回溯责任人。土豆践行了两年前对数据管理的认识,如下所示,发现确实减少了出错的情况,并且也可以回溯数据的信息,某些时候如果存在交付和被交付的关系,和模型交付一样最好同时保留交付数据的md5码,为后续数据复盘提供基础。

对于普通的小规模数据集而言(通常数据量在几十万以下,用单个或少量文件可以组织,通常是文本形式的数据),进行规则的数据文件命名管理,命名规则如下:

对于大规模的数据(通常是用于预训练的数据),规模在千万到亿级别,由于数据量过大会采用分part的方式储存在集群中,这个时候无法对每个文件名进行统一管理(每个part的文件名通常是part-xxxxx,同个数据集的所有part都在同一个文件夹内储存),通常对文件夹名进行管理即可,并且最好在wiki中对数据细节进行记录。

当然,一些公司会专门设计内部效率平台对数据和模型进行托管,此时可以将数据和模型托管在平台上进行管理,为多人协同提供更好的条件。

提供扩展性给当前的数据、代码等提供扩展性设计,是对未来自己的一种善待。我们要意识到现在在设计上的懒惰,会导致以后执行上的痛苦。数据可扩展性设计的原则是:

数据应该是一个自包含的状态,即通过数据中的某些特定字段可以复原这份数据本身,我称之为元字段(meta segment),同时也可以通过元字段对数据扩充更多字段。一种典型的元字段就是url,作为资源的唯一定位符,可以通过url去提取所需要的所有正排特征。即便会使得数据变得冗余,仍然要保证数据的自包含状态!一份数据的每一行应该具有唯一标识符,一种典型的唯一标识符就是hash码,从更简单的角度上看,可以用自增的数字表示每一行数据,考虑到大规模数据中通常都是分part的形式储存的,可以一并将part的编号纪录下来,用于唯一表示这份数据的每一行。这两条看着理所当然的原则,其实也是土豆在实际工作中吃过的亏,让我再讲一个故事:

我们之前在采集数据的时候,是根据用户query和相应的视频url去进行人工标注,视频有若干特征(如标题、OCR、ASR等等),在标注之前一并在特征库里进行了查找和拼接,之前负责这块的同学觉得此时视频的url并不重要,因此不需要保留就将其直接丢失了。最终落盘的数据字段,为: query \t video_feat_1 \t … \t video_feat_n \t label。从当时的角度看这当然没问题,然而随着项目的发展,后面逐渐需要新增字段,或者对旧字段进行更新,此时由于缺少了url,使得这件事情充满了毫无意义的挑战。 人工标注数据是很昂贵的,由于之前规划数据管理的时候没有考虑到“数据自包含”的重要性,导致很可能要将这一批标注数据直接舍弃!更严重的是,由于样本标注需要时间,现有数据的不可用会大大延误当前项目的排期!

从这个故事中,我们发现了一个本不应该出现的问题,由于设计数据组织形式的时候没考虑到未来的扩展性,导致缺少了让数据自包含的必要字段,即是视频的url,导致给后续新项目复用数据带来了不必要的困难。在这个过程中,我们只要坚守『数据自包含』原则,那么就会自然而然将url给附带上(即是当时并不是必要的字段),这会给后续的扩展性工作提供非常大的便利。

对于第二点原则,对每一条数据进行唯一标识也是为了能在后续进行方便的扩展。我们经常会遇到需要给一份数据中新增字段的需求,这个时候每一条数据都有唯一标识会极大地方便我们拼接旧数据和新字段。

以上谈到的都是数据的可扩展性,而代码架构上也是需要支持可扩展性的,一种良好的做法就是采用上文提到的『合理的继承』,在这个基础上,添加一个新的model、reader、process都只需要继承父类即可,因此代码功能的扩展会变得很轻松。以reader为例子,base reader主要负责了数据的分片组织和读取等基础操作,而对数据字段的解析则作为了一个未实现的空方法,供下游reader继承实现。此时如果需要对不同形式的数据进行读取,只需要注册一个新的子类reader即可,这相当于进行了功能的插件化。为了代码功能的可扩展性,我们应该用好OOP面对对象编程的思想,尽可能抽象出共用逻辑,将特化逻辑抽离出来做成插件使用。

采用管理手段解决复杂问题一个人的力量有限,要将一个事情做大做强,势必需要多人协同,多人协同也是一个复杂问题。土豆在这一年有机会作为方向负责人去规划一个小方向的迭代,我深刻意识到多人协同就像是计算机中的多进程处理,并不是无脑堆计算核就能提高效率的,在保证整个方向的规划和目标是合理的情况下,为了有效发挥人力作用,应该尽可能做到:

1、有效的信息交换:就像进程之间协作需要通信一般,多人协同同样需要畅通的信息交换,避免出现无法预知和无法控制的错误。2、任务分解和调度:就像进程在计算机中运行会受到计算机操作系统的任务调度一般,为了尽可能让每个人力都尽可能发挥出最大效能,方向的负责人就像操作系统一般,应该对整个方向的迭代进行任务拆解和调度,同时需要分析每一步可能的阻塞点、判断其是串行任务还是并行任务等。

作为方向负责人同学,应从长远角度看待项目发展,为了避免后续项目管理出现不可控问题,需要考虑制定合适的代码准入规则、开发范式等,我认为最好的一种方式应该是作为牵头人去统一整个方向的代码框架。以我自己的实践为例子。

1、背景:我作为视频搜索中多模态方向的负责人,负责多模态方向的规划以及多模态在各个业务方向上的落地,如排序、召回、质量建模等。在这个过程中由于会接触到各个方向的一线模型代码,而每个方向上的代码都各有不同,同时多模态项目同样也有不少。如果每个项目都是一套独立的代码,那么新人接手的熟悉成本就会很高。2、做法:为了降低新人接手成本,我作为负责人在熟悉所有涉及业务方向迭代的基础上,将所有其他业务方向(排序、召回、质量、多模)的基础模型训练、推理代码都统一到一个框架中,这个框架提供了多机多卡训练能力,同时提供了以上提及到的复盘机制和可扩展性设计,可处理的数据可以高达百TB级别,具有较好的scaling能力。3、结果:在统一框架的过程中,尽管中途遇到了一些结果不能和现存框架结果对齐的情况,同时短期需要更多人力去进行试验,但是从长期看,由于所有框架的基础代码都以统一框架的形式构建,新人接手项目的成本变得很低:ta只需要接手过这个框架编码的其中一个项目,后续的新项目的接手将变得很简单。从长期看收益是巨大的,除了降低了接手成本外,组员的沟通成本也得到了大幅度降低,同时能尽可能避免出现不熟悉框架而导致的低级错误。

因此,统一代码框架不仅仅是“码代码”那么简单,他背后蕴含着的逻辑是:将代码视为基建,而不是用时则来用完即抛的“砖头”,一旦形成了结实可靠的基建,那么团队之间就能形成对这套机制的认可,降低沟通成本,减少熟悉成本,同时能尽可能减少低级错误的发生,团队甚至可以对基建进行更进一步的建设,这样我们的技术才得以成长,我们的技术才具有护城河。这是我践行进行“有效的信息交换”的一种手段。

当然,这是一个大工程,我们在统一框架的时候要注意平衡“吐槽”和“建设”的边界。一种可行的方式是,由两个人力去完成一个项目,一个人力基于旧框架进行模型实验,另一个人力需要具有足够经验和能力,去将历史代码迁移到统一框架中,同时尝试去追赶旧框架的指标,在计算收益的时候将两者捆绑在一起,这样才能逐步推荐代码框架的统一。

同时,作为方向负责人应该去进行合理的项目管理,在给定了一个项目后,应盘点这个项目所需的资源,判断风险后启动项目。启动项目后应将项目进行问题拆分,如下甘特图所示,一个在排序模型引入多模态能力的项目可能包含有若干个步骤,这些步骤有些有依赖关系,那么他们就需要串行执行,前序未实现将导致后续步骤的阻塞,比如小流量试验的开始依赖于模型训练完成,依赖于在线和离线通路的搭建等。有些步骤可以并行完成,如数据处理过程中可以分人力进行模型代码编写,通路建设等。采用如下甘特图可以管理项目的进度和关键进展,同时让参与项目的多人都知晓整个项目的进展,以及项目的卡点所在。采用这种方式可以管理项目的复杂度,以图示的方式展现出来项目进度和关键进展,也可以让负责人及时判断是否有明显卡点以及卡点所在(资源、人力、技术),减少因错误认知带来的项目延期和错误。

声明:本内容为作者独立观点,不代表电子星球立场。未经允许不得转载。授权事宜与稿件投诉,请联系:editor@netbroad.com
觉得内容不错的朋友,别忘了一键三连哦!
赞 1
收藏 2
关注 49
成为作者 赚取收益
全部留言
0/200
成为第一个和作者交流的人吧