Batch采样策略的优化

本文版权归Pennyyu0214所有,如需转载本文或引用、复制文中原创内容的,请注明出处。

Batch Size介绍

Batch Size解释为模型训练时一次采样得到的样本数,是机器学习或深度学习任务中的常见超参数。在Batch Size没有得到应用以前一般使用BGD训练模式,即模型的训练往往是一次性读入训练集的所有样本,计算梯度后使用优化器更新参数。这种方法虽然得到的梯度更加准确,参数更新也稳定,但缺点有二:①BGD模式下模型参数各个梯度的更新量变得不平衡,需要分配不同的学习率补偿,这种情况下一般采用单独梯度更新策略;②同时承载全部样本会导致收敛变慢,GPU显存占用也会非常大。 因此出现了Mini-batch Gradient Descent模式,即每次迭代时输入一个批量大小的数据,更新后再输入下一批。很好地解决了BGD效率低的问题,同时也不像SGD那样波动大、容易陷入局部最优点。
在当今的深度模型优化任务中,Batch Size的设定通常收到GPU资源的影响,因为Batch Size设得过大会导致执行计算时特征结点过大,容易造成显存溢出,因此Batch Size和显存占用是紧密联系的。

Batch Size的衡量标准

Batch Size需要反映什么

上文说到,Batch Size的量化表示需要能够直接反映未来的计算图大小,具体是计算图中的输入特征大小,因为输入特征大小也直接影响到后续每个非叶节点(中间隐层)的大小。而读者应该也清楚,输入特征通常是BLH维度大小,其中B代表Batch大小,L代表当前Sequence Length即序列长度,H代表隐藏维度大小,而大多数情况下H在整个训练时是不会改变的,它取决于模型配置参数(配置分为 模型配置和训练配置 两个基本类别,模型配置包括层数、隐层维度、卷积核参数等,是模型本身具备的参数,直接反映了模型规格,是模型参数重载、复用的基本前提,大多数论文的同模型复现实验也都要保证这些参数一致才有说服力;训练配置包括Batch Size、学习率、Dropout rate、Warm-up rate、优化器相关参数等)。因此B、L会给计算图体积带来直接影响。所以Batch Size的衡量不应只反映到B的大小,也要包含序列长度L.
有了上述分析,我们认为Batch Size的大小应该直接与B、L两项挂钩,即一个批量下的样本数、样本的长度。

传统标准—按样本数量化

按样本数量化的Batch Size表示可以解释为设定值等于一个批量数据的样本数(即上文的B维度),Batch Size设为多少,采样的一个批量就是多少条数据。如Batch Size设置为64,就是读取64条文本,每个文本对应一个标签,转化为特征送入模型中。

为什么会存在

那么先前既然讲到Batch Size不能只以B的大小来衡量,为什么还会存在样本数量化方式呢?这要从深度学习框架的运算图模式说起,我们都知道深度学习框架有很多,例如TensorFlow、Pytorch、Theano等,同时还有TensorFlow和Theano的上层调用如Keras等。其中TensorFlow中就包含两种不同的图模式—静态图和动态图,我们逐一介绍两种模式。

静态图

静态图采用声明式编程,即先编译后执行的范式。根据前端语言(如python)描述的神经网络结构和参数信息构建固定的计算图,且在执行期间不再依赖前端语言,而是由TF框架负责调度执行,适用于做神经网络模型的部署。因此静态图在模型训练前就已经定义完毕,所有的操作都通过tf.Session对象和tf.Placeholder进行,这两个张量在运行时会随着数据的迭代而不断更新具体值,而形状、类型等属性是不再改变的。
在这里插入图片描述

动态图

动态图采用命令式编程的范式,也就是同时进行编译与执行操作。编译操作即使用前端解释器解析用户代码,执行即利用TF框架的算子分发功能,在编译后立即执行算子并返回结果。当模型接收输入数据后,TF开始动态生成图拓扑结构,添加输入节点并将数据传输给后续节点,且当前数据处理完后,图结构会被删除。因此在动态图下,模型开始新的batch训练时,原有的图结构都会失效,必须根据输入和控制条件重新生成图结构。
在这里插入图片描述
其他静态图与动态图之间的对比总结如下:
在这里插入图片描述
好了,有关图模式的介绍就先到此结束,回到之前的问题:为什么采用样本数量化的Batch Size?原因就在于TensorFlow的静态图模式,因为静态图模式下输入特征的维度都是固定的,这也意味着任何输入下序列长度L都是固定的(比如L=512,哪怕当前Batch的输入最长只有几十,也都要强制用Padding填充到512),这种情况下一般序列长度和隐层维度H一样,都作为模型配置保存或读取。因此只看样本数多少就能计算出Batch Size究竟是多大了(例如Batch Size设为64, 序列长度设为512,则体积量化的Batch Size为 64 ∗ 512 = 32768 64 * 512 = 32768 64512=32768,也就是 32 k 32k 32k)。

优点: 就是静态图的优点,对工业化部署非常友好;
缺点: ①Padding冗余十分严重,降低了训练效率(因为Padding导致批次内的有效数据减少,自然训练一个Epoch时间就要延长)②Padding冗余也带来了计算总代价的增加,如果此时输入Batch的不同样本之间长度差异大的话,这种现象更加严重。

按Token数量化

由于Padding冗余的缺陷,动态图为神经网络模型的运算极大地赋能了。虽然动态图每次执行时都附带一个定义图的过程,但由于不固定输入长度,特征的L维大小完全取决于当前批次的最大长度,因此特征张量中的有效值变得更加充实,数据的处理效率自然也就提高。此时Batch Size就不能单纯以样本数来衡量了,前文我们讲到,一个明确的Batch Size应该能直观地反映输入特征的体积,即隐层维度不变的前提下,Batch Size应与B、L两项相关。由此动态训练模式下采样的Batch Size为当前批量经Tokenize后包含的总Token数,但这里又有个问题:输入不定长,因此需要对较短的序列填补Padding以保持长度一致,在这种情况下的总token数就作为当前的Batch Size了。因此Batch Size计算为 B ∗ L B*L BL.

Token级的Batch Size在采样时采取如下策略:每读取一个样本 s s s,计算加入 s s s之后队列里所有样本分词并长度一致化后的Token总数,如果超过了,队列里除 s s s外的所有数据作为当前轮迭代的模型输入,在发送完成后清空队列并加入 s s s;否则将 s s s直接追加到队列末尾,继续读取数据。

优点: 每个批次的L维取决于数据的最大长度,而不再依赖一个固定值,极大降低了Padding数量;
缺点: 训练数据一般是经过shuffle的,因而顺序Batch采样下如果同一批次的长度差异较大,依然存在Padding冗余问题。

Batch采样改进方法—长度分桶机制

为解决冗余的Padding为训练效率带来的影响,需要改进Batch采样机制,确保同一Batch内数据的长度分布接近些。一个自然的方法就是修改Batch采样策略,每次打包批次时使用长度一致的序列即可,这样有效地将Padding数量降低至0.

但这种方法意味着要维护一个Seq Length大小的桶列表,由此带来的问题是:数据存储较为离散,桶内元素容易累积,给内存带来压力。因此本节介绍的一种新的Batch采样策略—长度分桶采样,这个算法严格意义上不是我原创的,而是跟别人交流时获得的启发。长度分桶采样在Padding压缩和空间效率上取了个折中,既不会因为样本积压造成内存耗费过大,又能够降低数据批次里Padding的密度,增加有效token占比,从而大幅提升训练效率。算法描述如下:

输入:数据集D,序列最大长度l,区间i,计划训练步数total_step, 批量大小Batch_Size
输出:None
s ← 0
buckets ← [[] for _ in range(l//i + 1)]  # 建立(l//i + 1)个空列表,每个列表代表一个桶。这里直接用python语法实现。
bucket_len ← [0] * (l//i + 1)  #为每个桶记录长度(列表长度)。
D_reader ← iter(D)  # 这里建立一个迭代器,方便后面执行一次读取一条数据的模式,实际场景可以使用其它方案。
while s<total_step do
	item ← next(D_reader)
	item_tok ← Tokenize(item)   # Tokenize()对样本执行分词id转换,并做其它后处理(eos等)。
	item_len ← len(item_tok)
	if item_len > l then    # 这里判断分词后是否超出长度限制,如果超出则跳过。
		continue
	end if
	bucket_id ← (item_len - 1) // i
	append(buckets[bucket_id], item_tok)   # 根据bucket_id映射到对应的桶编号,将样本加入
	bucket_len[bucket_id] ← bucket_len[bucket_id] + 1
	if (bucket_len[bucket_id]+1) * (bucket_id + 1) * 8 > Batch_Size then  # 判断当前bucket_id中的token规模是否满足Batch_Size要求,token规模指当前桶的token存量的上界,这个上界等于能被放置在该桶里的最大序列长度,用(bucket_id + 1) * 8即可得到。此外为防止溢出采用“预判”思想,即在当前桶长度基础上加1再判断。
		pack(buckets[bucket_id])   # 对一个完整批次的数据打包、并发送到模型,然后清理桶、归零长度计数器,准备处理下一条样本
		clear(buckets[bucket_id])
		bucket_len[bucket_id] = 0
	end if
	s ← s+1
end while

算法文字描述如下:

  1. 设置一个区间参数 i i i,每次读取新样本时,用其分词后的长度除以 i i i获得长度粗分类,也就是桶编号;
  2. 根据桶编号将样本添加到相应桶中;
  3. 判断当前桶的样本token量上界是否满足Batch_Size,如果满足了则发出,否则继续读取样本。

这种方法能够进一步减轻Batch内长度差异大带来的Padding冗余,提升了有效token的紧凑性,从而加速数据处理,使训练时的Epoch周期更短。当然这种方法也会存在以下问题:

  1. 数据采样不均衡。首先是长度读取频率的不均衡,较长序列会处理得更加频繁,而较短序列的采样频率低,使模型得不到有效学习;同时也存在分布的不均衡(长句的采样全集中在了短句前面)。对上述的解释举例如下:数据中有a、b两个样本,序列 a a a分词后长度为20,序列 b b b分词后长度为200,Batch Size设置为2000,则序列 b b b读取十次就可以组成完整Batch发送,而 a a a需要读取100次。因此 a a a的处理频率是 b b b的10%,由此造成长短序列采样频率不均衡;分布的不均衡是指 a a a的采样发生在了全部10次 b b b采样的后面,而不是中间某个位置;

    不过实际应用时这写问题带来的影响并不大,原因如下:短样本一般在质量上不如中等长度或长样本(普遍存在半句话,或者单个词或短语等),因此较低的频率从某种意义上也是减轻噪声的措施;而对于中等长度或长样本,其往往是采集到的完整句子或篇章,质量较高,因此应用更多的迭代步数能够使模型正确收敛,不易陷入局部最优点;对于分布的不均衡问题,当数据规模较大时(1M以上)影响也会变小。

  2. Epoch的计算不明确。一般模型训练任务的一个Epoch即将训练集完整迭代一遍的周期。而分桶策略下即使指针遍历到整个数据集末尾,桶内可能还会有未处理的数据,因此严格意义上此时不能算作一个Epoch。但是在数据规模较大(1M以上)时,以指针寻至末尾为当前Epoch结束条件也不会带来太大误差(实验表明按正常随机打乱的数据分布,一般指针结束后桶内剩余2-5k样本)。

实现部分

那么分桶机制该如何在训练脚本中实现呢?我们知道torch的DataLoader类定义了数据的载入模式,而Dataset则开放了数据采样的策略定制,我们可以在Dataset中定义分桶机制的数据处理算法,然后传送给DataLoader后设置batch_size参数为1即可。注意:这里设置batch_size为1并不等同于真正采样到的Batch大小是1,而是设置DataLoader对Dataset只做一次迭代就获得输入,此时输入中其实已经包含了整个批次的数据。

因此只要将分桶机制嵌入到Dataset,就能实现在DataLoader中的迭代。此外由于定制了特殊采样策略,DistributedSampler也无法使用。

给出torch实现代码如下:

from torch.utils.data import Dataset, DataLoader
...

class myDataset(Dataset):
	def __init__(self, input_lines, batch_size):
		super().__init__()
		self.data = input_lines
		self.input_len = len(self.data)
		self.batch_size = batch_size
		self.i = 0
		self.buckets = [[] for _ in range(64)]  # 这里假设最大长为512,区间为8,则计算桶数量为64
		self.bucket_len = [0] * 64
		
	def next(self):
		while True:
			line = self.data[self.i]
			self.i = (self.i + 1%self.input_len
			item_tok ← Tokenize(line)   # 分词器按使用环境而定,这里用Tokenize()表示
			if len(item_tok) > 512:
				continue
			bucket_id = (len(item_tok) - 1) // 8
			self.buckets[bucket_id].append(item_tok)
			self.bucket_len[bucket_id] += 1
			if (self.bucket_len[bucket_id]+1) * (bucket_id + 1) * 8 > self.batch_size :
				input_ids =  pack(buckets[bucket_id])    # 填充Padding、转换为张量等操作
				buckets[bucket_id].clear()
				bucket_len[bucket_id] = 0
				return input_ids
			
	@staticmethod    # 这里定义静态方法用于输出前对批数据进行处理,之前讲过DataLoader一次调用是返回一个Batch的数据,输出前加了一层列表嵌套;而我们在Dataset中使用采样策略,外层的DataLoader只需调用一次即可,因此嵌套的一层时多余的。这里通过索引到第一个位置,将输出还原为B * L形式。
	def collate_fn(input_ids):
		return input_ids[0]

batch_size = ...
input_lines = ... # 读取数据
mydataset = myDataset(input_lines, batch_size)
myloader = DataLoader(mydataset, collate_fn = mydataset.collate_fn)   # 将定义的mydataset映射到DataLoader中,并注册collate_fn

for input_ids in my_loader:
	... # 前反向、更新参数、验证等代码
	

多卡环境

到目前为止,以上实现是基于单卡环境下的分桶机制,那么对于多卡该怎么配置呢?我们都知道torch的分布式训练一般通过torch.multiprocessing和torch.distributed实现多线程操作,相比单线程会多出World_Size和Rank,分别代表总GPU数和当前GPU编号。由于我们无法使用DistributedSampler,因此只能通过配置Dataset中的行为,来手动实现数据的多卡合理分配。方案如下:

将原本桶内token规模的判别条件改为如下:

# 单卡时: if (self.bucket_len[bucket_id]+1) * (bucket_id + 1) * 8 > self.batch_size
if len(self.bucket_len[bucket_id]) >= self.batch_size  // ((bucket_id + 1) * 8) * self.world_size:  #batch_size以单卡度量

说明:多卡环境下需要保证每次采样时,所有线程的读取结果、桶内容都是相同状态,这样数据才能合理分配到不同卡上,数据并行也能有效执行。因此采样时所有线程均需读取能够满足全部线程的数据量,再使用多卡数据分发方法(取i%world_size==rank的位置i样本),而对于数据量的判别条件也不再以token规模衡量,而是以 单一GPU的Batch_Size对当前桶的长度上界能承载的最大序列数 作为临界值,当然这只是单卡的数量,最终还需乘上World_Size.

有了上述思路,多卡模式的实现代码修改如下:

from torch.utils.data import Dataset, DataLoader
...

class myDataset(Dataset):
	def __init__(self, input_lines, batch_size, rank, world_size):
		super().__init__()
		self.data = input_lines
		self.input_len = len(self.data)
		self.batch_size = batch_size
		self.i = 0
		self.buckets = [[] for _ in range(64)]  # 这里假设最大长为512,区间为8,则计算桶数量为64
		self.bucket_len = [0] * 64
		self.rank = rank
		self.world_size = world_size
		
	def next(self):
		while True:
			line = self.data[self.i]
			self.i = (self.i + 1%self.input_len
			item_tok ← Tokenize(line)   # 分词器按使用环境而定,这里用Tokenize()表示
			if len(item_tok) > 512:
				continue
			bucket_id = (len(item_tok) - 1) // 8
			self.buckets[bucket_id].append(item_tok)
			self.bucket_len[bucket_id] += 1
			
			if len(self.bucket_len[bucket_id]) >= self.batch_size  // ((bucket_id + 1) * 8) * self.world_size:
				input_ids_list = []
				for idx, item in enumerate(buckets[bucket_id]):
					if idx % self.world_size == self.rank:
						input_ids_list.append(item)
						input_ids =  pack(input_ids_list)
				buckets[bucket_id].clear()
				bucket_len[bucket_id] = 0
				return input_ids
			
	@staticmethod    # 这里定义静态方法用于输出前对批数据进行处理,之前讲过DataLoader一次调用是返回一个Batch的数据,输出前加了一层列表嵌套;而我们在Dataset中使用采样策略,外层的DataLoader只需调用一次即可,因此嵌套的一层时多余的。这里通过索引到第一个位置,将输出还原为B * L形式。
	def collate_fn(input_ids):
		return input_ids[0]

batch_size = ...
input_lines = ... # 读取数据
mydataset = myDataset(input_lines, batch_size)
myloader = DataLoader(mydataset, collate_fn = mydataset.collate_fn)   # 将定义的mydataset映射到DataLoader中,并注册collate_fn

for input_ids in my_loader:
	... # 前反向、更新参数、验证等代码
	

实验效果

为验证方法效果,我们展开两组回译任务的对比实验,均使用1100w英语单语料和同样的英汉模型,不同的是一组使用分桶机制采样(max_length为512,区间为8),而另一组使用顺序采样方式,对比两者的全部数据回译所需时间。结果如下:

方案用时(h)Batch_Size
w/o bucket125128(样本数)
w bucket125000(Token数)

由上述结果看出,分桶机制采样能够极大地提升数据处理的速度,回译任务的耗时相比顺序采样只有10%.
模型效果方面,训练时使用分桶机制采样相比顺序采样,在业务测试集上英中翻译的BLEU-4值也提升2个点不等(33.2→35.1)。

相关资源

  1. https://zhuanlan.zhihu.com/p/562073669
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值