23  开源代码释读

在这篇论文开放的源代码中 (“Csho33/Bacteria-ID: Source Code and Demos for "Rapid Identification of Pathogenic Bacteria Using Raman Spectroscopy and Deep Learning",” n.d.),包含以下文件:

  1. 1_reference_finetuning.ipynb - 展示如何对预训练的卷积神经网络进行微调,以实施 30 种菌株分类任务
  2. 2_prediction.ipynb- 展示如何使用调优后的卷积神经网络进行预测
  3. 3_clinical_finetuning.ipynb - 展示如何使用临床数据对预训练的卷积神经网络进行调优,并对个体患者进行预测
  4. config.py - 包含提供数据集的相关信息
  5. datasets.py - 包含设置光谱数据的数据集和数据加载器的代码
  6. resnet.py - 包含 ResNet 卷积神经网络模型类
  7. training.py - 包含训练卷积神经网络和进行预测的代码
  8. reference_model.ckpt - 保存的预训练卷积神经网络参数,用于笔记本1和2
  9. clinical_model.ckpt - 保存的预训练卷积神经网络参数,用于演示3

23.1 Notebooks

代码库中包含了 3 个 *.ipynb 文件,它们的内容大同小异,分别执行不同的任务。

  1. 1_reference_finetuning.ipynb - 展示如何对预训练的卷积神经网络进行微调,以实施 30 种菌株分类任务
  2. 2_prediction.ipynb- 展示如何使用调优后的卷积神经网络进行预测
  3. 3_clinical_finetuning.ipynb - 展示如何使用临床数据对预训练的卷积神经网络进行调优,并对个体患者进行预测

以第 1 个 Notebook 为例,解释说明一下:

第一块代码:导入使用到的基本模块。

from time import time
t00 = time()
import numpy as np

第二块代码:导入数据,打印数据的形状。X 包含了 3000 行数据,每行有 1000 列。这说明拉曼光谱一共采集了 1000 个波段的数据。y 的行数与 X 一样,但是只有一维。说明它保存的是物种信息。查看它的值,恰好是 0 - 29 之间的数字,应当是分别代表 30 个物种的编号。

X_fn = './data/X_finetune.npy'
y_fn = './data/y_finetune.npy'
X = np.load(X_fn)
y = np.load(y_fn)
print(X.shape, y.shape)
(3000, 1000) (3000,)

第三块代码:导入神经网络相关的模块。

from resnet import ResNet
import os
import torch

第四块代码:设置模型的参数。

# CNN 参数
layers = 6
hidden_size = 100
block_size = 2
hidden_sizes = [hidden_size] * layers
num_blocks = [block_size] * layers
input_dim = 1000
in_channels = 64
n_classes = 30
os.environ['CUDA_VISIBLE_DEVICES'] = '{}'.format(0)
cuda = torch.cuda.is_available()

第五块代码:创建模型,并加载保存的权重。

# 为演示加载训练好的权重
cnn = ResNet(hidden_sizes, num_blocks, input_dim=input_dim,
                in_channels=in_channels, n_classes=n_classes)
if cuda: cnn.cuda()
cnn.load_state_dict(torch.load(
    './pretrained_model.ckpt', map_location=lambda storage, loc: storage))
<All keys matched successfully>

第六块代码:导入额外的模块,包括自定义的 spectral_dataloaderrun_epoch 方法。

from datasets import spectral_dataloader
from training import run_epoch
from torch import optim

第七块代码:生成两个索引 idx_validx_tr,以便于将数据随机产分成训练集和测试集。

p_val = 0.1
n_val = int(3000 * p_val)
idx_tr = list(range(3000))
np.random.shuffle(idx_tr)
idx_val = idx_tr[:n_val]
idx_tr = idx_tr[n_val:]

第八块代码:拆分数据集,并进行训练和验证。

# 微调 CNN
epochs = 1 # 将这个数字改为约30来进行完整训练
batch_size = 10
t0 = time()
# 设置 Adam 优化器
optimizer = optim.Adam(cnn.parameters(), lr=1e-3, betas=(0.5, 0.999))
# 设置数据加载器
dl_tr = spectral_dataloader(X, y, idxs=idx_tr, batch_size=batch_size, shuffle=True)
dl_val = spectral_dataloader(X, y, idxs=idx_val, batch_size=batch_size, shuffle=False)
# 微调 CNN 的第一阶段
best_val = 0
no_improvement = 0
max_no_improvement = 5
print('开始微调!')
for epoch in range(epochs):
    print(' Epoch {}: {:0.2f}s'.format(epoch+1, time()-t0))
    # 训练
    acc_tr, loss_tr = run_epoch(epoch, cnn, dl_tr, cuda,
        training=True, optimizer=optimizer)
    print('  训练准确率: {:0.2f}'.format(acc_tr))
    # 验证
    acc_val, loss_val = run_epoch(epoch, cnn, dl_val, cuda,
        training=False, optimizer=optimizer)
    print('  验证准确率: {:0.2f}'.format(acc_val))
    # 早停检查性能
    if acc_val > best_val or epoch == 0:
        best_val = acc_val
        no_improvement = 0
    else:
        no_improvement += 1
    if no_improvement >= max_no_improvement:
        print('在 {} 轮后结束!'.format(epoch+1))
        break
print('\n 这个演示完成耗时: {:0.2f}s'.format(time()-t00))

23.2 config.py - 包含提供数据集的相关信息

这个文件定义了数据集中编号对应的菌株。

例如:

STRAINS[0] = "C. albicans"
STRAINS[1] = "C. glabrata"
STRAINS[2] = "K. aerogenes"
STRAINS[3] = "E. coli 1"

23.3 datasets.py - 包含设置光谱数据的数据集和数据加载器的代码

这段代码定义了如何在PyTorch中加载和处理光谱数据,以便用于机器学习模型的训练和评估。让我们逐步分析这里的组件和功能。

  1. 导入库和模块:

    • 使用PyTorch的DatasetDataLoader来处理数据集与数据加载。
    • 使用PyTorch的transforms模块来进行数据转换。
    • 还导入了torchnumpy库以支持数组和张量操作。
  2. SpectralDataset 类:

    • 这是一个继承自Dataset的自定义类,用于建立光谱数据集。
    • 它可以接收文件名(字符串形式,然后从中加载NumPy数组)或直接接收NumPy数组来初始化。
    • idxs 参数定义了从数据集中使用那些样本,这可以用于随机拆分数据集到训练、验证和测试集。
    • transform 参数允许在读取样本时应用一连串的数据变换操作。
    • __len__ 方法返回数据集中样本的数量。
    • __getitem__ 方法根据索引获取单个样本,并且可以通过自定义的变换来增加一个新的维度(如果提供了变换)。
  3. 数据转换(Transforms):

    • GetInterval 类从每个光谱中获取一段指定的区间。
    • ToFloatTensor 类将NumPy数组转换成PyTorch的浮点型张量(tensor)。
    • 这些类实现了__call__方法,使得它们可以像函数一样被调用,并直接作用于数据。
  4. spectral_dataloader 函数:

    • 基于提供的文件名或NumPy数组、索引和其它参数,这个函数创建并返回一个DataLoader对象。
    • 在创建DataLoader前,它使用了一个转换列表,并将这些转换合并(Compose)成一个执行链。
  5. spectral_dataloaders 函数:

    • 通过随机分割数据集,这个函数返回训练集、验证集和测试集的DataLoader对象。
    • 如果没有指定数据集的细分数量,它将依据百分比来计算训练和验证集的大小。
    • 根据提供的参数,分别使用spectral_dataloader函数来创建并返回训练集、验证集和测试集的DataLoader对象。

总而言之,这段代码提供了一个完整的管道,从加载和变换光谱数据到为机器学习模型的训练、验证和测试准备好数据迭代器(DataLoader)。

23.4 resnet.py - 包含 ResNet 卷积神经网络模型类

这段代码定义了两个神经网络模型(类)和一个函数,用于构建残差网络(ResNet)结构主要用于处理一维信号。下面是对这段代码的具体解释:

23.4.1 ResidualBlock

ResidualBlock 是一个继承自 nn.Module 的类,代表一个残差模块,是构成 ResNet 的基本单元。一个残差模块通常包括两个卷积层,并引入一条“捷径”(shortcut)或“旁路”,其目的是为了解决深层网络训练中的退化问题(Degradation Problem)。

  1. __init__ 方法初始化残差模块,接受以下参数:

    • in_channels: 输入信号的通道数。
    • out_channels: 输出信号的通道数。
    • stride: 卷积层的步长(stride)。
  2. 构建两个含有批量归一化(Batch Normalization)的卷积层 conv1conv2

  3. 对于捷径路径,如果输入和输出信道数不同或步长不为1,需要一个额外的卷积层和批量归一化来匹配维度。

  4. forward 方法定义了数据在残差模块中的前向传播过程。输入 x 首先通过第一个卷积层、批量归一化和ReLU激活函数,然后再通过第二个卷积层和批量归一化。与此同时,输入 x 也通过捷径路径。最后将两个输出相加,再次应用 ReLU 激活函数,并返回结果。

23.4.2 ResNet

ResNet 也是继承自 nn.Module 的类,代表整个残差网络模型。

  1. __init__ 方法初始化网络,接收以下参数:

    • hidden_sizes: 各个残差模块的隐藏层大小的列表。
    • num_blocks: 每个隐藏层大小对应的残差模块数量的列表。
    • input_dim: 输入数据的维度。
    • in_channels: 输入信号的通道数。
    • n_classes: 用于分类任务的输出类别数量。
  2. conv1bn1 分别表示第一个卷积层和其批量归一化层,用于处理原始输入信号。

  3. layers 列表和 strides 列表分别构建了网络的不同隐藏层的残差模块和对应的步长。

  4. encoder 表示一个由残差模块组成的编码器。

  5. z_dim 是编码后的维度,通过在一些随机输入上运行 encode 方法获取。

  6. linear 是一个全连接层,将编码后的输出转换为类别预测。

  7. encode 方法执行编码过程,将输入 x 通过第一个卷积层、批量归一化、编码器,最后将编码后的数据展平。

  8. forward 方法将输入信号编码并通过全连接层获取最终类别预测。

  9. _make_layer 方法构建残差模块的层。

  10. _get_encoding_size 通过输入一些随机数据来计算编码器的输出维度。

23.4.3 add_activation 函数

这个函数是一个特殊的辅助函数,返回指定名称的激活函数的实例。它接受一个字符串参数 activation,用于选择所需的激活函数。.FloatTensor

这些组件一起使用可以构建一个能够适应不同输入输出需求的弹性神经网络模型,特别适合于一维信号的处理任务,如语音识别、时间序列分析等。

总之,这个文件的作用就是仿照 ResNet 的架构构建了一个可用于一维信号处理任务的 ResNet。前面的 ResNet 指的是用于进行图片分类任务的 ResNet 本体;后面的 ResNet 指的是利用残差网络思想的一类 CNN 的名称。

Note

ResNet(Residual Network,残差网络)是一个深层的卷积神经网络(CNN)架构。它由微软研究院的研究者 Kaiming He 等人在2015年提出,并在同年的 ImageNet 竞赛中获得了胜利,大幅提升了图像识别任务的准确率。

核心概念:残差学习 (Residual Learning)

随着网络深度的增加,理论上模型的表达能力也应随之提高。然而,在实际操作中,直接训练深度很深的网络往往会遇到两个主要的问题: 1. 梯度消失或者梯度爆炸:随着网络层数的增加,梯度在反向传播过程中会逐渐消失或者爆炸,导致网络训练更加困难,甚至无法收敛。 2. 网络退化问题 (Degradation Problem):网络深度增加到一定程度后,准确率会饱和甚至下降,而不是因为过拟合。

为了解决这些问题,ResNet 引入了残差学习框架。如果一个浅层模型已经足够优秀,那么通过堆叠更多层来获得理论上更强大的表达能力时,除了学习新特征外,新增的层只需学习与浅层模型的输出差异(即残差),这比学习一个全新的特征映射要容易得多。

ResNet 的结构

一个标准的残差单元包括一个或多个卷积层,以及在这些卷积层之间的快捷连接(skip connection 或 shortcut),该连接直接跳过一些层。

这样,在模型的学习过程中,即使有些层没有学到有用的信息,快捷连接仍然可以容许较低/较浅层次的信息向前传递,从而有效缓解梯度消失问题,并有助于信号直接传递,使得模型可以训练得更深。

不同版本的 ResNet

ResNet 有不同层数的变体,包括但不限于 ResNet-18, ResNet-34, ResNet-50, ResNet-101 和 ResNet-152。其中数字表示网络层数。ResNet-50 之后的模型使用了瓶颈设计(bottleneck design),使用3个卷积层代替每个残差块中的2个,这样在增加更深的层数的同时,保持了计算复杂度。

此外,ResNet 常常用作许多视觉任务的基础架构,并且已被证明即使在非图像任务中也有不错的效果。由于其出色的性能和广泛的适用性,ResNet 成为深度学习领域的一个里程碑,并为后来的很多研究提供了灵感。

23.5 training.py - 包含训练卷积神经网络和进行预测的代码

这段代码包含了两个函数 run_epochget_predictions,它们用于在使用 PyTorch 进行机器学习工作流程中的训练和预测阶段。

23.5.1 run_epoch 函数:

run_epoch 函数负责在给定数据集上训练模型一个epoch或者评估模型。

参数:

  • epoch:当前的epoch索引。这在函数中没有被使用,但可以用于日志或回调。
  • model:要使用的PyTorch神经网络模型。
  • dataloader:一个提供数据批次的PyTorch DataLoader
  • cuda:一个布尔值,指示是否在GPU上进行处理。如果为 True,则会将数据和模型移至GPU内存。
  • training:一个布尔值,指示模型应处于训练模式(True)还是评估模式(False)。
  • optimizer:用于在训练期间执行反向传播的优化器。仅当 training 设置为 True 时使用。

行为:

  • 根据 training 标志,设置模型为训练模式或评估模式。
  • 初始化 total_losscorrecttotal 来跟踪损失,正确预测的数量和数据点的总数。
  • dataloader 中进行迭代,必要时将数据移至GPU,并将张量封装在 Variable 中。(注:从PyTorch 0.4.0开始,Variable 和张量已合并,这一行不是必需的)。
  • 对于每一批数据,使用模型进行预测,计算交叉熵损失,并且如果在训练模式下,执行反向传播和优化器步骤。
  • 跟踪总损失和正确预测的数量。
  • 计算并返回epoch的准确率和平均损失。

23.5.2 get_predictions 函数:

get_predictions 函数使用训练好的模型对提供的数据进行预测,或者获取类别的概率。

参数:

  • model:训练好的PyTorch神经网络模型。
  • dataloader:一个提供数据批次的PyTorch DataLoader
  • cuda:一个指示是否在GPU上处理的布尔值。
  • get_probs:一个布尔值,指示是否检索原始softmax概率(True)而不是预测类别索引(False)。

行为:

  • 将模型设置为评估模式。
  • dataloader 中进行迭代,必要时将数据移至GPU,并将张量封装在 Variable 中。(注:从PyTorch 0.4.0开始,这也不是必需的)。
  • 使用模型进行预测。如果 get_probsTrue,它会对输出应用 softmax 函数来获取预测概率;否则,它取argmax来获取预测的类别索引。
  • 将结果附加到列表 preds 中。
  • 收集所有预测或概率后,将它们作为NumPy数组返回。如果请求了概率,则将所有批次沿第一维度堆叠;否则,只是从预测类别的列表中创建一个数组。

注意:

  • 正如所提到的,使用 Variable 已经过时,在PyTorch 0.4.0及以上版本不再需要。
  • 对于 cuda 的检查以及数据/模型在GPU之间的传输,可以用更优雅的 PyTorch 功能来管理,比如 to(device),其中 device 可以是 'cuda''cpu'
  • get_predictions 函数可以根据 get_probs 标志返回类概率或类索引。

23.6 模型权重文件

代码库中提供了 3 个预训练模型的权重文件。分别是:

  1. pretrained_model.ckpt
  2. clinical_pretrained_model.ckpt
  3. finetuned_model.ckpt
Note

.ckpt 文件扩展名是一个约定俗成的用来表示 checkpoint(检查点)的文件扩展名。在机器学习和深度学习中,checkpoint 文件用于存储训练模型的状态,可以用于恢复或者继续训练进程,或者用于模型的评估和部署。

当使用如 TensorFlow、PyTorch 这样的深度学习框架时,.ckpt 文件通常包含了以下信息:

  1. 模型参数(Weights and Biases):这是训练过程中学习到的网络的核心,包括每一层的权重(weights)和偏差(biases)。

  2. 优化器状态:这包括了用于训练过程中优化算法的状态信息(如动量、学习率等),这对于继续中断的训练非常重要。

  3. 其他训练状态相关信息:例如,当前的epoch数、执行迭代的次数、最新的loss函数值等。

这样,如果训练过程因为某些原因(如硬件故障、电源中断等)被迫中断,.ckpt 文件可以用来重载训练时的状态,从而无缝地继续训练,而不是从头开始。另外,也可以使用这个文件来对训练后的模型性能进行评估和测试。

在实际操作中,根据使用的框架不同,.ckpt 文件的详细格式会有所不同。例如在 TensorFlow 中,检查点可能会由多个文件组成,并包含 .index 文件和 .data 文件的组合,以及一个 .meta 文件保存了模型的图信息。在 PyTorch 中,通常是一个单一的 .p.pt 文件,或者是多个 .ckpt 文件,这取决于保存检查点的具体方法。

简而言之,.ckpt 文件是深度学习中一个非常接重要的组件,允许数据科学家保存和恢复训练模型的状态。

要比较三个 .ckpt 文件(假设是使用 PyTorch 保存的模型权重)之间的差异,你可以加载每一个文件,并提取模型的权重,然后逐个比较权重矩阵。以下是一个PyTorch的基本的步骤,用于比较三个检查点文件的权重差异:

import torch

# 列出你的 .ckpt 文件路径
file_paths = ['pretrained_model.ckpt', 'clinical_pretrained_model.ckpt', 'finetuned_model.ckpt']

import torch
import numpy as np

# 创建一个函数来加载所有的 .ckpt 文件
def load_checkpoints(file_paths):
    checkpoints = []
    for path in file_paths:
        checkpoints.append(torch.load(path, map_location=torch.device('cpu')))
    return checkpoints

# 检查 keys 是否相同,并计算权重值相同和不同的 keys 数量
def compare_checkpoints(checkpoints):
    keys_list = [set(ckpt.keys()) for ckpt in checkpoints]
    intersection_keys = set.intersection(*keys_list)

    # 列出共有的和特有的 keys 数量
    unique_keys = [keys - intersection_keys for keys in keys_list]
    print(f"Number of common keys: {len(intersection_keys)}")
    for i, unique in enumerate(unique_keys):
        print(f"Number of keys only in checkpoint {i+1}: {len(unique)}")
        
    # 如果所有 keys 都相同
    if all(keys == intersection_keys for keys in keys_list):
        print("All checkpoints have the same keys.")
        same_values_keys = []
        different_values_keys = []
        for key in intersection_keys:
            # 比较每个检查点中对应键的权重值
            weights = [ckpt[key].numpy() for ckpt in checkpoints]
            if all(np.array_equal(weights[0], w) for w in weights[1:]):
                same_values_keys.append(key)
            else:
                different_values_keys.append(key)

        print(f"Number of keys with the exact same weights: {len(same_values_keys)}")
        print(f"Number of keys with differing weights: {len(different_values_keys)}")

        return same_values_keys, different_values_keys

比较 3 个权重文件。

# 加载所有 .ckpt 文件
checkpoints = load_checkpoints(file_paths)

# 获取比较结果
compare_checkpoints(checkpoints)
Number of common keys: 157
Number of keys only in checkpoint 1: 0
Number of keys only in checkpoint 2: 0
Number of keys only in checkpoint 3: 31

比较前 2 个权重文件。

# 获取比较结果
same_values_keys, different_values_keys = compare_checkpoints(checkpoints[0:2])

# 如果需要,可以进一步处理或打印相同和不同的 keys 信息
# 例如:
# print("Keys with the same weights:", same_values_keys)
# print("Keys with differing weights:", different_values_keys)
Number of common keys: 157
Number of keys only in checkpoint 1: 0
Number of keys only in checkpoint 2: 0
All checkpoints have the same keys.
Number of keys with the exact same weights: 0
Number of keys with differing weights: 157

在这个脚本中,我们首先加载了每个 .ckpt 文件。之后定义了一个函数 compare_weights,它会遍历每组权重的键(通常对应模型中的每一层),并计算两两之间的Frobenius范数(使用 numpy.linalg.norm)。Frobenius范数是度量矩阵(在这种情况下指权重矩阵)元素值的一种手段,可以作为比较它们相似度的一个指标。

最后,我们输出每个权重矩阵之间比较的结果,这样可以直观地看到不同检查点之间权重的具体差异。

请注意,上述代码片段假定所有 .ckpt 文件中包含相同的权重键,且这些权重是保存在同一模型结构中。如果模型结构不同,需要做进一步的处理来确保权重可以匹配对比。