0%

神经网络中的数据问题

今日在写程序时,遇到了一个蜜汁 bug,加载别人训练好的 ResNet18,识别精度很低,只有 16%,但理论上而言应该有 92%,我也好奇那 80% 的准确率去哪里了。而程序和数据本身又无错误,所以来探究一下这是为什么。

首先,网络结构和预训练的模型来自这里 1,这里声明一下,他提供的网络、参数都是没任何问题的,准确率低是我自己的原因。

错误程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
import torch
import numpy as np
from resnet18 import ResNet18
from torch.utils.data import Dataset, DataLoader
from torch.autograd import Variable
from torchvision import transforms


class testdataset(Dataset):
def __init__(self, data_path, label_path):
# 模型是预训练好的,取后面 10000 个做测试
self.x_data = np.load(data_path)
# 数据到 [0, 1] 之间
self.x_data = self.x_data / 255
self.x_data = self.x_data[50000:]
self.y_data = np.load(label_path)
self.y_data = self.y_data[50000:]

def __getitem__(self, index):
x_ = self.x_data[index]
x_ = x_.transpose(2, 1, 0)
y_ = self.y_data[index]
return torch.from_numpy(x_), torch.from_numpy(y_)

def __len__(self):
return len(self.x_data)


def _error(model, X, y):
out = model(X)
prediction = torch.argmax(out, 1)
prediction = prediction.unsqueeze(1)
correct = (prediction == y).sum().float()
return correct


def _eval(model, device, test_loader):
model.eval()
model.to(device)
natural_err_total = 0

for data, target in test_loader:
data, target = data.to(device,
dtype=torch.float), target.to(device,
dtype=torch.float)
X, y = Variable(data, requires_grad=True), Variable(target)
err_natural = _error(model, X, y)
natural_err_total += err_natural
print('acc: ', natural_err_total / 10000)


if __name__ == "__main__":

# 加载 resnet
resnet = ResNet18()
resnet_path = "resnet18_ckpt.pth"
checkpoint = torch.load(resnet_path, map_location='cpu')
print('loaded model...')

# 这里只是为了对应模型参数
net_state = {}
for key in checkpoint['net']:
net_state[key[7:]] = checkpoint['net'][key]
resnet.load_state_dict(net_state)
print('set model...')

data_path = "cifar10_data.npy"
label_path = "cifar10_label.npy"
test_data = testdataset(data_path=data_path, label_path=label_path)
test_loader = DataLoader(test_data, batch_size=128)
print('load data...')

# 干净样本准确率
print('natural', end=', ')
_eval(model=resnet, device='cpu', test_loader=test_loader)

在这样操作下,准确率只有 16.89% ,我也很奇怪是哪里错了。

正确程序

从师兄那里找到了一份正确的程序,准确率是 92.84%。后文会进行说明,这份代码只是结果对,但逻辑不对。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import torch
from resnet18 import ResNet18
from collections import OrderedDict
from torch.utils.data import Dataset
import numpy as np


class TensorDataset(Dataset):
"""
"""
def __init__(self, dataPath, labelPath):
x = np.load(dataPath)
x = x[50000:] / 255.
x = x.astype("float32")
data = x.transpose(0, 3, 1, 2)
label = np.load(labelPath)[50000:]
label = np.reshape(label, (data.shape[0], ))
data, label = torch.from_numpy(data), torch.from_numpy(label)
self.data_tensor = data
self.target_tensor = label

def __getitem__(self, index):
return self.data_tensor[index], self.target_tensor[index]

def __len__(self):
return self.data_tensor.size(0)


net = ResNet18()
resnet_path = "resnet18_ckpt.pth"
d = torch.load(resnet_path, map_location=torch.device('cpu'))['net']
d = OrderedDict([(k[7:], v) for (k, v) in d.items()])
net.load_state_dict(d)
dataPath = "cifar10_data.npy"
labelPath = "cifar10_label.npy"
dataset = TensorDataset(dataPath, labelPath)
dataloader = torch.utils.data.DataLoader(dataset,
batch_size=64,
shuffle=True,
num_workers=0)

total = 0
correct = 0
for batch_idx, (inputs, targets) in enumerate(dataloader):
outputs = net(inputs)
_, predicted = outputs.max(1)
total += targets.size(0)
correct += predicted.eq(targets).sum().item()
acc = 100. * correct / total
print(acc)

问题分析

两份代码的数据加载、准确率计算都是正确的。经过二分法逐行注释,终于找到了问题所在,来依次分析一下。

第一个问题,因为网络的输入是 channel first,而 numpy 的数据中 channel 位于最后面,所以需要对数据进行转置处理。但是这里有坑。假设,图片之前的维度是 height, width, channel,大小是 32 X 32 X 3

  • 假设此时转置的方法是 np.transpose(2, 0, 1),得到矩阵的维度是 channel, height, width,大小是 3 X 32 X 32,这样输入网络是没有问题的。
  • 假设此时转置的方法是 np.transpose(2, 1, 0),得到矩阵的维度是 channel, width, height,大小是 3 X 32 X 32,这样输入网络不会报错,但准确率会很低,大概只有 50%,究其原因是数据增强所导致的。

第二个问题,是 model.eval() 导致的,如果开启这个,准确率就会很低;如果不开启,准确率又正常,所以来深究一下这是为哈。踩坑无数。首先来回顾下 pytorch 的训练过程:

1
2
3
4
5
6
7
8
9
10
11
for x, y in dataloader:
# 模型输出
y_pre = model(x)
# 计算损失
loss = criterion(y_pre, y)
# 上次计算遗留的梯度清空,准备反向传播
optimizer.zero_grad()
# 计算梯度,不反向传播
loss.backward()
# 更新参数
optimizer.step()

model.eval() 会将 batchnormaldropout 固定住,用训练好的值。否则,一旦测试时数据的 batchsize 变小,就会导致图像失真,模型准确率降低。这可能是问题所在。且在使用 pytorch 训练模型时,一定要注意 traineval 模式的切换。

沿着这个思路,先在一些网站先找到一些可能的解决方案 2 4。这里说可能是因为:任何问题都需要上下文才能知道准确的解决方案,即使描述的是同一个问题,也可能有多种解决方案。里面大概的解决方案如下:

  • batchsize 很小导致的,但我这里 batchsize 已经是 128 了,显然并不是这个原因
  • 许多地方用同一个 bn 层,但我打开训练代码 3 ,里面 bn 层没有共用,所以也不是这个原因
  • 在训练阶段,将模型设置为 model.train() 之前,一定要更新参数,包括 zero_grad,但我看了代码,不是这个问题
  • bn 层的参数 track_running_stats 导致的。那我们来看看这个参数是啥意思,打开原文档 5 ,发现描述的佷晦涩,我没看懂。所以在网上找了找 6 ,意思是,如果这个参数为 Truebn 就会追踪历史数据,以滑动加权平均的方式来更新 $\mu$ 和 $\sigma$。而 track_running_stats 取值为 False,就不会追踪历史数据,只会根据当前的 batch 计算均值和方差。
    • 可能由于数据比较不稳且 batchsize 很小影响了精度,但我打开程序,也不是这个原因。何况 model.eval() 模式下,track_running_stats 取值为 True,使用训练好的模型参数,且 bn 的参数也不会更新。
    • 多说一些,bn 的均值和方差是在 forward 方法中更新的 7 ,而不是在 optimizer.step 中更新。所以处于训练模式,bn 的参数不需要反向传播仍然能更新。这里需要注意。

以上,也就是打开搜索引擎,能看到了解决方案了。我屏蔽了某DN,某园等,以及另外的一些抄袭社区等等,他们的解决方案我看不到。捕获回过头来分析下我看到的解决方案,也能意识到是数据处理部分的问题了。反过来想,训练模式下精度很高,说明 bn 层在动,而 bn 影响的是数据。众所周知,颠覆结果的不是模型,是数据,所以猜测是数据的问题。

打开源程序,我们发现训练阶段对数据做了标准化处理 8;而我们处理测试数据时,只是单纯的除以 255,保证数据在 0 到 1 之间,但这是不对的。那么也大概找到了解决方案,加载测试数据的时候进行标准化,此时模型的精度终于正确了!!!原因居然是:数据没有标准化处理。而 train 模式下精度很高,可能是因为通过 bn 层调整了数据分布。所以正确代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class testdataset(Dataset):
def __init__(self, data_path, label_path):
# 模型是预训练好的,取后面 10000 个做测试
self.x_data = np.load(data_path)
# 数据到 [0, 1] 之间
self.x_data = self.x_data / 255
self.x_data = self.x_data[50000:]
self.y_data = np.load(label_path)
self.y_data = self.y_data[50000:]

mean = np.array([0.4914, 0.4822, 0.4465]).reshape(1, 3)
var = np.array([0.2023, 0.1994, 0.2010]).reshape(1, 3)
self.x_data = (self.x_data - mean) / var

def __getitem__(self, index):
x_ = self.x_data[index]
x_ = x_.transpose(2, 0, 1)
y_ = self.y_data[index]
return torch.from_numpy(x_), torch.from_numpy(y_)

def __len__(self):
return len(self.x_data)

if __name__ == "__main__":
...
net.eval()
...
predict(net, testloader)
...

标准化

既然标准化如此重要,就来回顾下标准化的用途。

  • 第一步,数据归一化,也就是映射到 0 到 1 之间,这是为了防止梯度爆炸以及特征尺度的缩放。
    • 因为误差反向传播求偏导的时候,会作用到原始数据 $x$,如果 $x$ 的取值是 255,梯度瞬间爆炸。
    • 一个特征的变化范围可能是[1000,10000],另一个特征的变化范围可能是[−0.1,0.2],在进行距离有关的计算时,单位的不同会导致计算结果的不同,尺度大的特征会起决定性作用。所以要归一化,消除特征间单位和尺度差异的影响。刚才代码归一化的方式很简单,就是除以 255,当然还有很多其它的方式以及优缺点 9 10

在数据归一化后,可以标准化也可以不标准化,上述代码所用的 Z-Score 标准化的意思就是将数据映射到均值为 0,方差为 1 的分布空间中。

\begin{equation}
x = \frac{x-\mu}{\sigma}
\end{equation}

  • 此类标准化是通过特征的平均值和标准差,将特征缩放成一个标准的正态分布,缩放后均值为0,方差为1。特别适用于数据的最大值和最小值未知,或存在孤立点。
  • 标准化是为了方便数据的下一步处理,而进行的数据缩放等变换,不同于归一化,并不是为了方便与其他数据一同处理或比较。归一化是为了消除纲量压缩到 [0, 1] 区间;标准化只是调整特征整体的分布,也就是平移到原点附近。且,归一化与最大,最小值有关;标准化与均值,标准差有关。
  • 估算均值与方差需要总体的平均值与方差,但是这一值在真实的分析与挖掘中很难得到,大多数情况下是用样本的均值与标准差替代,所以一般要求数据符合正态分布。

因此,也很容易定位到问题所在。训练的时候归一化处理的数据,而预测的时候没有,数据在两种不同的分布空间,所以导致预测精度降低。本文记录了全部 debug 的过程,学到了好多东西(误

reference


  1. 1.https://github.com/laisimiao/classification-cifar10-pytorch
  2. 2.https://www.zhihu.com/question/354742972
  3. 3.https://github.com/laisimiao/classification-cifar10-pytorch/blob/master/models/resnet.py#L60-L64
  4. 4.https://discuss.pytorch.org/t/performance-highly-degraded-when-eval-is-activated-in-the-test-phase/3323
  5. 5.https://pytorch.org/docs/stable/generated/torch.nn.BatchNorm2d.html
  6. 6.https://www.zhihu.com/question/282672547
  7. 7.https://pytorch.org/docs/stable/_modules/torch/nn/modules/batchnorm.html#BatchNorm2d
  8. 8.https://github.com/laisimiao/classification-cifar10-pytorch/blob/master/main.py#L38-L41
  9. 9.https://ssjcoding.github.io/2019/03/27/normalization-and-standardization/
感谢上学期间打赏我的朋友们。赛博乞讨:我,秦始皇,打钱。

欢迎订阅我的文章