prog-pt-dl-merge-2

144 阅读59分钟

PyTorch 深度学习编程(三)

原文:Programming Pytorch for Deep Learning

译者:飞龙

协议:CC BY-NC-SA 4.0

第六章:声音之旅

深度学习最成功的应用之一是我们每天随身携带的东西。无论是 Siri 还是 Google Now,驱动这两个系统以及亚马逊的 Alexa 的引擎都是神经网络。在本章中,我们将看一下 PyTorch 的torchaudio库。您将学习如何使用它来构建一个用于分类音频数据的基于卷积的模型的流水线。之后,我将建议一种不同的方法,让您可以使用一些您学到的图像技巧,并在 ESC-50 音频数据集上获得良好的准确性。

但首先,让我们看看声音本身。它是什么?它通常以数据形式表示,这是否为我们提供了任何线索,告诉我们应该使用什么类型的神经网络来从数据中获得洞察?

声音

声音是通过空气的振动产生的。我们听到的所有声音都是高低压力的组合,我们通常用波形来表示,就像图 6-1 中的那个。在这个图像中,原点上方的波是高压,下方的部分是低压。

正弦波

图 6-1. 正弦波

图 6-2 显示了一首完整歌曲的更复杂的波形。

歌曲波形

图 6-2. 歌曲波形

在数字声音中,我们以每秒多次对这个波形进行采样,传统上是 44,100 次,用于 CD 音质,然后存储每个采样点的波的振幅值。在时间t,我们有一个单一的存储值。这与图像略有不同,图像需要两个值xy来存储值(对于灰度图像)。如果我们在神经网络中使用卷积滤波器,我们需要一个 1D 滤波器,而不是我们用于图像的 2D 滤波器。

现在您对声音有了一些了解,让我们看看我们使用的数据集,这样您就可以更加熟悉它。

ESC-50 数据集

环境声音分类(ESC)数据集是一组现场录音,每个录音长达 5 秒,并分配给 50 个类别之一(例如,狗叫、打鼾、敲门声)。我们将在本章的其余部分中使用此集合,以尝试两种分类音频的方法,并探索使用torchaudio来简化加载和操作音频。

获取数据集

ESC-50 数据集是一组 WAV 文件。您可以通过克隆 Git 存储库来下载它:

git clone https://github.com/karoldvl/ESC-50

或者您可以使用 curl 下载整个存储库:

curl https://github.com/karoldvl/ESC-50/archive/master.zip

所有的 WAV 文件都存储在audio目录中,文件名如下:

1-100032-A-0.wav

我们关心文件名中的最后一个数字,因为这告诉我们这个声音片段被分配到了哪个类别。文件名的其他部分对我们来说并不重要,但大多数与 ESC-50 所绘制的更大的 Freesound 数据集相关(有一个例外,我马上会回来解释)。如果您想了解更多信息,ESC-50 存储库中的README文档会提供更详细的信息。

现在我们已经下载了数据集,让我们看看它包含的一些声音。

在 Jupyter 中播放音频

如果您想真正听到 ESC-50 中的声音,那么您可以使用 Jupyter 内置的音频播放器IPython.display.Audio,而不是将文件加载到标准音乐播放器(如 iTunes)中:

import IPython.display as display
display.Audio('ESC-50/audio/1-100032-A-0.wav')

该函数将读取我们的 WAV 文件和 MP3 文件。您还可以生成张量,将它们转换为 NumPy 数组,并直接播放这些数组。播放ESC-50目录中的一些文件,以了解可用的声音。完成后,我们将更深入地探索数据集。

探索 ESC-50

处理新数据集时,最好在构建模型之前先了解数据的形状。例如,在分类任务中,你会想知道你的数据集是否实际包含了所有可能的类别的示例,并且最好所有类别的数量是相等的。让我们看看 ESC-50 是如何分解的。

注意

如果你的数据集的数据量是不平衡的,一个简单的解决方案是随机复制较小类别的示例,直到你将它们增加到其他类别的数量。虽然这感觉像是虚假的账务,但在实践中它是令人惊讶地有效(而且便宜!)。

我们知道每个文件名中最后一组数字描述了它所属的类别,所以我们需要做的是获取文件列表并计算每个类别的出现次数:

import glob
from collections import Counter

esc50_list = [f.split("-")[-1].replace(".wav","")
        for f in
        glob.glob("ESC-50/audio/*.wav")]
Counter(esc50_list)

首先,我们建立一个 ESC-50 文件名列表。因为我们只关心文件名末尾的类别编号,我们去掉 .wav 扩展名,并在 - 分隔符上分割文件名。最后我们取分割字符串中的最后一个元素。如果你检查 esc50_list,你会得到一堆从 0 到 49 的字符串。我们可以编写更多的代码来构建一个 dict 并为我们计算所有出现的次数,但我懒,所以我使用了一个 Python 的便利函数 Counter,它可以为我们做所有这些。

这是输出!

Counter({'15': 40,
     '22': 40,
     '36': 40,
     '44': 40,
     '23': 40,
     '31': 40,
     '9': 40,
     '13': 40,
     '4': 40,
     '3': 40,
     '27': 40,
     …})

我们有一种罕见的完全平衡的数据集。让我们拿出香槟,安装一些我们很快会需要的库。

SoX 和 LibROSA

torchaudio 进行的大部分音频处理依赖于另外两个软件:SoXLibROSALibROSA 是一个用于音频分析的 Python 库,包括生成梅尔频谱图(你将在本章稍后看到这些),检测节拍,甚至生成音乐。

另一方面,SoX 是一个你可能已经熟悉的程序,如果你多年来一直在使用 Linux 的话。事实上,SoX 是如此古老,以至于它早于 Linux 本身;它的第一个版本是在 1991 年 7 月发布的,而 Linux 的首次亮相是在 1991 年 9 月。我记得在 1997 年使用它将 WAV 文件转换为 MP3 文件在我的第一台 Linux 电脑上。但它仍然很有用!

如果你通过 conda 安装 torchaudio,你可以跳到下一节。如果你使用 pip,你可能需要安装 SoX 本身。对于基于 Red Hat 的系统,输入以下命令:

yum install sox

或者在基于 Debian 的系统上,你将使用以下命令:

apt intall sox

安装 SoX 后,你可以继续获取 torchaudio 本身。

torchaudio

安装 torchaudio 可以通过 condapip 进行:

conda install -c derickl torchaudio
pip install torchaudio

torchvision 相比,torchaudio 类似于 torchtext,因为它并不像 torchvision 那样受到喜爱、维护或文档化。我预计随着 PyTorch 变得更受欢迎,更好的文本和音频处理流程将被创建,这种情况将在不久的将来发生改变。不过,torchaudio 对我们的需求来说已经足够了;我们只需要编写一些自定义的数据加载器(对于音频或文本处理,我们不需要这样做)。

无论如何,torchaudio 的核心在于 load()save()。在本章中,我们只关心 load(),但如果你从输入生成新的音频(例如文本到语音模型),你需要使用 save()load() 接受在 filepath 中指定的文件,并返回音频文件的张量表示和该音频文件的采样率作为一个单独的变量。

我们现在有了从 ESC-50 数据集中加载一个 WAV 文件并将其转换为张量的方法。与我们之前处理文本和图像的工作不同,我们需要写更多的代码才能继续创建和训练模型。我们需要编写一个自定义的 dataset

构建 ESC-50 数据集

我们在第二章中讨论过数据集,但torchvisiontorchtext为我们做了所有繁重的工作,所以我们不必太担心细节。你可能还记得,自定义数据集必须实现两个类方法,__getitem____len__,以便数据加载器可以获取一批张量及其标签,以及数据集中张量的总数。我们还有一个__init__方法用于设置诸如文件路径之类的东西,这些东西将一遍又一遍地使用。

这是我们对 ESC-50 数据集的第一次尝试:

class ESC50(Dataset):

    def __init__(self,path):
        # Get directory listing from path
        files = Path(path).glob('*.wav')
        # Iterate through the listing and create a list of tuples (filename, label)
        self.items = [(f,int(f.name.split("-")[-1]
                    .replace(".wav",""))) for f in files]
        self.length = len(self.items)

    def __getitem__(self, index):
        filename, label = self.items[index]
        audio_tensor, sample_rate = torchaudio.load(filename)
        return audio_tensor, label

    def __len__(self):
        return self.length

类中的大部分工作发生在创建其新实例时。__init__方法接受path参数,找到该路径内的所有 WAV 文件,然后通过使用我们在本章早些时候使用的相同字符串拆分来生成*(filename, label)*元组,以获取该音频样本的标签。当 PyTorch 从数据集请求项目时,我们索引到items列表,使用torchaudio.load使torchaudio加载音频文件,将其转换为张量,然后返回张量和标签。

这就足够让我们开始了。为了进行健全性检查,让我们创建一个ESC50对象并提取第一个项目:

test_esc50 = ESC50(PATH_TO_ESC50)
tensor, label = list(test_esc50)[0]

tensor
tensor([-0.0128, -0.0131, -0.0143,  ...,  0.0000,  0.0000,  0.0000])

tensor.shape
torch.Size([220500])

label
'15'

我们可以使用标准的 PyTorch 构造来构建数据加载器:

example_loader = torch.utils.data.DataLoader(test_esc50, batch_size = 64,
shuffle = True)

但在这之前,我们必须回到我们的数据。您可能还记得,我们应该始终创建训练、验证和测试集。目前,我们只有一个包含所有数据的目录,这对我们的目的来说不好。将数据按 60/20/20 的比例分成训练、验证和测试集应该足够了。现在,我们可以通过随机抽取整个数据集的样本来做到这一点(注意要进行无重复抽样,并确保我们新构建的数据集仍然是平衡的),但是 ESC-50 数据集再次帮助我们省去了很多工作。数据集的编译者将数据分成了五个相等的平衡folds,文件名中的第一个数字表示。我们将1,2,3折作为训练集,4折作为验证集,5折作为测试集。但如果你不想无聊和连续,可以随意混合!将每个折叠移到testtrainvalidation目录中:

mv 1* ../train
mv 2* ../train
mv 3* ../train
mv 4* ../valid
mv 5* ../test

现在我们可以创建各个数据集和加载器:

from pathlib import Path

bs=64
PATH_TO_ESC50 = Path.cwd() / 'esc50'
path =  'test.md'
test

train_esc50 = ESC50(PATH_TO_ESC50 / "train")
valid_esc50 = ESC50(PATH_TO_ESC50 / "valid")
test_esc50  = ESC50(PATH_TO_ESC50 / "test")

train_loader = torch.utils.data.DataLoader(train_esc50, batch_size = bs,
                shuffle = True)
valid_loader = torch.utils.data.DataLoader(valid_esc50, batch_size = bs,
                shuffle = True)
test_loader  = torch.utils.data.DataLoader(test_esc50, batch_size = bs,
                shuffle = True)

我们已经准备好了我们的数据,所以我们现在准备好查看分类模型了。

ESC-50 的 CNN 模型

对于我们第一次尝试分类声音,我们构建了一个模型,它大量借鉴了一篇名为“用于原始波形的非常深度卷积网络”的论文。² 您会发现它使用了我们在第三章中的许多构建模块,但是我们使用的是 1D 变体,而不是 2D 层,因为我们的音频输入少了一个维度:

class AudioNet(nn.Module):
    def __init__(self):
        super(AudioNet, self).__init__()
        self.conv1 = nn.Conv1d(1, 128, 80, 4)
        self.bn1 = nn.BatchNorm1d(128)
        self.pool1 = nn.MaxPool1d(4)
        self.conv2 = nn.Conv1d(128, 128, 3)
        self.bn2 = nn.BatchNorm1d(128)
        self.pool2 = nn.MaxPool1d(4)
        self.conv3 = nn.Conv1d(128, 256, 3)
        self.bn3 = nn.BatchNorm1d(256)
        self.pool3 = nn.MaxPool1d(4)
        self.conv4 = nn.Conv1d(256, 512, 3)
        self.bn4 = nn.BatchNorm1d(512)
        self.pool4 = nn.MaxPool1d(4)
        self.avgPool = nn.AvgPool1d(30)
        self.fc1 = nn.Linear(512, 10)

    def forward(self, x):
        x = self.conv1(x)
        x = F.relu(self.bn1(x))
        x = self.pool1(x)
        x = self.conv2(x)
        x = F.relu(self.bn2(x))
        x = self.pool2(x)
        x = self.conv3(x)
        x = F.relu(self.bn3(x))
        x = self.pool3(x)
        x = self.conv4(x)
        x = F.relu(self.bn4(x))
        x = self.pool4(x)
        x = self.avgPool(x)
        x = x.permute(0, 2, 1)
        x = self.fc1(x)
        return F.log_softmax(x, dim = 2)

我们还需要一个优化器和一个损失函数。对于优化器,我们像以前一样使用 Adam,但你认为我们应该使用什么损失函数?(如果你回答CrossEntropyLoss,给自己一个金星!)

audio_net = AudioNet()
audio_net.to(device)

创建完我们的模型后,我们保存我们的权重,并使用第四章中的find_lr()函数:

audio_net.save("audionet.pth")
import torch.optim as optim
optimizer = optim.Adam(audionet.parameters(), lr=0.001)
logs,losses = find_lr(audio_net, nn.CrossEntropyLoss(), optimizer)
plt.plot(logs,losses)

从图 6-3 中的图表中,我们确定适当的学习率大约是1e-5(基于下降最陡的地方)。我们将其设置为我们的学习率,并重新加载我们模型的初始权重:

AudioNet 学习率图

图 6-3. AudioNet 学习率图
lr = 1e-5
model.load("audionet.pth")
import torch.optim as optim
optimizer = optim.Adam(audionet.parameters(), lr=lr)

我们对模型进行 20 个周期的训练:

train(audio_net, optimizer, torch.nn.CrossEntropyLoss(),
train_data_loader, valid_data_loader, epochs=20)

训练后,您应该发现模型在我们的数据集上达到了大约 13%至 17%的准确率。这比我们随机选择 50 个类别中的一个时可以期望的 2%要好。但也许我们可以做得更好;让我们探讨一种不同的查看音频数据的方式,可能会产生更好的结果。

这个频率是我的宇宙

如果您回顾一下 ESC-50 的 GitHub 页面,您会看到一个网络架构和其准确度得分的排行榜。您会注意到,与其他相比,我们的表现并不出色。我们可以扩展我们创建的模型使其更深,这可能会稍微提高我们的准确度,但要实现真正的性能提升,我们需要切换领域。在音频处理中,您可以像我们一直在做的那样处理纯波形;但大多数情况下,您将在频域中工作。这种不同的表示将原始波形转换为一个视图,显示了在特定时间点的所有声音频率。这可能是向神经网络呈现更丰富信息的表示形式,因为它可以直接处理这些频率,而不必弄清楚如何将原始波形信号映射为模型可以使用的内容。

让我们看看如何使用LibROSA生成频谱图。

Mel 频谱图

传统上,进入频域需要在音频信号上应用傅立叶变换。我们将通过在 mel 刻度上生成我们的频谱图来超越这一点。mel 刻度定义了一个音高刻度,其中相距相等,其中 1000 mels = 1000 Hz。这种刻度在音频处理中很常用,特别是在语音识别和分类应用中。使用LibROSA生成 mel 频谱图只需要两行代码:

sample_data, sr = librosa.load("ESC-50/train/1-100032-A-0.wav", sr=None)
spectrogram = librosa.feature.melspectrogram(sample_data, sr=sr)

这将生成一个包含频谱图数据的 NumPy 数组。如果我们像图 6-4 中所示显示这个频谱图,我们就可以看到我们声音中的频率:

librosa.display.specshow(spectrogram, sr=sr, x_axis='time', y_axis='mel')

Mel 频谱图

图 6-4。Mel 频谱图

然而,图像中并没有太多信息。我们可以做得更好!如果我们将频谱图转换为对数刻度,由于该刻度能够表示更广泛的值范围,我们可以看到音频结构的更多内容。这在音频处理中很常见,LibROSA包含了一个方法:

log_spectrogram = librosa.power_to_db(spectrogram, ref=np.max)

这将计算一个10 * log10(spectrogram / ref)的缩放因子。ref默认为1.0,但在这里我们传入np.max(),以便spectrogram / ref将落在[0,1]的范围内。图 6-5 显示了新的频谱图。

对数 Mel 频谱图

图 6-5。对数 mel 频谱图

现在我们有了一个对数刻度的 mel 频谱图!如果您调用log_spectrogram.shape,您会看到它是一个 2D 张量,这是有道理的,因为我们已经用张量绘制了图像。我们可以创建一个新的神经网络架构,并将这些新数据输入其中,但我有一个恶毒的技巧。我们刚刚生成了频谱图数据的图像。为什么不直接处理这些呢?

这一开始可能看起来有些愚蠢;毕竟,我们有基础频谱图数据,这比图像表示更精确(对我们来说,知道一个数据点是 58 而不是 60 对我们来说意义更大,而不是不同色调,比如紫色)。如果我们从头开始,这肯定是这样。但是!我们已经有了一些训练有素的网络,如 ResNet 和 Inception,我们知道它们擅长识别图像的结构和其他部分。我们可以构建音频的图像表示,并使用预训练网络再次利用迁移学习的超能力,通过很少的训练来大幅提高准确度。这对我们的数据集可能很有用,因为我们没有很多示例(只有 2000 个!)来训练我们的网络。

这个技巧可以应用于许多不同的数据集。如果您能找到一种便宜地将数据转换为图像表示的方法,那么值得这样做,并将 ResNet 网络应用于其,以了解迁移学习对您的作用,这样您就知道通过使用不同方法可以超越什么。有了这个,让我们创建一个新的数据集,以便根据需要为我们生成这些图像。

一个新的数据集

现在丢弃原始的ESC50数据集类,构建一个新的ESC50Spectrogram。虽然这将与旧类共享一些代码,但在这个版本的__get_item__方法中会有更多的操作。我们通过LibROSA生成频谱图,然后通过一些复杂的matplotlib操作将数据转换为 NumPy 数组。我们将该数组应用于我们的转换流水线(只使用ToTensor),并返回该数组和项目的标签。以下是代码:

class ESC50Spectrogram(Dataset):

def __init__(self,path):
    files = Path(path).glob('*.wav')
    self.items = [(f,int(f.name.split("-")[-1].replace(".wav","")))
                   for f in files]
    self.length = len(self.items)
    self.transforms = torchvision.transforms.Compose(
                 [torchvision.transforms.ToTensor()])

def __getitem__(self, index):
    filename, label = self.items[index]
    audio_tensor, sample_rate = librosa.load(filename, sr=None)
    spectrogram = librosa.feature.melspectrogram(audio_tensor, sr=sample_rate)
    log_spectrogram = librosa.power_to_db(spectrogram, ref=np.max)
    librosa.display.specshow(log_spectrogram, sr=sample_rate,
                             x_axis='time', y_axis='mel')
    plt.gcf().canvas.draw()
    audio_data = np.frombuffer(fig.canvas.tostring_rgb(), dtype=np.uint8)
    audio_data = audio_data.reshape(fig.canvas.get_width_height()[::-1] + (3,))
    return (self.transforms(audio_data), label)

def __len__(self):
    return self.length

我们不会花太多时间在这个数据集的版本上,因为它有一个很大的缺陷,我用 Python 的process_time()方法演示了这一点:

oldESC50 = ESC50("ESC-50/train/")
start_time = time.process_time()
oldESC50.__getitem__(33)
end_time = time.process_time()
old_time = end_time - start_time

newESC50 = ESC50Spectrogram("ESC-50/train/")
start_time = time.process_time()
newESC50.__getitem__(33)
end_time = time.process_time()
new_time = end_time - start_time

old_time = 0.004786839000075815
new_time = 0.39544327499993415

新数据集的速度几乎比我们原始的只返回原始音频的数据集慢一百倍!这将使训练变得非常缓慢,甚至可能抵消使用迁移学习所能带来的任何好处。

我们可以使用一些技巧来解决大部分问题。第一种方法是添加一个缓存,将生成的频谱图存储在内存中,这样我们就不必每次调用__getitem__方法时都重新生成它。使用 Python 的functools包,我们可以很容易地做到这一点:

import functools

class ESC50Spectrogram(Dataset):
 #skipping init code

    @functools.lru_cache(maxsize=<size of dataset>)
    def __getitem__(self, index):

只要您有足够的内存来存储整个数据集的内容到 RAM 中,这可能就足够了。我们设置了一个最近最少使用(LRU)缓存,将尽可能长时间地保留内容在内存中,最近没有被访问的索引在内存紧张时首先被驱逐出缓存。然而,如果您没有足够的内存来存储所有内容,您将在每个批次迭代时遇到减速,因为被驱逐的频谱图需要重新生成。

我的首选方法是预计算所有可能的图表,然后创建一个新的自定义数据集类,从磁盘加载这些图像。(您甚至可以添加 LRU 缓存注释以进一步加快速度。)

我们不需要为预计算做任何花哨的事情,只需要一个将图表保存到正在遍历的目录中的方法:

def precompute_spectrograms(path, dpi=50):
    files = Path(path).glob('*.wav')
    for filename in files:
        audio_tensor, sample_rate = librosa.load(filename, sr=None)
        spectrogram = librosa.feature.melspectrogram(audio_tensor, sr=sr)
        log_spectrogram = librosa.power_to_db(spectrogram, ref=np.max)
        librosa.display.specshow(log_spectrogram, sr=sr, x_axis='time',
                                 y_axis='mel')
        plt.gcf().savefig("{}{}_{}.png".format(filename.parent,dpi,
                          filename.name),dpi=dpi)

这种方法比我们之前的数据集更简单,因为我们可以使用matplotlibsavefig方法直接将图表保存到磁盘,而不必与 NumPy 搞在一起。我们还提供了一个额外的输入参数dpi,允许我们控制生成输出的质量。在我们已经设置好的所有traintestvalid路径上运行(可能需要几个小时才能处理完所有图像)。

现在我们只需要一个新的数据集来读取这些图像。我们不能使用第二章到第四章中的标准ImageDataLoader,因为 PNG 文件名方案与其使用的目录结构不匹配。但没关系,我们可以使用 Python Imaging Library 打开一张图片:

from PIL import Image

    class PrecomputedESC50(Dataset):
        def __init__(self,path,dpi=50, transforms=None):
            files = Path(path).glob('{}*.wav.png'.format(dpi))
            self.items = [(f,int(f.name.split("-")[-1]
            .replace(".wav.png",""))) for f in files]
            self.length = len(self.items)
            if transforms=None:
                self.transforms =
                torchvision.transforms.Compose([torchvision.transforms.ToTensor()])
            else:
                self.transforms = transforms

        def __getitem__(self, index):
            filename, label = self.items[index]
            img = Image.open(filename)
            return (self.transforms(img), label)

        def __len__(self):
            return self.length

这段代码更简单,希望从数据集中获取一个条目所需的时间也反映在其中:

start_time = time.process_time()
b.__getitem__(33)
end_time = time.process_time()
end_time - start_time
>> 0.0031465259999094997

从这个数据集获取一个元素大致需要与我们原始基于音频的数据集相同的时间,所以我们不会因为转向基于图像的方法而失去任何东西,除了预计算所有图像并创建数据库的一次性成本。我们还提供了一个默认的转换流水线,将图像转换为张量,但在初始化期间可以替换为不同的流水线。有了这些优化,我们可以开始将迁移学习应用到这个问题上。

一只野生的 ResNet 出现了

正如您可能记得的那样,从第四章中,迁移学习要求我们使用已经在特定数据集上训练过的模型(在图像的情况下,可能是 ImageNet),然后在我们特定的数据领域上微调它,即我们将 ESC-50 数据集转换为频谱图像。您可能会想知道一个在正常照片上训练过的模型对我们是否有用。事实证明,预训练模型确实学到了很多结构,可以应用于乍看起来可能非常不同的领域。以下是我们从第四章中初始化模型的代码:

from torchvision import models
spec_resnet = models.ResNet50(pretrained=True)

for param in spec_resnet.parameters():
    param.requires_grad = False

spec_resnet.fc = nn.Sequential(nn.Linear(spec_resnet.fc.in_features,500),
nn.ReLU(),
nn.Dropout(), nn.Linear(500,50))

这使我们使用了一个预训练的(并冻结的)ResNet50模型,并将模型的头部替换为一个未经训练的Sequential模块,最后以一个输出为 50 的Linear结尾,每个类别对应 ESC-50 数据集中的一个类。我们还需要创建一个DataLoader,以获取我们预先计算的频谱图。当我们创建 ESC-50 数据集时,我们还希望使用标准的 ImageNet 标准差和均值对传入的图像进行归一化,因为预训练的 ResNet-50 架构就是用这种方式训练的。我们可以通过传入一个新的管道来实现:

esc50pre_train = PreparedESC50(PATH, transforms=torchvision.transforms
.Compose([torchvision.transforms.ToTensor(),
torchvision.transforms.Normalize
(mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225])]))

esc50pre_valid = PreparedESC50(PATH, transforms=torchvision.transforms
.Compose([torchvision.transforms.ToTensor(),
torchvision.transforms.Normalize
(mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225])]))

esc50_train_loader = (esc50pre_train, bs, shuffle=True)
esc50_valid_loader = (esc50pre_valid, bs, shuffle=True)

设置好数据加载器后,我们可以继续寻找学习率并准备训练。

寻找学习率

我们需要找到一个学习率来在我们的模型中使用。就像在第四章中一样,我们会保存模型的初始参数,并使用我们的find_lr()函数来找到一个适合训练的学习率。图 6-6 显示了损失与学习率之间的关系图。

spec_resnet.save("spec_resnet.pth")
loss_fn = nn.CrossEntropyLoss()
optimizer = optim.Adam(spec_resnet.parameters(), lr=lr)
logs,losses = find_lr(spec_resnet, loss_fn, optimizer)
plt.plot(logs, losses)

SpecResNet 学习率图

图 6-6. SpecResNet 学习率图

查看学习率与损失之间的图表,似乎1e-2是一个不错的起点。由于我们的 ResNet-50 模型比之前的模型更深,我们还将使用不同的学习率[1e-2,1e-4,1e-8],其中最高的学习率应用于我们的分类器(因为它需要最多的训练!),而对于已经训练好的主干使用较慢的学习率。同样,我们使用 Adam 作为优化器,但可以尝试其他可用的优化器。

在我们应用这些不同的学习率之前,我们会训练几个周期,只更新分类器,因为我们在创建网络时冻结了 ResNet-50 的主干:

optimizer = optim.Adam(spec_resnet.parameters(), lr=[1e-2,1e-4,1e-8])

train(spec_resnet, optimizer, nn.CrossEntropyLoss(),
esc50_train_loader, esc50_val_loader,epochs=5,device="cuda")

现在我们解冻主干并应用我们的不同学习率:

for param in spec_resnet.parameters():
    param.requires_grad = True

optimizer = optim.Adam(spec_resnet.parameters(), lr=[1e-2,1e-4,1e-8])

train(spec_resnet, optimizer, nn.CrossEntropyLoss(),
esc50_train_loader, esc50_val_loader,epochs=20,device="cuda")

> Epoch 19, accuracy = 0.80

如您所见,验证准确率约为 80%,我们已经远远超过了原始的AudioNet模型。再次展示了迁移学习的强大!可以继续训练更多周期,看看准确率是否继续提高。如果查看 ESC-50 排行榜,我们已经接近人类水平的准确率。而这仅仅是使用 ResNet-50。您可以尝试使用 ResNet-101,或者尝试使用不同架构的集成来进一步提高分数。

还有数据增强要考虑。让我们看看在迄今为止我们一直在工作的两个领域中如何做到这一点。

音频数据增强

当我们在第四章中查看图像时,我们发现通过对传入的图片进行更改,如翻转、裁剪或应用其他转换,可以提高分类器的准确性。通过这些方式,我们让神经网络在训练阶段更加努力,并在最后得到一个更通用的模型,而不仅仅是适应所呈现的数据(过拟合的祸根,不要忘记)。我们能在这里做同样的事情吗?是的!事实上,我们可以使用两种方法——一种明显的方法适用于原始音频波形,另一种可能不太明显的想法源自我们决定在 mel 频谱图像上使用基于 ResNet 的分类器。让我们先看看音频转换。

torchaudio 转换

torchvision类似,torchaudio包括一个transforms模块,对传入数据执行转换。然而,提供的转换数量有些稀少,特别是与处理图像时得到的丰富多样相比。如果您感兴趣,请查看文档获取完整列表,但我们在这里只看一个torchaudio.transforms.PadTrim。在 ESC-50 数据集中,每个音频剪辑的长度都是相同的。这在现实世界中并不常见,但我们的神经网络喜欢(有时也坚持,取决于它们的构建方式)输入数据是规则的。PadTrim将接收到的音频张量填充到所需长度,或者将其修剪到不超过该长度。如果我们想将剪辑修剪到新长度,我们会这样使用PadTrim

audio_tensor, rate = torchaudio.load("test.wav")
audio_tensor.shape
trimmed_tensor = torchaudio.transforms.PadTrim(max_len=1000)(audio_orig)

然而,如果您正在寻找实际改变音频声音的增强(例如添加回声、噪音或更改剪辑的节奏),那么torchaudio.transforms模块对您没有用。相反,我们需要使用SoX

SoX 效果链

为什么它不是transforms模块的一部分,我真的不确定,但torchaudio.sox_effects.SoxEffectsChain允许您创建一个或多个SoX效果链,并将这些效果应用于输入文件。界面有点棘手,让我们在一个新版本的数据集中看看它的运行方式,该数据集改变了音频文件的音调:

class ESC50WithPitchChange(Dataset):

    def __init__(self,path):
        # Get directory listing from path
        files = Path(path).glob('*.wav')
        # Iterate through the listing and create a list of tuples (filename, label)
        self.items = [(f,f.name.split("-")[-1].replace(".wav","")) for f in files]
        self.length = len(self.items)
        self.E = torchaudio.sox_effects.SoxEffectsChain()
        self.E.append_effect_to_chain("pitch", [0.5])

    def __getitem__(self, index):
        filename, label = self.items[index]
        self.E.set_input_file(filename)
        audio_tensor, sample_rate = self.E.sox_build_flow_effects()
        return audio_tensor, label

    def __len__(self):
        return self.length

在我们的__init__方法中,我们创建一个新的实例变量E,一个SoxEffectsChain,它将包含我们要应用于音频数据的所有效果。然后,我们通过使用append_effect_to_chain添加一个新效果,该方法接受一个指示效果名称的字符串,以及要发送给sox的参数数组。您可以通过调用torchaudio.sox_effects.effect_names()获取可用效果的列表。如果我们要添加另一个效果,它将在我们已经设置的音调效果之后发生,因此如果您想创建一系列单独的效果并随机应用它们,您需要为每个效果创建单独的链。

当选择要返回给数据加载器的项目时,情况有所不同。我们不再使用torchaudio.load(),而是引用我们的效果链,并使用set_input_file指向文件。但请注意,这并不会加载文件!相反,我们必须使用sox_build_flow_effects(),它在后台启动SoX,应用链中的效果,并返回我们通常从load()中获得的张量和采样率信息。

SoX可以做的事情数量相当惊人,我不会详细介绍您可以使用的所有可能效果。我建议结合list_effects()查看SoX文档以了解可能性。

这些转换允许我们改变原始音频,但在本章的大部分时间里,我们都在构建一个处理管道,用于处理梅尔频谱图像。我们可以像为该管道生成初始数据集所做的那样,创建修改后的音频样本,然后从中创建频谱图像,但在那时,我们将创建大量数据,需要在运行时混合在一起。幸运的是,我们可以对频谱图像本身进行一些转换。

SpecAugment

现在,你可能会想到:“等等,这些频谱图只是图片!我们可以对它们使用任何图片变换!” 是的!后面的你获得金星。但是我们必须小心一点;例如,随机裁剪可能会剪掉足够的频率,从而潜在地改变输出类别。在我们的 ESC-50 数据集中,这不是一个大问题,但如果你在做类似语音识别的事情,那么在应用增强时肯定要考虑这一点。另一个有趣的可能性是,因为我们知道所有的频谱图具有相同的结构(它们总是一个频率图!),我们可以创建基于图像的变换,专门围绕这种结构工作。

2019 年,谷歌发布了一篇关于 SpecAugment 的论文,[3]在许多音频数据集上报告了新的最先进结果。该团队通过使用三种新的数据增强技术直接应用于梅尔频谱图:时间弯曲、频率掩码和时间掩码,从中获得了这些结果。我们不会讨论时间弯曲,因为从中获得的好处很小,但我们将为掩码时间和频率实现自定义变换。

频率掩码

频率掩码会随机地从我们的音频输入中移除一个频率或一组频率。这样做是为了让模型更加努力;它不能简单地记忆输入及其类别,因为在每个批次中输入的不同频率会被掩码。模型将不得不学习其他特征,以确定如何将输入映射到一个类别,这希望会导致一个更准确的模型。

在我们的梅尔频谱图中,这通过确保在任何时间步长中该频率的频谱图中没有任何内容来显示。图 6-7 展示了这是什么样子:基本上是在自然频谱图上画了一条空白线。

这是一个实现频率掩码的自定义Transform的代码:

class FrequencyMask(object):
    """
 Example:
 >>> transforms.Compose([
 >>>     transforms.ToTensor(),
 >>>     FrequencyMask(max_width=10, use_mean=False),
 >>> ])

 """

    def __init__(self, max_width, use_mean=True):
        self.max_width = max_width
        self.use_mean = use_mean

    def __call__(self, tensor):
        """
 Args:
 tensor (Tensor): Tensor image of
 size (C, H, W) where the frequency
 mask is to be applied.

 Returns:
 Tensor: Transformed image with Frequency Mask.
 """
        start = random.randrange(0, tensor.shape[2])
        end = start + random.randrange(1, self.max_width)
        if self.use_mean:
            tensor[:, start:end, :] = tensor.mean()
        else:
            tensor[:, start:end, :] = 0
        return tensor

    def __repr__(self):
        format_string = self.__class__.__name__ + "(max_width="
        format_string += str(self.max_width) + ")"
        format_string += 'use_mean=' + (str(self.use_mean) + ')')

        return format_string

当应用变换时,PyTorch 将使用图像的张量表示调用__call__方法(因此我们需要将其放在将图像转换为张量后的Compose链中,而不是之前)。我们假设张量将以通道×高度×宽度格式呈现,并且我们希望将高度值设置在一个小范围内,要么为零,要么为图像的平均值(因为我们使用对数梅尔频谱图,平均值应该与零相同,但我们包括两种选项,以便您可以尝试看哪种效果更好)。范围由max_width参数提供,我们得到的像素掩码将在 1 到max_pixels之间。我们还需要为掩码选择一个随机起点,这就是start变量的作用。最后,这个变换的复杂部分——我们应用我们生成的掩码:

tensor[:, start:end, :] = tensor.mean()

当我们将其分解时,情况就没有那么糟糕了。我们的张量有三个维度,但我们希望在所有红色、绿色和蓝色通道上应用这个变换,所以我们使用裸的:来选择该维度中的所有内容。使用start:end,我们选择我们的高度范围,然后我们选择宽度通道中的所有内容,因为我们希望在每个时间步长上应用我们的掩码。然后在表达式的右侧,我们设置值;在这种情况下,是tensor.mean()。如果我们从 ESC-50 数据集中取一个随机张量并将变换应用于它,我们可以在图 6-7 中看到这个类别正在创建所需的掩码。

torchvision.transforms.Compose([FrequencyMask(max_width=10, use_mean=False),
torchvision.transforms.ToPILImage()])(torch.rand(3,250,200))

应用于随机 ESC-50 样本的频率掩码

图 6-7。应用于随机 ESC-50 样本的频率掩码

接下来我们将转向时间掩码。

时间掩码

有了我们的频率掩码完成后,我们可以转向时间掩码,它与频率掩码相同,但在时间域中。这里的代码大部分是相同的:

class TimeMask(object):
    """
 Example:
 >>> transforms.Compose([
 >>>     transforms.ToTensor(),
 >>>     TimeMask(max_width=10, use_mean=False),
 >>> ])

 """

    def __init__(self, max_width, use_mean=True):
        self.max_width = max_width
        self.use_mean = use_mean

    def __call__(self, tensor):
        """
 Args:
 tensor (Tensor): Tensor image of
 size (C, H, W) where the time mask
 is to be applied.

 Returns:
 Tensor: Transformed image with Time Mask.
 """
        start = random.randrange(0, tensor.shape[1])
        end = start + random.randrange(0, self.max_width)
        if self.use_mean:
            tensor[:, :, start:end] = tensor.mean()
        else:
            tensor[:, :, start:end] = 0
        return tensor

    def __repr__(self):
        format_string = self.__class__.__name__ + "(max_width="
        format_string += str(self.max_width) + ")"
        format_string += 'use_mean=' + (str(self.use_mean) + ')')
        return format_string

正如您所看到的,这个类与频率掩码类似。唯一的区别是我们的start变量现在在高度轴上的某个点范围内,当我们进行掩码处理时,我们这样做:

tensor[:, :, start:end] = 0

这表明我们选择张量的前两个维度的所有值和最后一个维度中的start:end范围。再次,我们可以将这应用于来自 ESC-50 的随机张量,以查看掩码是否被正确应用,如图 6-8 所示。

torchvision.transforms.Compose([TimeMask(max_width=10, use_mean=False),
torchvision.transforms.ToPILImage()])(torch.rand(3,250,200))

应用于随机 ESC-50 样本的时间掩码

图 6-8。应用于随机 ESC-50 样本的时间掩码

为了完成我们的增强,我们创建一个新的包装器转换,确保一个或两个掩码应用于频谱图像:

class PrecomputedTransformESC50(Dataset):
    def __init__(self,path,dpi=50):
        files = Path(path).glob('{}*.wav.png'.format(dpi))
        self.items = [(f,f.name.split("-")[-1].replace(".wav.png",""))
                      for f in files]
        self.length = len(self.items)
        self.transforms = transforms.Compose([
    transforms.ToTensor(),
    RandomApply([FrequencyMask(self.max_freqmask_width)]p=0.5),
    RandomApply([TimeMask(self.max_timemask_width)]p=0.5)
])

    def __getitem__(self, index):
        filename, label = self.items[index]
        img = Image.open(filename)
        return (self.transforms(img), label)

    def __len__(self):
        return self.length

尝试使用这种数据增强重新运行训练循环,看看您是否像谷歌一样通过这些掩码获得更好的准确性。但也许我们还可以尝试更多与这个数据集有关的内容?

进一步实验

到目前为止,我们已经创建了两个神经网络——一个基于原始音频波形,另一个基于 mel 频谱图像——用于对 ESC-50 数据集中的声音进行分类。尽管您已经看到基于 ResNet 的模型在使用迁移学习的力量时更准确,但创建这两个网络的组合来查看是否增加或减少准确性将是一个有趣的实验。这样做的一个简单方法是重新审视第四章中的集成方法:只需组合和平均预测。此外,我们跳过了基于我们从频谱图中获取的原始数据构建网络的想法。如果创建了一个适用于该数据的模型,那么如果将其引入集成,是否会提高整体准确性?我们还可以使用其他版本的 ResNet,或者我们可以创建使用不同预训练模型(如 VGG 或 Inception)作为骨干的新架构。探索一些这些选项并看看会发生什么;在我的实验中,SpecAugment 将 ESC-50 分类准确性提高了约 2%。

结论

在本章中,我们使用了两种非常不同的音频分类策略,简要介绍了 PyTorch 的torchaudio库,并看到了在数据集上预先计算转换的方法,而在进行实时转换时会严重影响训练时间。我们讨论了两种数据增强方法。作为一个意外的奖励,我们再次通过使用迁移学习来训练基于图像的模型,快速生成一个与 ESC-50 排行榜上其他模型相比准确度较高的分类器。

这结束了我们对图像、测试和音频的导览,尽管我们将在第九章中再次涉及这三个方面,当我们看一些使用 PyTorch 的应用程序时。不过,接下来,我们将看看在模型训练不够正确或速度不够快时如何调试模型。

进一步阅读

理解SoX可以做什么超出了本书的范围,并且对于我们接下来在本章中要做的事情并不是必要的。

参见“用于原始波形的非常深度卷积神经网络” 由 Wei Dai 等人(2016 年)。

参见“SpecAugment:用于自动语音识别的简单数据增强方法” 由 Daniel S. Park 等人(2019 年)。

第七章:调试 PyTorch 模型

到目前为止,我们在本书中创建了许多模型,但在本章中,我们简要地看一下如何解释它们并弄清楚底层发生了什么。我们看一下如何使用 PyTorch 钩子和类激活映射来确定模型决策的焦点,以及如何将 PyTorch 连接到 Google 的 TensorBoard 进行调试。我将展示如何使用火焰图来识别转换和训练管道中的瓶颈,并提供一个加速缓慢转换的示例。最后,我们将看看如何在处理更大的模型时通过检查点来交换计算和内存。不过,首先,简要谈谈您的数据。

凌晨 3 点。你的数据在做什么?

在我们深入研究像 TensorBoard 或梯度检查点这样的闪亮东西之前,问问自己:您了解您的数据吗?如果您正在对输入进行分类,您是否在所有可用标签上拥有平衡的样本?在训练、验证和测试集中?

而且,您确定您的标签是*正确的吗?*像 MNIST 和 CIFAR-10(加拿大高级研究所)这样的重要基于图像的数据集已知包含一些不正确的标签。您应该检查您的数据,特别是如果类别彼此相似,比如狗品种或植物品种。简单地对数据进行合理性检查可能会节省大量时间,如果您发现,比如说,一个标签类别只有微小的图像,而其他所有类别都有大分辨率的示例。

一旦您确保数据处于良好状态,那么是的,让我们转到 TensorBoard 开始检查模型中的一些可能问题。

TensorBoard

TensorBoard是一个用于可视化神经网络各个方面的 Web 应用程序。它允许轻松实时查看诸如准确性、损失激活值等统计数据,以及您想要发送的任何内容。尽管它是为 TensorFlow 编写的,但它具有如此通用和相当简单的 API,以至于在 PyTorch 中使用它与在 TensorFlow 中使用它并没有太大不同。让我们安装它,看看我们如何使用它来获取有关我们模型的一些见解。

注意

在阅读 PyTorch 时,您可能会遇到一个名为Visdom的应用程序,这是 Facebook 对 TensorBoard 的替代方案。在 PyTorch v1.1 之前,支持可视化的方式是使用 Visdom 与 PyTorch,同时第三方库如tensorboardX可用于与 TensorBoard 集成。虽然 Visdom 仍在维护,但在 v1.1 及以上版本中包含了官方的 TensorBoard 集成,这表明 PyTorch 的开发人员已经认识到 TensorBoard 是事实上的神经网络可视化工具。

安装 TensorBoard

安装 TensorBoard 可以使用pipconda

pip install tensorboard
conda install tensorboard
注意

PyTorch 需要 v1.14 或更高版本的 TensorBoard。

然后可以在命令行上启动 TensorBoard:

tensorboard --logdir=runs

然后,您可以转到http://[your-machine]:6006,您将看到图 7-1 中显示的欢迎屏幕。现在我们可以向应用程序发送数据。

Tensorboard

图 7-1. TensorBoard

将数据发送到 TensorBoard

使用 PyTorch 的 TensorBoard 模块位于torch.utils.tensorboard中:

from torch.utils.tensorboard import SummaryWriter
writer = SummaryWriter()
writer.add_scalar('example', 3)

我们使用SummaryWriter类与 TensorBoard 通信,使用标准的日志输出位置*./runs*,可以通过使用带有标签的add_scalar发送标量。由于SummaryWriter是异步工作的,可能需要一会儿,但您应该看到 TensorBoard 更新,如图 7-2 所示。

Tensorboard 中的示例数据点

图 7-2. TensorBoard 中的示例数据点

这并不是很令人兴奋,对吧?让我们写一个循环,从初始起点发送更新:

import random
value = 10
writer.add_scalar('test_loop', value, 0)
for i in range(1,10000):
  value += random.random() - 0.5
  writer.add_scalar('test_loop', value, i)

通过传递我们在循环中的位置,如图 7-3 所示,TensorBoard 会给我们一个绘制我们从 10 开始进行的随机漫步的图。如果我们再次运行代码,我们会看到它在显示中生成了一个不同的run,我们可以在网页的左侧选择是否要查看所有运行或只查看特定的一些。

在 tensorboard 中绘制随机漫步

图 7-3. 在 TensorBoard 中绘制随机漫步

我们可以用这个函数来替换训练循环中的print语句。我们也可以发送模型本身以在 TensorBoard 中得到表示!

import torch
import torchvision
from torch.utils.tensorboard import SummaryWriter
from torchvision import datasets, transforms,models

writer = SummaryWriter()
model = models.resnet18(False)
writer.add_graph(model,torch.rand([1,3,224,224]))

def train(model, optimizer, loss_fn, train_data_loader, test_data_loader, epochs=20):
    model = model.train()
    iteration = 0

    for epoch in range(epochs):
        model.train()
        for batch in train_loader:
            optimizer.zero_grad()
            input, target = batch
            output = model(input)
            loss = loss_fn(output, target)
            writer.add_scalar('loss', loss, epoch)
            loss.backward()
            optimizer.step()

        model.eval()
        num_correct = 0
        num_examples = 0
        for batch in val_loader:
            input, target = batch
            output = model(input)
            correct = torch.eq(torch.max(F.softmax(output), dim=1)[1], target).view(-1)
            num_correct += torch.sum(correct).item()
            num_examples += correct.shape[0]
            print("Epoch {}, accuracy = {:.2f}".format(epoch,
                   num_correct / num_examples)
            writer.add_scalar('accuracy', num_correct / num_examples, epoch)
        iterations += 1

当使用add_graph()时,我们需要发送一个张量来跟踪模型,以及模型本身。一旦发生这种情况,你应该在 TensorBoard 中看到GRAPHS出现,并且如图 7-4 所示,点击大的 ResNet 块会显示模型结构的更多细节。

可视化 ResNet

图 7-4. 可视化 ResNet

现在我们可以将准确性和损失信息以及模型结构发送到 TensorBoard。通过聚合多次运行的准确性和损失信息,我们可以看到特定运行与其他运行有何不同,这在尝试弄清楚为什么训练运行产生糟糕结果时是一个有用的线索。我们很快会回到 TensorBoard,但首先让我们看看 PyTorch 为调试提供的其他功能。

PyTorch 钩子

PyTorch 有钩子,它们是可以附加到张量或模块的前向或后向传递的函数。当 PyTorch 在传递过程中遇到带有钩子的模块时,它会调用已注册的钩子。在张量上注册的钩子在计算其梯度时会被调用。

钩子是操纵模块和张量的潜在强大方式,因为如果你愿意,你可以完全替换钩子中的输出。你可以改变梯度,屏蔽激活,替换模块中的所有偏置等等。然而,在本章中,我们只会将它们用作在数据流过程中获取有关网络信息的一种方式。

给定一个 ResNet-18 模型,我们可以使用register_forward_hook在模型的特定部分附加一个前向钩子:

def print_hook(self, module, input, output):
  print(f"Shape of input is {input.shape}")

model = models.resnet18()
hook_ref  = model.fc.register_forward_hook(print_hook)
model(torch.rand([1,3,224,224]))
hook_ref.remove()
model(torch.rand([1,3,224,224]))

如果你运行这段代码,你应该会看到打印出的文本,显示模型的线性分类器层的输入形状。请注意,第二次通过模型传递随机张量时,你不应该看到print语句。当我们向模块或张量添加钩子时,PyTorch 会返回对该钩子的引用。我们应该始终保存该引用(这里我们在hook_ref中这样做),然后在完成时调用remove()。如果你不保存引用,那么它将一直存在并占用宝贵的内存(并在传递过程中浪费计算资源)。反向钩子的工作方式相同,只是你要调用register_backward_hook()

当然,如果我们可以print()某些内容,我们肯定可以将其发送到 TensorBoard!让我们看看如何使用钩子和 TensorBoard 来获取训练过程中关于我们层的重要统计信息。

绘制均值和标准差

首先,我们设置一个函数,将输出层的均值和标准差发送到 TensorBoard:

def send_stats(i, module, input, output):
  writer.add_scalar(f"{i}-mean",output.data.std())
  writer.add_scalar(f"{i}-stddev",output.data.std())

我们不能单独使用这个来设置一个前向钩子,但是使用 Python 函数partial(),我们可以创建一系列前向钩子,它们将自动附加到具有设置i值的层,以确保正确的值被路由到 TensorBoard 中的正确图表中:

from functools import partial

for i,m in enumerate(model.children()):
  m.register_forward_hook(partial(send_stats, i))

请注意,我们正在使用model.children(),它只会附加到模型的每个顶层块,因此如果我们有一个nn.Sequential()层(在基于 ResNet 的模型中会有),我们只会将钩子附加到该块,而不是每个nn.Sequential列表中的单个模块。

如果我们使用通常的训练函数训练我们的模型,我们应该看到激活开始流入 TensorBoard,如图 7-5 所示。您将不得不在 UI 中切换到挂钟时间,因为我们不再使用钩子将步骤信息发送回 TensorBoard(因为我们只在调用 PyTorch 钩子时获取模块信息)。

Tensorboard 中模块的均值和标准差

图 7-5。TensorBoard 中模块的均值和标准差

现在,我在第二章中提到,理想情况下,神经网络中的层应该具有均值为 0,标准差为 1,以确保我们的计算不会无限制地增长或减少到零。查看 TensorBoard 中的层。它们看起来是否保持在这个值范围内?图表有时会突然上升然后崩溃吗?如果是这样,这可能是网络训练困难的信号。在图 7-5 中,我们的均值接近零,但标准差也非常接近零。如果您的网络的许多层中都发生这种情况,这可能表明您的激活函数(例如ReLU)并不完全适合您的问题领域。尝试使用其他函数进行实验,看看它们是否可以提高模型的性能;PyTorch 的LeakyReLU是一个很好的替代品,提供与标准ReLU类似的激活,但可以传递更多信息,这可能有助于训练。

关于 TensorBoard 的介绍就到这里,但是“进一步阅读”将指引您查阅更多资源。与此同时,让我们看看如何让模型解释它是如何做出决定的。

类激活映射

类激活映射(CAM)是一种在网络对传入张量进行分类后可视化激活的技术。在基于图像的分类器中,通常显示为热图覆盖在原始图像上,如图 7-6 所示。

使用 Casper 生成类激活映射

图 7-6。Casper 的类激活映射

从热图中,我们可以直观地了解网络是如何从可用的 ImageNet 类中决定波斯猫的。网络的激活在猫的脸部和身体周围最高,在图像的其他地方较低。

要生成热图,我们捕获网络的最终卷积层的激活,就在它进入“线性”层之前,因为这样我们可以看到组合的 CNN 层认为在从图像到类的最终映射中重要的是什么。幸运的是,有了 PyTorch 的钩子功能,这是相当简单的。我们将钩子封装在一个类SaveActivations中:

class SaveActivations():
    activations=None
    def __init__(self, m):
      self.hook = m.register_forward_hook(self.hook_fn)
    def hook_fn(self, module, input, output):
      self.features = output.data
    def remove(self):
      self.hook.remove()

然后,我们将 Casper 的图像通过网络(对 ImageNet 进行归一化),应用softmax将输出张量转换为概率,并使用torch.topk()作为提取最大概率及其索引的方法:

import torch
from torchvision import models, transforms
from torch.nn import functional as F

casper = Image.open("casper.jpg")
# Imagenet mean/std

normalize = transforms.Normalize(
   mean=[0.485, 0.456, 0.406],
   std=[0.229, 0.224, 0.225]
)

preprocess = transforms.Compose([
   transforms.Resize((224,224)),
   transforms.ToTensor(),
   normalize
])

display_transform = transforms.Compose([
   transforms.Resize((224,224))])

casper_tensor = preprocess(casper)

model = models.resnet18(pretrained=True)
model.eval()
casper_activations = SaveActivations(model.layer_4)
prediction = model(casper_tensor.unsqueeze(0))
pred_probabilities = F.softmax(prediction).data.squeeze()
casper_activations.remove()
torch.topk(pred_probabilities,1)
注意

我还没有解释torch.nn.functional,但最好的理解方法是它包含在torch.nn中提供的函数的实现。例如,如果您创建torch.nn.softmax()的实例,您将获得一个具有执行softmaxforward()方法的对象。如果您查看torch.nn.softmax()的实际源代码,您会看到该方法只是调用F.softmax()。由于我们不需要将softmax作为网络的一部分,我们只是调用底层函数。

如果我们现在访问casper_activations.activations,我们将看到它已经被一个张量填充,其中包含我们需要的最终卷积层的激活。然后我们这样做:

fts = sf[0].features[idx]
        prob = np.exp(to_np(log_prob))
        preds = np.argmax(prob[idx])
        fts_np = to_np(fts)
        f2=np.dot(np.rollaxis(fts_np,0,3), prob[idx])
        f2-=f2.min()
        f2/=f2.max()
        f2
plt.imshow(dx)
plt.imshow(scipy.misc.imresize(f2, dx.shape), alpha=0.5, cmap='jet');

这计算了来自 Casper 的激活的点积(我们索引为 0 是因为输入张量的第一维中有批处理,记住)。如第一章中提到的,PyTorch 以 C × H × W 格式存储图像数据,因此我们接下来需要将维度重新排列为 H × W × C 以显示图像。然后,我们从张量中去除最小值,并通过最大值进行缩放,以确保我们只关注结果热图中最高的激活(即,与波斯猫相关的内容)。最后,我们使用一些matplot魔法来显示 Casper,然后在顶部显示张量,调整大小并给出标准的jet颜色映射。请注意,通过用不同的类替换idx,您可以看到热图指示图像中存在哪些激活(如果有的话)在分类时。因此,如果模型预测汽车,您可以看到图像的哪些部分被用来做出这个决定。Casper 的第二高概率是安哥拉兔,我们可以从该索引的 CAM 中看到它专注于他非常蓬松的毛皮!

我们已经了解了模型在做出决策时的情况。接下来,我们将调查模型在训练循环或推断期间大部分时间都在做什么。

火焰图

与 TensorBoard 相比,火焰图并不是专门为神经网络创建的。不,甚至不是为了 TensorFlow。事实上,火焰图的起源可以追溯到 2011 年,当时一位名叫 Brendan Gregg 的工程师在一家名为 Joyent 的公司工作,他想出了这种技术来帮助调试他在 MySQL 中遇到的问题。这个想法是将大量的堆栈跟踪转换成单个图像,这本身就可以呈现出 CPU 在一段时间内的运行情况。

注意

Brendan Gregg 现在在 Netflix 工作,并有大量与性能相关的工作可供阅读和消化。

以 MySQL 插入表中的一行为例,我们每秒对堆栈进行数百次或数千次的采样。每次采样时,我们会得到一个堆栈跟踪,显示出该时刻堆栈中的所有函数。因此,如果我们在一个被另一个函数调用的函数中,我们将得到一个包含调用者和被调用者函数的跟踪。一个采样跟踪看起来像这样:

65.00%     0.00%  mysqld   [kernel.kallsyms]   [k] entry_SYSCALL_64_fastpath
             |
             ---entry_SYSCALL_64_fastpath
                |
                |--18.75%-- sys_io_getevents
                |          read_events
                |          schedule
                |          __schedule
                |          finish_task_switch
                |
                |--10.00%-- sys_fsync
                |          do_fsync
                |          vfs_fsync_range
                |          ext4_sync_file
                |          |
                |          |--8.75%-- jbd2_complete_transaction
                |          |          jbd2_log_wait_commit
                |          |          |
                |          |          |--6.25%-- _cond_resched
                |          |          |          preempt_schedule_common
                |          |          |          __schedule

这里有很多信息;这只是一个 400KB 堆栈跟踪集的一个小样本。即使有这种整理(可能不是所有堆栈跟踪中都有),要看清楚这里发生了什么也是很困难的。

另一方面,火焰图版本简单明了,如您在图 7-7 中所见。y 轴是堆栈高度,x 轴是,虽然不是时间,但表示了在采样时该函数在堆栈中出现的频率。因此,如果我们在堆栈顶部有一个函数占据了 80%的图形,我们就会知道程序在该函数中花费了大量的运行时间,也许我们应该查看该函数,看看是什么让它运行如此缓慢。

MySQL 火焰图

图 7-7. MySQL 火焰图

您可能会问,“这与深度学习有什么关系?”好吧,没错;在深度学习研究中,一个常见的说法是,当训练变慢时,您只需再购买 10 个 GPU 或向谷歌支付更多 TPU Pod 的费用。但也许您的训练流水线并不是完全受 GPU 限制。也许您有一个非常慢的转换,当您获得所有那些闪亮的新显卡时,它们并没有像您想象的那样有所帮助。火焰图提供了一种简单、一目了然的方法来识别 CPU 限制的瓶颈,这在实际的深度学习解决方案中经常发生。例如,还记得我们在第四章中谈到的所有基于图像的转换吗?大多数都使用 Python Imaging Library,并且完全受 CPU 限制。对于大型数据集,您将在训练循环中一遍又一遍地执行这些转换!因此,虽然它们在深度学习的背景下并不经常被提及,但火焰图是您工具箱中很好的工具。如果没有其他办法,您可以将它们用作向老板证明您确实受到 GPU 限制,并且您需要在下周四之前获得所有那些 TPU 积分!我们将看看如何从您的训练周期中获取火焰图,并通过将慢转换从 CPU 移动到 GPU 来修复它。

安装 py-spy

有许多方法可以生成可以转换为火焰图的堆栈跟踪。前一节中生成的是使用 Linux 工具perf生成的,这是一个复杂而强大的工具。我们将采取一个相对简单的选项,并使用py-spy,一个基于 Rust 的堆栈分析器,直接生成火焰图。通过pip安装它:

pip install py-spy

您可以通过使用--pid参数找到正在运行进程的进程标识符(PID),并附加py-spy

py-spy --flame profile.svg --pid 12345

或者您可以传入一个 Python 脚本,这是我们在本章中运行它的方式。首先,让我们在一个简单的 Python 脚本上运行它:

import torch
import torchvision

def get_model():
    return torchvision.models.resnet18(pretrained=True)

def get_pred(model):
    return model(torch.rand([1,3,224,224]))

model = get_model()

for i in range(1,10000):
    get_pred(model)

将此保存为flametest.py,然后让我们在其上运行py-spy,每秒采样 99 次,运行 30 秒:

py-spy -r 99 -d 30 --flame profile.svg -- python t.py

在浏览器中打开profile.svg文件,让我们看看生成的图形。

阅读火焰图

图 7-8 展示了图形大致应该是什么样子(由于采样的原因,它在您的机器上可能不会完全像这样)。您可能首先注意到的是图形是向下的,而不是向上的。py-spyicicle格式编写火焰图,因此堆栈看起来像钟乳石,而不是经典火焰图的火焰。我更喜欢正常格式,但py-spy不提供更改选项,而且这并没有太大的区别。

ResNet 加载和推理的火焰图

图 7-8. ResNet 加载和推理的火焰图

一眼看去,您应该看到大部分执行时间都花在各种forward()调用中,这是有道理的,因为我们正在使用模型进行大量预测。左侧的那些小块呢?如果您单击它们,您会发现 SVG 文件会放大,如图 7-9 所示。

放大的火焰图

图 7-9. 放大的火焰图

在这里,我们可以看到脚本设置了 ResNet-18 模块,并调用load_state_dict()来从磁盘加载保存的权重(因为我们使用pretrained=True调用它)。您可以单击“重置缩放”以返回完整的火焰图。此外,右侧的搜索栏将用紫色突出显示匹配的条形,如果您试图查找一个函数。尝试使用resnet,它将显示堆栈中名称中带有resnet的每个函数调用。这对于查找不经常出现在堆栈中的函数或查看该模式在整个图中出现的频率很有用。

玩一下 SVG,看看在这个示例中 BatchNorm 和池化等东西占用了多少 CPU 时间。接下来,我们将看一种使用火焰图来查找问题、修复问题并使用另一个火焰图验证的方法。

修复慢转换

在现实情况下,你的数据管道的一部分可能会导致减速。如果你有一个慢转换,这将是一个特别的问题,因为它将在训练批次期间被调用多次,导致在创建模型时出现巨大的瓶颈。这里是一个示例转换管道和一个数据加载器:

import torch
import torchvision
from torch import optim
import torch.nn as nn
from torchvision import datasets, transforms, models
import torch.utils.data
from PIL import Image
import numpy as np

device = "cuda:0"
model = models.resnet18(pretrained=True)
model.to(device)

class BadRandom(object):
    def __call__(self, img):
        img_np = np.array(img)
        random = np.random.random_sample(img_np.shape)
        out_np = img_np + random
        out = Image.fromarray(out_np.astype('uint8'), 'RGB')
        return out

    def __repr__(self):
        str = f"{self.__class__.__name__  }"
        return str

train_data_path = "catfish/train"
image_transforms =
torchvision.transforms.Compose(
  [transforms.Resize((224,224)),BadRandom(), transforms.ToTensor()])

我们不会运行完整的训练循环;相反,我们模拟了从训练数据加载器中提取图像的 10 个时期:

train_data = torchvision.datasets.ImageFolder(root=train_data_path,
transform=image_transforms)
batch_size=32
train_data_loader = torch.utils.data.DataLoader(train_data,
batch_size=batch_size)

optimizer = optim.Adam(model.parameters(), lr=2e-2)
criterion = nn.CrossEntropyLoss()

def train(model, optimizer, loss_fn,  train_loader, val_loader,
epochs=20, device='cuda:0'):
    model.to(device)
    for epoch in range(epochs):
        print(f"epoch {epoch}")
        model.train()
        for batch in train_loader:
            optimizer.zero_grad()
            ww, target = batch
            ww = ww.to(device)
            target= target.to(device)
            output = model(ww)
            loss = loss_fn(output, target)
            loss.backward()
            optimizer.step()

        model.eval()
        num_correct = 0
        num_examples = 0
        for batch in val_loader:
            input, target = batch
            input = input.to(device)
            target= target.to(device)
            output = model(input)
            correct = torch.eq(torch.max(output, dim=1)[1], target).view(-1)
            num_correct += torch.sum(correct).item()
            num_examples += correct.shape[0]
        print("Epoch {}, accuracy = {:.2f}"
        .format(epoch, num_correct / num_examples))

train(model,optimizer,criterion,
train_data_loader,train_data_loader,epochs=10)

让我们像以前一样在py-spy下运行该代码:

py-spy -r 99 -d 120 --flame slowloader.svg -- python slowloader.py

如果你打开生成的slowloader.svg,你应该会看到类似于图 7-10 的东西。尽管火焰图大部分时间都被用于加载图像并将其转换为张量,但我们在应用随机噪声上花费了采样运行时间的 16.87%。看看代码,我们的BadRandom实现是在 PIL 阶段应用噪声,而不是在张量阶段,所以我们受制于图像处理库和 NumPy,而不是 PyTorch 本身。因此,我们的第一个想法可能是重写转换,使其在张量而不是 PIL 图像上操作。这可能会更快,但并非总是如此——在进行性能更改时的重要事情始终是要测量一切。

带有 BadRandom 的火焰图

图 7-10。带有 BadRandom 的火焰图

但有一件奇怪的事情,一直贯穿整本书,尽管我直到现在才注意到它:你是否注意到我们从数据加载器中提取批次,然后将这些批次放入 GPU?因为转换发生在加载器从数据集类获取批次时,这些转换总是会在 CPU 上发生。在某些情况下,这可能会导致一些疯狂的横向思维。我们在每个图像上应用随机噪声。如果我们能一次在每个图像上应用随机噪声呢?

这里可能一开始看起来有点费解的部分是:我们向图像添加随机噪声。我们可以将其写为x + y,其中x是我们的图像,y是我们的噪声。我们知道图像和噪声都是 3D 的(宽度、高度、通道),所以这里我们所做的就是矩阵乘法。在一个批次中,我们将这样做z次。我们只是在从加载器中取出每个图像时对每个图像进行迭代。但请考虑,在加载过程结束时,图像被转换为张量,一个批次的*[z, c, h, w]。那么,你难道不能只是添加一个形状为[z, c, h, w]*的随机张量,以这种方式应用随机噪声吗?而不是按顺序应用噪声,它一次性完成。现在我们有了一个矩阵运算,以及一个非常昂贵的 GPU,它碰巧非常擅长矩阵运算。在 Jupyter Notebook 中尝试这样做,看看 CPU 和 GPU 张量矩阵操作之间的差异:

cpu_t1 = torch.rand(64,3,224,224)
cpu_t2 = torch.rand(64,3,224,224)
%timeit cpu_t1 + cpu_t2
>> 5.39 ms ± 4.29 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

gpu_t1 = torch.rand(64,3,224,224).to("cuda")
gpu_t2 = torch.rand(64,3,224,224).to("cuda")
%timeit gpu_t1 + gpu_t2
>> 297 µs ± 338 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)

这样做的速度快了近 20 倍。我们可以将这个转换从我们的数据加载器中取出,等到整个批次都准备好后再执行矩阵运算:

def add_noise_gpu(tensor, device):
  random_noise = torch_rand_like(tensor).to(device)
  return tensor.add_(random_noise)

在我们的训练循环中,在input.to(device)之后添加这行:

input = add_noise_gpu(input, device)

然后从转换管道中移除BadRandom转换,并使用py-spy再次进行测试。新的火焰图显示在图 7-11 中。它如此之快,以至于它甚至不再在我们的采样频率下显示。我们刚刚将代码加速了近 17%!现在,并非所有标准转换都可以以 GPU 友好的方式编写,但如果可能的话,如果转换正在减慢你的速度,那么这绝对是一个值得考虑的选项。

带有 GPU 加速随机噪声的火焰图

图 7-11。带有 GPU 加速随机噪声的火焰图

现在我们已经考虑了计算,是时候看看房间里的另一个大象了:内存,特别是 GPU 上的内存。

调试 GPU 问题

在本节中,我们将更深入地研究 GPU 本身。在训练更大的深度学习模型时,您很快会发现,您花了很多钱购买的闪亮 GPU(或者更明智地,连接到基于云的实例)经常陷入困境,痛苦地抱怨内存不足。但是那个 GPU 有几千兆字节的存储空间!您怎么可能用完?

模型往往会占用大量内存。例如,ResNet-152 大约有 6000 万个激活,所有这些都占据了 GPU 上宝贵的空间。让我们看看如何查看 GPU 内部,以确定在内存不足时可能发生了什么。

检查您的 GPU

假设您正在使用 NVIDIA GPU(如果使用其他设备,请查看备用 GPU 供应商的驱动程序网站以获取他们自己的实用程序),CUDA 安装包括一个非常有用的命令行工具,称为nvidia-smi。当不带参数运行时,此工具可以为您提供有关 GPU 上使用的内存的快照,甚至更好的是,是谁在使用它!图 7-12 显示了在终端中运行nvidia-smi的输出。在笔记本中,您可以通过使用!nvidia-smi调用该实用程序。

从 nvidia-smi 输出

图 7-12。从 nvidia-smi 输出

这个示例来自我家里运行的一台 1080 Ti 机器。我正在运行一堆笔记本,每个笔记本都占用了一部分内存,但有一个占用了 4GB!您可以使用os.getpid()获取笔记本的当前 PID。结果表明,占用最多内存的进程实际上是我用来测试上一节中 GPU 变换的实验性笔记本!您可以想象,随着模型、批数据以及前向和后向传递的数据,内存很快会变得紧张。

注意

我还有一些进程在运行,也许令人惊讶的是,正在进行图形处理——即 X 服务器和 GNOME。除非您构建了本地机器,否则几乎肯定看不到这些。

此外,PyTorch 将为每个进程分配大约 0.5GB 的内存给自身和 CUDA。这意味着最好一次只处理一个项目,而不要像我这样到处运行 Jupyter Notebook(您可以使用内核菜单关闭与笔记本连接的 Python 进程)。

仅运行nvidia-smi将为您提供 GPU 使用情况的当前快照,但您可以使用-l标志获得持续输出。以下是一个示例命令,每 5 秒将转储时间戳、已使用内存、空闲内存、总内存和 GPU 利用率:

nvidia-smi --query-gpu=timestamp,
memory.used, memory.free,memory.total,utilization.gpu --format=csv -l 5

如果您真的认为 GPU 使用的内存比应该使用的要多,可以尝试让 Python 的垃圾收集器参与其中。如果您有一个不再需要的tensor_to_be_deleted,并且希望它从 GPU 中消失,那么来自 fast.ai 库深处的一个提示是使用del将其推开:

import gc
del tensor_to_be_deleted
gc.collect()

如果您在 Jupyter Notebook 中进行大量工作,创建和重新创建模型,可能会发现删除一些引用并通过使用gc.collect()调用垃圾收集器将收回一些内存。如果您仍然遇到内存问题,请继续阅读,因为可能会有解决您困扰的答案!

梯度检查点

尽管在上一节中介绍了所有删除和垃圾收集技巧,您可能仍然会发现自己内存不足。对于大多数应用程序来说,下一步要做的事情是减少在训练循环中通过模型的数据批量大小。这样做会起作用,但您将增加每个时代的训练时间,并且很可能模型不会像使用足够内存处理更大批量大小的等效模型那样好,因为您将在每次传递中看到更多数据集。但是,我们可以通过使用梯度检查点在 PyTorch 中为大型模型交换计算和内存。

处理更大模型时的一个问题是,前向和后向传递会产生大量中间状态,所有这些状态都会占用 GPU 内存。梯度检查点的目标是通过分段模型来减少可能同时存在于 GPU 上的状态量。这种方法意味着您可以在非分段模型的情况下具有四到十倍的批量大小,但这会使训练更加计算密集。在前向传递期间,PyTorch 会将输入和参数保存到一个段中,但实际上不执行前向传递。在后向传递期间,PyTorch 会检索这些内容,并为该段计算前向传递。中间值会传递到下一个段,但这些值必须仅在段与段之间执行。

将模型分割成这些段的工作由torch.utils.checkpoint.checkpoint_sequential()处理。它适用于nn.Sequential层或生成的层列表,但需要注意它们需要按照模型中出现的顺序排列。以下是它在 AlexNet 的features模块上的工作方式:

from torch.utils.checkpoint import checkpoint_sequential
import torch.nn as nn

class CheckpointedAlexNet(nn.Module):

    def __init__(self, num_classes=1000, chunks=2):
        super(CheckpointedAlexNet, self).__init__()
        self.features = nn.Sequential(
            nn.Conv2d(3, 64, kernel_size=11, stride=4, padding=2),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=3, stride=2),
            nn.Conv2d(64, 192, kernel_size=5, padding=2),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=3, stride=2),
            nn.Conv2d(192, 384, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.Conv2d(384, 256, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.Conv2d(256, 256, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=3, stride=2),
        )
        self.avgpool = nn.AdaptiveAvgPool2d((6, 6))
        self.classifier = nn.Sequential(
            nn.Dropout(),
            nn.Linear(256 * 6 * 6, 4096),
            nn.ReLU(inplace=True),
            nn.Dropout(),
            nn.Linear(4096, 4096),
            nn.ReLU(inplace=True),
            nn.Linear(4096, num_classes),
        )

    def forward(self, x):
        x = checkpoint_sequential(self.features, chunks, x)
        x = self.avgpool(x)
        x = x.view(x.size(0), 256 * 6 * 6)
        x = self.classifier(x)
        return x

正如您所看到的,当需要时,检查点是模型的一个简单补充。我们在新版本的模型中添加了一个chunks参数,默认情况下将其分成两个部分。然后,我们只需要调用checkpoint_sequentialfeatures模块,段数和我们的输入。就是这样!

在检查点中的一个小问题是,它与BatchNormDropout层的交互方式会导致不良行为。为了解决这个问题,您可以在这些层之前和之后只检查点模型的部分。在我们的CheckpointedAlexNet中,我们可以将classifier模块分成两部分:一个包含未检查点的Dropout层,以及一个包含我们的Linear层的最终nn.Sequential模块,我们可以以与features相同的方式检查点。

如果您发现为了使模型运行而减少批量大小,请在要求更大的 GPU 之前考虑检查点!

结论

希望现在您已经具备了在训练模型不如预期时寻找答案的能力。从清理数据到运行火焰图或 TensorBoard 可视化,您有很多工具可供使用;您还看到了如何通过 GPU 转换以及使用检查点来交换内存和计算。

拥有经过适当训练和调试的模型,我们正走向最严酷的领域:生产

进一步阅读