薛定谔的准确率:PyTorch随机数引发的可复现性陷阱

2022 年 6 月 25 日 极市平台
↑ 点击 蓝字  关注极市平台

作者丨清川@知乎(已授权)
来源丨https://zhuanlan.zhihu.com/p/531766476
编辑丨极市平台

极市导读

 

本文主要讨论PyTorch模型训练中的两种可复现性:一种是在完全不改动代码的情况下重复运行,获得相同的准确率曲线;另一种是改动有限的代码,改动部分不影响训练过程的前提下,获得相同的曲线。 >>加入极市CV技术交流群,走在计算机视觉的最前沿

1 第一种情况,浅显地讲,我们只需要固定所有随机数种子就行。

我们知道,计算机一般会使用混合线性同余法来生成伪随机数序列。在我们每次调用rand()函数时,就会执行一次或若干次下面的递推公式:

满足一定条件时,可以近似地认为 序列中的每一项符合均匀分布,通过 我们可以得到0到1之间的随机数。这类算法都有一个特点,就是一旦固定了序列的初始值 ,整个随机数序列也就固定了,这个初始值就被我们称作种子。也就是说,我们在程序的起始位置设定好随机数种子,程序单次执行中第 次调用到rand()得到的数值将会是固定的,一旦程序中rand()函数的调用顺序固定,无论程序重复运行多少遍,结果都将是稳定的。在Minecraft中我们可以通过特定的种子生成一模一样的世界就是这个原理。

在深度学习中,我们常用Dropout减轻过拟合现象,在训练时会随机抑制一定比例的神经元(将激活值设定为零);常用RandomFlip、RandomCrop等方法处理训练集,引入一些随机噪声来提高模型泛化能力;常用shuffle的方式从训练集中随机抽取batch,一方面可以稳定训练,一方面也可以减轻过拟合。这些方法都引入了训练的随机性。我们在炼丹调参的时候肯定希望特定的超参数对应固定的性能,否则就不能肯定模型效果是超参数带来的还是随机性带来的了。

在PyTorch中我们一般使用如下方法固定随机数种子。这个函数的调用尽量放在所有import之后,其他代码之前。

def seed_everything(seed):
    torch.manual_seed(seed)       # Current CPU
    torch.cuda.manual_seed(seed)  # Current GPU
    np.random.seed(seed)          # Numpy module
    random.seed(seed)             # Python random module
    torch.backends.cudnn.benchmark = False    # Close optimization
    torch.backends.cudnn.deterministic = True # Close optimization
    torch.cuda.manual_seed_all(seed) # All GPU (Optional)

有些工具库中已经给出了类似的函数,但效果需要自己实验确定,比如pytorch_lightning.seed_everything中就没有去除cudnn对于卷积操作的优化,很多情况下仍然无法复现。建议使用上面给出的代码,至少在我的实验中一直是可以实现稳定复现的。

2 第二种情况,总的来说,一定要万分确定改动的代码没有影响random()的调用顺序。

重复运行的可复现性早有讨论,但修改代码的可复现性其实是更大的陷阱。如果你觉得,这么简单的问题会有人犯错吗?连自己的代码有没有影响训练都不知道吗?我们看如下问题:在固定随机数种子的前提下,你写了一个训练模型的代码,输出了训练的loss和准确率并绘制了图像。突然你想在每轮训练之后再测一下测试准确率,于是小心翼翼地修改了代码,那么问题来了,训练的loss和准确率会和之前一样吗?

如果你没有加入额外的操作,答案是一定会不一样!我最近的实验中就发现,模型测试的次数会很明显地影响准确率本身,测的次数不一样,准确率也不一样,有时候训练结束的效果甚至会波动1%这么大。我实验中要验证的算法是两个模型协同训练的,其中一个模型应该与Baseline性能曲线完全相同,现在实验结果却差了1%,尴尬了!

海森堡测不准原理

首先排除其他因素,比如我们在测试时确定使用了model.eval(),避免了前向传播时Dropout层起作用,也避免了BatchNorm层对数据的均值方差进行滑动平均,可以认为我们避免了一切直接影响模型参数的操作。那究竟是什么在作祟?

首先要清楚我提到的固定随机数种子对可复现性起作用的前提:rand()函数调用的次序固定。也就是说,假如在某次rand()调用之前我们插入了其他的rand()操作,那这次的结果必然不同。

>>> import torch
>>> from utils import seed_everything

>>> seed_everything(0)
>>> torch.rand(5)
tensor([0.49630.76820.08850.13200.3074])

>>> seed_everything(0)
>>> _ = torch.rand(1)
>>> torch.rand(5)
tensor([0.76820.08850.13200.30740.6341])

我们再反思一下,模型测试中唯一不敢确定的就是DataLoader了。按照常规设置,训练时一般使用带shuffle的DataLoader,而测试时使用不带shuffle的,那既然不带shuffle,为啥还是会出错?我们写一个最小样例复现一下这个问题:

import torch
from torch.utils.data import TensorDataset, DataLoader
from utils import seed_everything

seed_everything(0)
dataset = TensorDataset(torch.rand((103)), torch.rand(10))
dataloader = DataLoader(dataset, shuffle=False, batch_size=2)
print(torch.rand(5))
# tensor([0.5263, 0.2437, 0.5846, 0.0332, 0.1387])

seed_everything(0)
dataset = TensorDataset(torch.rand((103)), torch.rand(10))
dataloader = DataLoader(dataset, shuffle=False, batch_size=2)
for inputs, labels in dataloader:
    pass
print(torch.rand(5))
tensor([0.58460.03320.13870.24220.8155])

然后研读一下Pytorch中DataLoader的源码就会发现问题所在。

Python的in操作符会先调用后面的迭代器中的__iter__魔法函数。每次遍历数据集时,DataLoader的__iter__()都会返回一个新的生成器,无论上次遍历是否中途break,它都会重新从头开始。这个生成器底层有一个_index_sampler,shuffle设置为真时它使用BatchSampler(RandomSampler),随机抽取batchsize个数据索引,如果为假则使用BatchSampler(SequentialSampler)顺序抽取。

上面所说的生成器的基类叫做_BaseDataLoaderIter,在它的初始化函数中唯一调用了一次随机数函数,用以确定全局随机数种子。

class _BaseDataLoaderIter(object):
    def __init__(self, loader: DataLoader) -> None:
        ...
        self._base_seed = torch.empty((), dtype=torch.int64).random_(generator=loader.generator).item()
        ...

这里的_base_seed将会是一个长整型标量随机数。这个种子会在哪里使用呢?目前只在其子类_MultiProcessingDataLoaderIter中使用。当我们将DataLoader的worker数量设置为大于0时,将使用多进程的方式加载数据。在这个子类的初始化函数中会新建n个进程,然后将_base_seed作为进程参数传入:

...
w = multiprocessing_context.Process(
    target=_utils.worker._worker_loop,
    args=(self._dataset_kind, self._dataset, index_queue,
          self._worker_result_queue, self._workers_done_event,
          self._auto_collation, self._collate_fn, self._drop_last,
          self._base_seed, self._worker_init_fn, i, self._num_workers,
          self._persistent_workers))
w.daemon = True
w.start()
...

worker进程内部实际使用到这个种子的地方如下

def _worker_loop(dataset_kind, dataset, index_queue, data_queue, done_event,
                 auto_collation, collate_fn, drop_last, base_seed, init_fn, worker_id,
                 num_workers, persistent_workers)
:

    ...
    seed = base_seed + worker_id
    random.seed(seed)
    torch.manual_seed(seed)
    if HAS_NUMPY:
        np_seed = _generate_state(base_seed, worker_id)
        import numpy as np
        np.random.seed(np_seed)
    ...

这些操作将会在init_fn之前,控制每个进程起始的随机数种子。但据我观察这些操作已经在RandomSampler初始化之后了,所以不知道它们是怎么解决serendipity:可能95%的人还在犯的PyTorch错误https://zhuanlan.zhihu.com/p/523239005)这篇文章提到的低版本PyTorch中DataLoader随机序列重复的问题的。但这些不是重点,按照PyTorch向后兼容的设计理念,这里无论谁继承_BaseDataLoaderIter这个基类,无论子类是否用到_base_seed这个种子,随机数函数都是会被调用的。调用关系梳理如下:

for inputs, labels in DataLoader(...):
    pass
# in操作符会调用如下
DataLoader()
    DataLoader.self.__iter__()
        DataLoader.self._get_iterator()
            _MultiProcessingDataLoaderIter(DataLoader.self)
                _BaseDataLoaderIter(DataLoader.self)
                    _BaseDataLoaderIter.self._base_seed = torch.empty(
                        (), dtype=torch.int64).random_(generator=DataLoader.generator).item()
# 一般来说generator是None,我们不指定,random_没有from和to时,会取数据类型最大范围,这里相当于随机生成一个大整数

那么如何解决呢?我尝试过使用DataLoader的generator参数去指定一个随机数序列,但发现这样只会屏蔽遍历数据操作以外的随机数调用的影响。也就是说,这种情况下,只要调用DataLoader的次数变化,还是无法复现。那么最简单有效的方法就是在每次DataLoader的in操作调用之前都固定一下随机数种子。

def stable(dataloader, seed):
    seed_everything(seed)
    return dataloader

for inputs, labels in stable(DataLoader(...), seed):
    pass

这里需要格外注意的是,stable函数会使训练时每个epoch内部的shuffle规律相同! 之前我们提到shuffle训练集可以减轻模型过拟合,是至关重要的,当每个epoch内部第i个batch的内容都对应相同时,模型会训不起来。所以,一个简单的技巧,在传入随机数种子的时候加上一个epoch序号。

for epoch in range(MAX_EPOCH):  # training
    for inputs, labels in stable(DataLoader(...), seed + epoch):
        pass

这时随机数种子的设定和in操作绑定成了类似的原子操作,所有涉及到random()调用的新增代码都不会影响到准确率曲线的复现了。

3 本文未讨论的其他随机性

按照本文所说的方法就一定能实现可复现性了吗?不一定。因为随机性还体现在方方面面:比如超参数,当我们改变DataLoader的worker数量时,显然会引入随机性;比如系统配置,同样的代码在不同架构和精度的CPU、GPU上运行,底层优化或者截断误差都可能带来随机性。在我之前做硬件工作的时候,电池电量不同都可能导致同一个程序在同一块板子上跑出完全不同的结果。可复现性其实是学术界广泛关注的一个专门的研究领域,本文只是为日常模型训练提供一些直观的技巧。

公众号后台回复“项目实践”获取50+CV项目实践机会~

△点击卡片关注极市平台,获取 最新CV干货
极市干货
最新数据集资源: 医学图像开源数据集汇总
实操教程 Pytorch - 弹性训练原理分析《CUDA C 编程指南》导读
极视角动态: 极视角作为重点项目入选「2022青岛十大资本青睐企业」榜单! 极视角发布EQP激励计划,招募优质算法团队展开多维度生态合作!


点击阅读原文进入CV社区

收获更多技术干货

登录查看更多
0

相关内容

专知会员服务
28+阅读 · 2020年10月24日
《深度学习》圣经花书的数学推导、原理与Python代码实现
【模型泛化教程】标签平滑与Keras, TensorFlow,和深度学习
专知会员服务
20+阅读 · 2019年12月31日
开源书:PyTorch深度学习起步
专知会员服务
50+阅读 · 2019年10月11日
Pytorch里面多任务Loss是加起来还是分别backward?
极市平台
0+阅读 · 2022年6月29日
实操教程|用Pytorch训练神经网络
极市平台
0+阅读 · 2022年4月22日
pytorch提取参数及自定义初始化
极市平台
0+阅读 · 2022年4月13日
实操教程|Pytorch常用损失函数拆解
极市平台
3+阅读 · 2022年1月6日
pytorch学习 | 提取参数及自定义初始化
极市平台
0+阅读 · 2021年12月21日
实践教程 | 浅谈 PyTorch 中的 tensor 及使用
极市平台
1+阅读 · 2021年12月14日
pytorch中六种常用的向量相似度评估方法
极市平台
22+阅读 · 2021年12月9日
PyTorch 深度剖析:如何保存和加载PyTorch模型?
极市平台
0+阅读 · 2021年11月28日
基于Pytorch的动态卷积复现
极市平台
2+阅读 · 2021年11月7日
深度学习Pytorch框架Tensor张量
极市平台
0+阅读 · 2021年11月1日
国家自然科学基金
0+阅读 · 2015年12月31日
国家自然科学基金
2+阅读 · 2015年12月31日
国家自然科学基金
0+阅读 · 2015年12月31日
国家自然科学基金
2+阅读 · 2015年12月31日
国家自然科学基金
2+阅读 · 2014年12月31日
国家自然科学基金
0+阅读 · 2012年12月31日
国家自然科学基金
0+阅读 · 2012年12月31日
国家自然科学基金
0+阅读 · 2012年12月31日
国家自然科学基金
1+阅读 · 2012年12月31日
国家自然科学基金
0+阅读 · 2011年12月31日
Arxiv
0+阅读 · 2022年8月27日
VIP会员
相关资讯
Pytorch里面多任务Loss是加起来还是分别backward?
极市平台
0+阅读 · 2022年6月29日
实操教程|用Pytorch训练神经网络
极市平台
0+阅读 · 2022年4月22日
pytorch提取参数及自定义初始化
极市平台
0+阅读 · 2022年4月13日
实操教程|Pytorch常用损失函数拆解
极市平台
3+阅读 · 2022年1月6日
pytorch学习 | 提取参数及自定义初始化
极市平台
0+阅读 · 2021年12月21日
实践教程 | 浅谈 PyTorch 中的 tensor 及使用
极市平台
1+阅读 · 2021年12月14日
pytorch中六种常用的向量相似度评估方法
极市平台
22+阅读 · 2021年12月9日
PyTorch 深度剖析:如何保存和加载PyTorch模型?
极市平台
0+阅读 · 2021年11月28日
基于Pytorch的动态卷积复现
极市平台
2+阅读 · 2021年11月7日
深度学习Pytorch框架Tensor张量
极市平台
0+阅读 · 2021年11月1日
相关基金
国家自然科学基金
0+阅读 · 2015年12月31日
国家自然科学基金
2+阅读 · 2015年12月31日
国家自然科学基金
0+阅读 · 2015年12月31日
国家自然科学基金
2+阅读 · 2015年12月31日
国家自然科学基金
2+阅读 · 2014年12月31日
国家自然科学基金
0+阅读 · 2012年12月31日
国家自然科学基金
0+阅读 · 2012年12月31日
国家自然科学基金
0+阅读 · 2012年12月31日
国家自然科学基金
1+阅读 · 2012年12月31日
国家自然科学基金
0+阅读 · 2011年12月31日
Top
微信扫码咨询专知VIP会员