原文来自:https://tanelp.github.io/posts/a-bug-that-plagues-thousands-of-open-source-ml-projects/
原文发布时间:
在Pytorch中Numpy的随机数生成器与多进程数据集加载一起使用会对导致同样的数据增强,除非在worker_init_fn选项中设置特定的seed。在作者的项目中,这个bug可能会默默地退化模型的准确率。
这个bug会不会对其他项目造成损害呢?很好奇,作者从GitHub下载了十万个导入PyTorch的存储库,并分析了它们的源代码。作者保留了那些使用了自定义数据集,并且将NumPy的随机数生成器与多进程数据加载配合使用,或者或多或少使用抽象语法树进行分析的项目。其中,超过95%的项目受此问题困扰。该问题存在于PyTorch的官方教程,OpenAI的代码和NVIDIA的项目中,甚至特斯拉 AI 负责人Karpathy也承认自己遇到过该问题。
The Bug
在 PyTorch 中加载、预处理和扩充数据的规范方法是基于 torch.utils.data.Dataset 父类构造新的子类并重写 __getitem__方法。为了应用诸如随机裁剪和图像翻转之类的数据增强方法,__getitem__方法经常使用 NumPy 来生成随机数,然后将该子类数据集传递给 DataLoader 来创建 batch数据。这种训练 pipeline 可能会受到数据预处理的阻碍,因此并行加载和处理数据势在必行,实现并行处理数据可以通过设置 DataLoader 对象中的 num_workers 参数来实现。
现在问题是,这个工作方式导致了相同的数据增强方法。
一个简单的例子
为了使问题更具体,这是一个示例数据集,该数据集返回三元素随机向量。我们使用两个和四个工作进程的批处理大小。
import numpy as np
from torch.utils.data import Dataset, DataLoader
class RandomDataset(Dataset):
def __getitem__(self, index):
return np.random.randint(0, 1000, 3)
def __len__(self):
return 16
dataset = RandomDataset()
dataloader = DataLoader(dataset, batch_size=2, num_workers=4)
for batch in dataloader:
print(batch)
结果如下:
tensor([[116, 760, 679], # 1st batch, returned by process 0
[754, 897, 764]])
tensor([[116, 760, 679], # 2nd batch, returned by process 1
[754, 897, 764]])
tensor([[116, 760, 679], # 3rd batch, returned by process 2
[754, 897, 764]])
tensor([[116, 760, 679], # 4th batch, returned by process 3
[754, 897, 764]])
tensor([[866, 919, 441], # 5th batch, returned by process 0
[ 20, 727, 680]])
tensor([[866, 919, 441], # 6th batch, returned by process 1
[ 20, 727, 680]])
tensor([[866, 919, 441], # 7th batch, returned by process 2
[ 20, 727, 680]])
tensor([[866, 919, 441], # 8th batch, returned by process 3
[ 20, 727, 680]])
每个进程返回的随机数是相同的!
来自官方教程的例子
本教程演示了如何在人脸地标数据集上使用Dataset
和DataLoader
类。它还提到了数据增强的重要性,并提供了随机数据增强的示例。这是使用NumPy的随机数生成器实现的。
top = np.random.randint(0, h - new_h)
left = np.random.randint(0, w - new_w)
遵循通过增加num_workers来加快数据处理速度的提示,他们获得了数据裁剪:
(batch size 8, num_workers 2, random crop augmentation)
来自OpenAI的EBM模型的例子
在基于能量模型的隐式生成和建模中,基于能量的模型用于图像的生成。数据集的__getitem__ 方法从磁盘读取图像和标签,扭曲前者,然后返回所有三个:
if FLAGS.datasource == 'default':
im_corrupt = im + 0.3 * np.random.randn(image_size, image_size)
elif FLAGS.datasource == 'random':
im_corrupt = 0.5 + 0.5 * np.random.randn(image_size, image_size)
return im_corrupt, im, label
但是,这些扭曲是相同的:
(batch size 8, num_workers 2, random crop augmentation)
为什么会造成这种现象?
PyTorch 使用多进程并行加载数据,worker 进程是使用 fork start 方法创建的。这意味着每个工作进程继承父进程的所有资源,包括 NumPy 的随机数生成器的状态。
解决方法
该DataLoader
构造函数有一个可选的worker_init_fn
参数。该函数在工作进程的所有数据加载,初始化之前被调用,因此可以在worker_init_fn
中为NumPy设置种子,例如:
def worker_init_fn(worker_id):
np.random.seed(np.random.get_state()[1][0] + worker_id)
dataset = RandomDataset()
dataloader = DataLoader(dataset, batch_size=2, num_workers=4,
worker_init_fn=worker_init_fn)
for batch in dataloader:
print(batch)
该代码为每个批次输出不同的值,就像预期的那样:
tensor([[282, 4, 785],
[ 35, 581, 521]])
tensor([[684, 17, 95],
[774, 794, 420]])
tensor([[939, 988, 37],
[983, 933, 821]])
tensor([[832, 50, 453],
[ 37, 322, 981]])
tensor([[180, 413, 50],
[894, 318, 729]])
tensor([[530, 594, 116],
[636, 468, 264]])
tensor([[142, 88, 429],
[407, 499, 422]])
tensor([[ 69, 965, 760],
[360, 872, 22]])
但是迭代多次时,
for epoch in range(3):
print(f"epoch: {epoch}")
for batch in dataloader:
print(batch)
print("-"*25)
结果:
epoch: 0
tensor([[282, 4, 785],
[ 35, 581, 521]])
tensor([[684, 17, 95],
[774, 794, 420]])
tensor([[939, 988, 37],
[983, 933, 821]])
tensor([[832, 50, 453],
[ 37, 322, 981]])
-------------------------
epoch: 1
tensor([[282, 4, 785],
[ 35, 581, 521]])
tensor([[684, 17, 95],
[774, 794, 420]])
tensor([[939, 988, 37],
[983, 933, 821]])
tensor([[832, 50, 453],
[ 37, 322, 981]])
-------------------------
epoch: 2
tensor([[282, 4, 785],
[ 35, 581, 521]])
tensor([[684, 17, 95],
[774, 794, 420]])
tensor([[939, 988, 37],
[983, 933, 821]])
tensor([[832, 50, 453],
[ 37, 322, 981]])
-------------------------
对数据集进行三次迭代,在每次迭代epoch时产生相同的随机数。发生这种情况是因为对随机状态的所有更改对于每个worker进程来说都是本地的。默认情况下,工作进程在每个epoch结束时被杀死,所有进程资源都将丢失。同时,主进程中的随机状态并没有改变,它用于再次初始化每个工作子进程。
因此,需要在每个epoch更改NumPy的随机种子,例如通过np.random.seed(initial_seed + epoch)
。
此外,如果使用PyTorch(例如torch.randint)或Python内置的随机数生成器对随机数进行采样,则不会出现这些问题。PyTorch通过将上述种子设置为seed + worker_id自动来处理这些问题。
其他讨论
这位用户描述的 bug 也引起了众多网友的热议,其中一些人并不认为这是 bug。用户「amasterblaster」认为,这不是一个 bug,而是所有种子随机函数的预期功能。这是因为即使在随机实验中,有时你想要对比静态参数的变化,并得到相同的随机数。只有当你被读为真随机(true random)时,才会根据 OS time 设置 seed。
用户「xicor7017」表示自己也遇到了相同的问题,也认为它并不是一个 bug,而是一个可能不为人所知的特征。如果忽略它的话,调试问题时会很麻烦。
与此同时,另一些人表达出了不同的观点,认为既然「如果事情朝着人们不希望的方向发展,那么它就不应该这样,也就构成了 bug。
用户「IntelArtiGen」称自己意识到了这个 bug,认为它是不正常的,并且对自己的项目造成了一些小问题。用户「gwern」赞同这种观点,认为如果 95% 以上的用户使用时出现错误,则代码就是错的。
用户「synonymous1964」进一步解读了这个 bug。ta 认为,人们可能误解了这个问题,问题不在于设置特定的随机种子会导致每次训练过程中生成相同序列的随机数,这显然是按预期工作的。相反,问题在于多个数据下载进程中(由 PyTorch 中的 num_workers 设置)的每个进程都会在某个特定的训练过程中输出相同序列的随机数。毫无疑问,这当然会对项目造成影响,具体取决于你如何进行数据加载和扩充。所以,即使这个 bug 是「按预期工作的」,但向更多其他用户指出来也挺好的。