自然语言处理前馈网络

内容介绍

众所周知,感知器是现存最简单的神经网络。但是感知器的一个的缺点就是它无法有效处理非线性可分的数据模式,这在处理复杂数据时可能导致其失效例如,图1中绘制的数据点。这相当于非此即彼(XOR)的情况,在这种情况下,决策边界不能是一条直线(线性可分)。这个例子中,感知器并不适用。

图1 XOR数据集中的两个类绘制为圆形和星形。我们发现不能用一条直线将其分开

我们将探索传统上称为前馈网络的神经网络模型,以及两种前馈神经网络:多层感知器和卷积神经网络。多层感知器在结构上扩展了我们研究的简单感知器,将多个感知器分组在一个单层,并将多个层叠加在一起。我们稍后将首先介绍多层感知器。

我们研究的第二种前馈神经网络,卷积神经网络,在处理数字信号时深受窗口滤波器的启发。通过这种窗口特性,卷积神经网络能够在输入中学习局部化模式,这不仅使其成为计算机视觉的主轴,而且是检测单词和句子等序列数据中的子结构的理想候选。

实验环境

Python 3.6.7

多层感知机(The Multilayer Perceptron)

多层感知机(MLP)是一种基本的前馈神经网络模型,通过多层次的结构实现对复杂模式的学习和分类任务的执行。它扩展了最简单形式的感知器模型,克服了感知器无法解决非线性可分问题的限制。

结构组成

  1. 神经元与层次结构

    • 神经元(Perceptron):MLP的基本组成单元,每个神经元接收多个输入信号,通过加权求和后应用激活函数得出输出。
    • 层(Layer):神经元按层次组织,每层神经元与前一层的所有神经元相连,每个连接都有一个权重。
  2. 多层次的组织

    • 输入层(Input Layer):接收输入数据特征的层。
    • 隐藏层(Hidden Layer):在输入层和输出层之间的层,每个隐藏层都由多个神经元组成。隐藏层的存在使得MLP能够学习复杂的非线性关系。
    • 输出层(Output Layer):最终产生MLP的输出,通常与任务类型相关联,例如分类任务的输出层可能是一个softmax层,回归任务可能是一个线性层。

工作原理

  • 前馈传播(Feedforward Propagation):数据从输入层经过每一层的神经元传递,通过加权求和和激活函数的计算,最终产生输出。

  • 学习与优化

    • 反向传播(Backpropagation):MLP通过反向传播算法来学习和优化模型参数(权重和偏置),以最小化预测输出与实际标签之间的误差。
    • 损失函数(Loss Function):用于衡量预测输出与实际标签之间的差异,通常使用交叉熵(Cross-Entropy)用于分类任务,均方误差(Mean Squared Error)用于回归任务。

优点

  • 处理非线性问题:相比于单层感知器,MLP通过多层次的非线性变换可以更好地适应和学习复杂的数据模式和关系。

  • 通用性:适用于多种机器学习任务,如分类、回归、聚类等,只需适当调整输出层和损失函数。

最简单的MLP,如图2所示,由三个表示阶段和两个线性层组成。第一阶段是输入向量。这是给定给模型的向量。给定输入向量,第一个线性层计算一个隐藏向量——表示的第二阶段。隐藏向量之所以这样被调用,是因为它是位于输入和输出之间的层的输出。

我们所说的“层的输出”是什么意思?理解这个的一种方法是隐藏向量中的值是组成该层的不同感知器的输出。使用这个隐藏的向量,第二个线性层计算一个输出向量。在像Yelp评论分类这样的二进制任务中,输出向量仍然可以是1。虽然在这个例子中,我们只展示了一个隐藏的向量,但是有可能有多个中间阶段,每个阶段产生自己的隐藏向量。最终的隐藏向量总是通过线性层和非线性的组合映射到输出向量。

图2 一种具有两个线性层和三个表示阶段(输入向量、隐藏向量和输出向量)的MLP的可视化表示

XOR的一个例子

根据前面对于XOR的解释,我们在一个二元分类任务中训练感知器和MLP:星和圆。每个数据点是一个二维坐标。在不深入研究实现细节的情况下,最终的模型预测如图3所示。在这个图中,错误分类的数据点用黑色填充。在图左中,从填充的形状可以看出,感知器在学习一个可以将星星和圆分开的决策边界方面有困难。然而,MLP(右边)学习了一个更精确地对恒星和圆进行分类的决策边界

图3 从感知器(左)和MLP(右)学习的XOR问题的解决方案显示

图3中,每个数据点的真正分类是该点的形状:星形或圆形。在左边的图中,感知器学习—个不能正确地将圆与星分开的决策边界。在右边的图中,MLP学会了从圆中分离星。

虽然在图中显示MLP有两个决策边界,但它实际上只是一个决策边界,因为中间表示法改变了空间,使一个超平面同时出现在这两个位置上。在图4中,我们可以看到MLP计算的中间值。这些点的形状表示类(星形或圆形)。

图4 MLP的输入和中间表示是可视化的。从左到右:(1)网络的输入;(2)第一个线性模块的输出;(3)第一个非线性模块的输出;(4)第二个线性模块的输出。第一个线性模块的输出将圆和星分组,而第二个线性模块的输出将数据点重新组织为线性可分的。

相反,如图5所示,感知器没有额外的一层来处理数据的形状,直到数据变成线性可分的。

图5 感知器的输入和输出表示。因为它没有像MLP那样的中间表示来分组和重新组织,所以它不能将圆和星分开。

在PyTorch中实现MLP

在本节中,我们将介绍PyTorch中的一个实现。如前所述,MLP除了简单的感知器之外,还有一个额外的计算层。在我们在例4-1中给出的实现中,我们用PyTorch的两个线性模块实例化了这个想法。线性对象被命名为fc1和fc2,它们遵循一个通用约定,即将线性模块称为“完全连接层”,简称为“fc层”。除了这两个线性层外,还有一个修正的线性单元(ReLU)非线性,它在被输入到第二个线性层之前应用于第一个线性层的输出。由于层的顺序性,必须确保层中的输出数量等于下一层的输入数量。使用两个线性层之间的非线性是必要的,因为没有它,两个线性层在数学上等价于一个线性层4,因此不能建模复杂的模式。MLP的实现只实现反向传播的前向传递。这是因为PyTorch根据模型的定义和向前传递的实现,自动计算出如何进行向后传递和梯度更新

具体代码如下

import torch.nn as nn
import torch.nn.functional as F

class MultilayerPerceptron(nn.Module):
    def __init__(self, input_dim, hidden_dim, output_dim):
        """
        初始化多层感知器(MLP)模型。

        Args:
            input_dim (int): 输入向量的大小。
            hidden_dim (int): 第一个全连接层的输出大小(隐藏层大小)。
            output_dim (int): 第二个全连接层的输出大小(最终输出大小)。
        """
        super(MultilayerPerceptron, self).__init__()
        # 定义第一个全连接层,将输入维度映射到隐藏层维度
        self.fc1 = nn.Linear(input_dim, hidden_dim)
        # 定义第二个全连接层,将隐藏层维度映射到输出维度
        self.fc2 = nn.Linear(hidden_dim, output_dim)

    def forward(self, x_in, apply_softmax=False):
        """
        MLP的前向传播过程。

        Args:
            x_in (torch.Tensor): 输入数据张量。
                x_in 的形状应为 (batch, input_dim)。
            apply_softmax (bool): 是否应用 softmax 激活函数的标志。
                如果与交叉熵损失函数一起使用,应为 False。

        Returns:
            torch.Tensor: 输出张量。张量形状应为 (batch, output_dim)。
        """
        # 通过第一个全连接层并应用 ReLU 激活函数
        intermediate = F.relu(self.fc1(x_in))
        # 通过第二个全连接层得到输出
        output = self.fc2(intermediate)

        # 如果 apply_softmax 为真,应用 softmax 激活函数
        if apply_softmax:
            output = F.softmax(output, dim=1)
        return output

由于MLP实现的通用性,可以为任何大小的输入建模。为了演示,我们使用大小为3的输入维度、大小为4的输出维度和大小为100的隐藏维度。请注意,在print语句的输出中,每个层中的单元数很好地排列在一起,以便为维度3的输入生成维度4的输出。

batch_size = 2  # 一次输入的样本数量
input_dim = 3  # 输入向量的维度
hidden_dim = 100  # 隐藏层的维度
output_dim = 4  # 输出向量的维度

# 初始化多层感知器(MLP)模型
mlp = MultilayerPerceptron(input_dim, hidden_dim, output_dim)

# 打印模型结构
print(mlp)

结果如下

MultilayerPerceptron( (fc1): Linear(in_features=3, out_features=100, bias=True) (fc2): Linear(in_features=100, out_features=4, bias=True) )

我们可以通过传递一些随机输入来快速测试模型的“连接”,如下面示例所示。因为模型还没有经过训练,所以输出是随机的。 

import torch

def describe(x):
    """
    打印张量的类型、形状和值。

    Args:
        x (torch.Tensor): 要描述的输入张量。
    """
    print("Type: {}".format(x.type()))  # 打印张量的类型
    print("Shape/size: {}".format(x.shape))  # 打印张量的形状/大小
    print("Values: \n{}".format(x))  # 打印张量的值

# 生成随机输入张量,其形状为 (batch_size, input_dim)
x_input = torch.rand(batch_size, input_dim)

# 调用 describe 函数,打印张量的信息
describe(x_input)

结果如下

Type: torch.FloatTensor Shape/size: torch.Size([2, 3]) Values: tensor([[0.6193, 0.7045, 0.7812], [0.6345, 0.4476, 0.9909]])

y_output = mlp(x_input, apply_softmax=False)
describe(y_output)

Type: torch.FloatTensor Shape/size: torch.Size([2, 4]) Values: tensor([[ 0.2356, 0.0983, -0.0111, -0.0156], [ 0.1604, 0.1586, -0.0642, 0.0010]], grad_fn=<AddmmBackward>)

实际案例

案例一:基于多层感知机的姓氏分类

我们将MLP应用于将姓氏分类到其原籍国的任务。从公开观察到的数据推断人口统计信息(如国籍)具有从产品推荐到确保不同人口统计用户获得公平结果的应用。人口统计和其他自我识别信息统称为“受保护属性”。“在建模和产品中使用这些属性时,必须小心。”我们首先对每个姓氏的字符进行拆分,并像对待“示例:将餐馆评论的情绪分类”中的单词一样对待它们。除了数据上的差异,字符层模型在结构和实现上与基于单词的模型基本相似.

姓氏数据集

姓氏数据集,它收集了来自18个不同国家的10,000个姓氏,这些姓氏是作者从互联网上不同的姓名来源收集的。该数据集将在本课程实验的几个示例中重用,并具有一些使其有趣的属性。第一个性质是它是相当不平衡的。排名前三的课程占数据的60%以上:27%是英语,21%是俄语,14%是阿拉伯语。剩下的15个民族的频率也在下降——这也是语言特有的特性。第二个特点是,在国籍和姓氏正字法(拼写)之间有一种有效和直观的关系。有些拼写变体与原籍国联系非常紧密(比如“O ‘Neill”、“Antonopoulos”、“Nagasawa”或“Zhu”)。

class SurnameDataset(Dataset):
    """
    姓氏数据集类,用于加载和处理姓氏数据。

    Args:
        Dataset: PyTorch Dataset 类的子类。

    Attributes:
        _target_df (pandas.DataFrame): 包含姓氏和对应国籍的 DataFrame。
        _vectorizer (SurnameVectorizer): 用于向量化和反向量化姓氏的向量器。
    """

    def __getitem__(self, index):
        """
        根据给定索引返回数据集中的单个样本。

        Args:
            index (int): 要检索的样本的索引。

        Returns:
            dict: 包含输入姓氏向量和目标国籍索引的字典。
        """
        # 从目标 DataFrame 中获取具有给定索引的行
        row = self._target_df.iloc[index]
        # 使用向量器将姓氏向量化
        surname_vector = self._vectorizer.vectorize(row.surname)
        # 使用向量器查找目标国籍的索引
        nationality_index = self._vectorizer.nationality_vocab.lookup_token(row.nationality)

        # 返回包含姓氏向量和目标国籍索引的字典
        return {'x_surname': surname_vector,
                'y_nationality': nationality_index}
class SurnameVectorizer(object):
    """协调词汇表并将其应用的向量器"""

    def __init__(self, surname_vocab, nationality_vocab):
        """
        初始化 SurnameVectorizer 类。

        Args:
            surname_vocab (Vocabulary): 姓氏词汇表。
            nationality_vocab (Vocabulary): 国籍词汇表。
        """
        self.surname_vocab = surname_vocab
        self.nationality_vocab = nationality_vocab

    def vectorize(self, surname):
        """向量化提供的姓氏

        Args:
            surname (str): 姓氏字符串
        Returns:
            one_hot (np.ndarray): 压缩的 one-hot 编码
        """
        vocab = self.surname_vocab
        one_hot = np.zeros(len(vocab), dtype=np.float32)
        for token in surname:
            one_hot[vocab.lookup_token(token)] = 1
        return one_hot

    @classmethod
    def from_dataframe(cls, surname_df):
        """从数据集 DataFrame 实例化向量器

        Args:
            surname_df (pandas.DataFrame): 姓氏数据集
        Returns:
            SurnameVectorizer 的一个实例
        """
        surname_vocab = Vocabulary(unk_token="@")
        nationality_vocab = Vocabulary(add_unk=False)

        for index, row in surname_df.iterrows():
            for letter in row.surname:
                surname_vocab.add_token(letter)
            nationality_vocab.add_token(row.nationality)

        return cls(surname_vocab, nationality_vocab)

姓氏分类器模型

import torch.nn as nn
import torch.nn.functional as F

class SurnameClassifier(nn.Module):
    """用于对姓氏进行分类的两层多层感知器(MLP)"""

    def __init__(self, input_dim, hidden_dim, output_dim):
        """
        初始化姓氏分类器。

        Args:
            input_dim (int): 输入向量的大小。
            hidden_dim (int): 第一个全连接层的输出大小(隐藏层大小)。
            output_dim (int): 第二个全连接层的输出大小(最终输出大小)。
        """
        super(SurnameClassifier, self).__init__()
        # 定义第一个全连接层,将输入维度映射到隐藏层维度
        self.fc1 = nn.Linear(input_dim, hidden_dim)
        # 定义第二个全连接层,将隐藏层维度映射到输出维度
        self.fc2 = nn.Linear(hidden_dim, output_dim)

    def forward(self, x_in, apply_softmax=False):
        """
        分类器的前向传播过程。

        Args:
            x_in (torch.Tensor): 输入数据张量。
                x_in 的形状应为 (batch, input_dim)。
            apply_softmax (bool): 是否应用 softmax 激活函数的标志。
                如果与交叉熵损失函数一起使用,应为 False。

        Returns:
            torch.Tensor: 输出张量。张量形状应为 (batch, output_dim)。
        """
        # 通过第一个全连接层并应用 ReLU 激活函数
        intermediate_vector = F.relu(self.fc1(x_in))
        # 通过第二个全连接层得到预测向量
        prediction_vector = self.fc2(intermediate_vector)

        # 如果 apply_softmax 为真,应用 softmax 激活函数
        if apply_softmax:
            prediction_vector = F.softmax(prediction_vector, dim=1)

        return prediction_vector

常规训练

args = Namespace(
    # Data and path information
    surname_csv="data/surnames/surnames_with_splits.csv",  # 姓氏数据的 CSV 文件路径
    vectorizer_file="vectorizer.json",  # 向量化器的保存路径
    model_state_file="model.pth",  # 模型状态的保存路径
    save_dir="model_storage/ch4/surname_mlp",  # 模型存储目录的路径
    # Model hyper parameters
    hidden_dim=300,  # 隐藏层的维度
    # Training  hyper parameters
    seed=1337,  # 随机种子,用于生成随机数
    num_epochs=100,  # 训练的最大轮数
    early_stopping_criteria=5,  # 提前停止训练的标准,即验证集上损失连续不下降的轮数
    learning_rate=0.001,  # 学习率,控制模型参数更新的步长
    batch_size=64,  # 批量训练时每个批次的样本数量
)

训练中最显著的差异与模型中输出的种类和使用的损失函数有关。在这个例子中,输出是一个多类预测向量,可以转换为概率。正如在模型描述中所描述的,这种输出的损失类型仅限于CrossEntropyLoss和NLLLoss。由于它的简化,我们使用了CrossEntropyLoss。

在下面例子中,我们展示了数据集、模型、损失函数和优化器的实例化。

dataset = SurnameDataset.load_dataset_and_make_vectorizer(args.surname_csv)  # 加载数据集并创建向量化器
vectorizer = dataset.get_vectorizer()  # 获取数据集的向量化器

# 初始化分类器模型
classifier = SurnameClassifier(input_dim=len(vectorizer.surname_vocab),
                               hidden_dim=args.hidden_dim,
                               output_dim=len(vectorizer.nationality_vocab))

classifier = classifier.to(args.device)  # 将分类器模型移到指定设备上

# 定义损失函数为交叉熵损失,并考虑样本类别不均衡情况
loss_func = nn.CrossEntropyLoss(dataset.class_weights)

# 定义优化器为 Adam,并传入分类器模型的参数和学习率
optimizer = optim.Adam(classifier.parameters(), lr=args.learning_rate)

本例的训练循环除了变量名以外几乎是相同的。具体来说,显示了使用不同的key从batch_dict中获取数据。除了外观上的差异,训练循环的功能保持不变。利用训练数据,计算模型输出、损失和梯度。然后,使用梯度来更新模型。

# Step 1: 清零梯度
optimizer.zero_grad()

# Step 2: 计算输出
y_pred = classifier(batch_dict['x_surname'])

# Step 3: 计算损失
loss = loss_func(y_pred, batch_dict['y_nationality'])  # 计算预测值与目标值之间的损失
loss_batch = loss.to("cpu").item()  # 将损失转移到 CPU 上并提取出数值
running_loss += (loss_batch - running_loss) / (batch_index + 1)  # 更新运行损失,用于打印训练进度

# Step 4: 使用损失计算梯度
loss.backward()

# Step 5: 使用优化器执行梯度更新步骤
optimizer.step()

显示了分类新姓氏的代码。给定一个姓氏作为字符串,该函数将首先应用向量化过程,然后获得模型预测。注意,我们包含了apply_softmax标志,所以结果包含概率。模型预测,在多项式的情况下,是类概率的列表。我们使用PyTorch张量最大函数来得到由最高预测概率表示的最优类。

def predict_nationality(name, classifier, vectorizer):
    """
    预测给定姓名的国籍和其概率。

    Args:
        name (str): 要预测的姓名。
        classifier (nn.Module): 训练过的分类器模型。
        vectorizer (SurnameVectorizer): 用于向量化姓名的向量器。

    Returns:
        dict: 包含预测的国籍和其概率值的字典。
    """
    # 向量化姓名
    vectorized_name = vectorizer.vectorize(name)
    vectorized_name = torch.tensor(vectorized_name).view(1, -1)  # 转换为张量并添加批次维度

    # 使用分类器进行预测
    result = classifier(vectorized_name, apply_softmax=True)

    # 获取预测概率最高的国籍
    probability_values, indices = result.max(dim=1)
    index = indices.item()
    predicted_nationality = vectorizer.nationality_vocab.lookup_index(index)

    # 获取预测概率值
    probability_value = probability_values.item()

    # 返回预测结果和概率值
    return {'nationality': predicted_nationality,
            'probability': probability_value}

不仅要看最好的预测,还要看更多的预测。例如,NLP中的标准实践是采用k-best预测并使用另一个模型对它们重新排序。PyTorch提供了一个torch.topk函数,它提供了一种方便的方法来获得这些预测

def predict_topk_nationality(name, classifier, vectorizer, k=5):
    """
    预测给定姓名的前 k 个国籍及其概率。

    Args:
        name (str): 要预测的姓名。
        classifier (nn.Module): 训练过的分类器模型。
        vectorizer (SurnameVectorizer): 用于向量化姓名的向量器。
        k (int): 返回前 k 个国籍的数量,默认为 5。

    Returns:
        list: 包含前 k 个国籍及其概率值的字典列表。
    """
    # 向量化姓名
    vectorized_name = vectorizer.vectorize(name)
    vectorized_name = torch.tensor(vectorized_name).view(1, -1)  # 转换为张量并添加批次维度

    # 使用分类器进行预测
    prediction_vector = classifier(vectorized_name, apply_softmax=True)

    # 获取前 k 个概率值和对应的索引
    probability_values, indices = torch.topk(prediction_vector, k=k)

    # 将张量转换为 numpy 数组,并提取值
    probability_values = probability_values.detach().numpy()[0]
    indices = indices.detach().numpy()[0]

    # 构建结果列表
    results = []
    for prob_value, index in zip(probability_values, indices):
        nationality = vectorizer.nationality_vocab.lookup_index(index)
        results.append({'nationality': nationality,
                        'probability': prob_value})

    return results

简单地说,在训练过程中,dropout有一定概率使属于两个相邻层的单元之间的连接减弱。这有什么用呢?我们从斯蒂芬•梅里蒂(Stephen Merity)的一段直观(且幽默)的解释开始:“Dropout,简单地说,是指如果你能在喝醉的时候反复学习如何做一件事,那么你应该能够在清醒的时候做得更好。这一见解产生了许多最先进的结果和一个新兴的领域。”

神经网络——尤其是具有大量分层的深层网络——可以在单元之间创建有趣的相互适应。“Coadaptation”是神经科学中的一个术语,但在这里它只是指一种情况,即两个单元之间的联系变得过于紧密,而牺牲了其他单元之间的联系。这通常会导致模型与数据过拟合。通过概率地丢弃单元之间的连接,我们可以确保没有一个单元总是依赖于另一个单元,从而产生健壮的模型。dropout不会向模型中添加额外的参数,但是需要一个超参数——“drop probability”。drop probability,它是单位之间的连接drop的概率。通常将下降概率设置为0.5。

import torch.nn as nn
import torch.nn.functional as F

class MultilayerPerceptron(nn.Module):
    def __init__(self, input_dim, hidden_dim, output_dim):
        """
        初始化多层感知器(MLP)模型。

        Args:
            input_dim (int): 输入向量的大小。
            hidden_dim (int): 第一个全连接层的输出大小(隐藏层大小)。
            output_dim (int): 第二个全连接层的输出大小(最终输出大小)。
        """
        super(MultilayerPerceptron, self).__init__()
        self.fc1 = nn.Linear(input_dim, hidden_dim)  # 第一个全连接层
        self.fc2 = nn.Linear(hidden_dim, output_dim)  # 第二个全连接层

    def forward(self, x_in, apply_softmax=False):
        """
        MLP的前向传播过程。

        Args:
            x_in (torch.Tensor): 输入数据张量。
                x_in 的形状应为 (batch, input_dim)。
            apply_softmax (bool): 是否应用 softmax 激活函数的标志。
                如果与交叉熵损失函数一起使用,应为 False。

        Returns:
            torch.Tensor: 输出张量。张量形状应为 (batch, output_dim)。
        """
        # 使用 ReLU 激活函数对第一个全连接层的输出进行非线性变换
        intermediate = F.relu(self.fc1(x_in))
        # 使用 dropout 对中间层进行正则化
        output = self.fc2(F.dropout(intermediate, p=0.5))

        # 如果 apply_softmax 为真,应用 softmax 激活函数
        if apply_softmax:
            output = F.softmax(output, dim=1)
        return output

CNN超参数

为了理解不同的设计决策对CNN意味着什么,我们在图6中展示了一个示例。在本例中,单个“核”应用于输入矩阵。卷积运算(线性算子)的精确数学表达式对于理解这一节并不重要,但是从这个图中可以直观地看出,核是一个小的方阵,它被系统地应用于输入矩阵的不同位置。

卷积运算的维数

首先要理解的概念是卷积运算的维数。在图4-6和本节的其他图中,我们使用二维卷积进行说明,但是根据数据的性质,还有更适合的其他维度的卷积。在PyTorch中,卷积可以是一维、二维或三维的,分别由Conv1d、Conv2d和Conv3d模块实现。一维卷积对于每个时间步都有一个特征向量的时间序列非常有用。在这种情况下,我们可以在序列维度上学习模式。NLP中的卷积运算大多是一维的卷积。另一方面,二维卷积试图捕捉数据中沿两个方向的时空模式;例如,在图像中沿高度和宽度维度——为什么二维卷积在图像处理中很流行。类似地,在三维卷积中,模式是沿着数据中的三维捕获的。例如,在视频数据中,信息是三维的,二维表示图像的帧,时间维表示帧的序列。就本课程而言,我们主要使用Conv1d。

通道

非正式地,通道(channel)是指沿输入中的每个点的特征维度。例如,在图像中,对应于RGB组件的图像中的每个像素有三个通道。在使用卷积时,文本数据也可以采用类似的概念。从概念上讲,如果文本文档中的“像素”是单词,那么通道的数量就是词汇表的大小。如果我们更细粒度地考虑字符的卷积,通道的数量就是字符集的大小(在本例中刚好是词汇表)。在PyTorch卷积实现中,输入通道的数量是in_channels参数。卷积操作可以在输出(out_channels)中产生多个通道。您可以将其视为卷积运算符将输入特征维“映射”到输出特征维。图7和图8说明了这个概念。

在PyTorch中实现CNNs

我们将通过端到端示例来利用上一节中介绍的概念。一般来说,神经网络设计的目标是找到一个能够完成任务的超参数组态。我们再次考虑在“示例:带有多层感知器的姓氏分类”中引入的现在很熟悉的姓氏分类任务,但是我们将使用CNNs而不是MLP。我们仍然需要应用最后一个线性层,它将学会从一系列卷积层创建的特征向量创建预测向量。这意味着目标是确定卷积层的配置,从而得到所需的特征向量。所有CNN应用程序都是这样的:首先有一组卷积层,它们提取一个feature map,然后将其作为上游处理的输入。在分类中,上游处理几乎总是应用线性(或fc)层。

batch_size = 2  # 输入数据的批量大小
one_hot_size = 10  # 每个时间步的 one-hot 编码向量的大小
sequence_width = 7  # 输入序列的长度
data = torch.randn(batch_size, one_hot_size, sequence_width)  # 生成随机输入数据

# 定义一维卷积层
conv1 = Conv1d(in_channels=one_hot_size, out_channels=16, kernel_size=3)

# 进行一维卷积操作
intermediate1 = conv1(data)

# 打印输入数据和卷积后的数据的大小
print(data.size())  # 输出输入数据的形状
print(intermediate1.size())  # 输出卷积后数据的形状

进一步减小输出张量的主要方法有三种。第一种方法是创建额外的卷积并按顺序应用它们。最终,对应的sequence_width (dim=2)维度的大小将为1。我们在例字中展示了应用两个额外卷积的结果。一般来说,对输出张量的约简应用卷积的过程是迭代的,需要一些猜测工作。我们的示例是这样构造的:经过三次卷积之后,最终的输出在最终维度上的大小为1。

在本例中,尽管词汇表和DataLoader的实现方式与“示例:带有多层感知器的姓氏分类”中的示例相同,但Vectorizer的vectorize()方法已经更改,以适应CNN模型的需要。具体来说,正如我们在示例4-18中的代码中所示,该函数将字符串中的每个字符映射到一个整数,然后使用该整数构造一个由onehot向量组成的矩阵。重要的是,矩阵中的每一列都是不同的onehot向量。主要原因是,我们将使用的Conv1d层要求数据张量在第0维上具有批处理,在第1维上具有通道,在第2维上具有特性。

除了更改为使用onehot矩阵之外,我们还修改了矢量化器,以便计算姓氏的最大长度并将其保存为max_surname_length

class SurnameVectorizer(object):
    """协调词汇表并将其应用的向量器"""

    def vectorize(self, surname):
        """
        将姓氏向量化为矩阵表示。

        Args:
            surname (str): 姓氏字符串。

        Returns:
            one_hot_matrix (np.ndarray): 一个包含独热向量的矩阵。
        """
        # 初始化独热向量矩阵的大小
        one_hot_matrix_size = (len(self.character_vocab), self.max_surname_length)
        one_hot_matrix = np.zeros(one_hot_matrix_size, dtype=np.float32)

        # 对每个字符在矩阵中的位置进行编码
        for position_index, character in enumerate(surname):
            character_index = self.character_vocab.lookup_token(character)
            one_hot_matrix[character_index][position_index] = 1

        return one_hot_matrix

    @classmethod
    def from_dataframe(cls, surname_df):
        """
        从数据集 DataFrame 实例化向量器。

        Args:
            surname_df (pandas.DataFrame): 姓氏数据集。

        Returns:
            an instance of the SurnameVectorizer: SurnameVectorizer 的一个实例。
        """
        # 初始化字符词汇表、国籍词汇表和最大姓氏长度
        character_vocab = Vocabulary(unk_token="@")
        nationality_vocab = Vocabulary(add_unk=False)
        max_surname_length = 0

        # 遍历数据集中的每一行
        for index, row in surname_df.iterrows():
            # 更新最大姓氏长度
            max_surname_length = max(max_surname_length, len(row.surname))
            # 向字符词汇表和国籍词汇表添加字符和国籍
            for letter in row.surname:
                character_vocab.add_token(letter)
            nationality_vocab.add_token(row.nationality)

        # 返回实例化的向量器
        return cls(character_vocab, nationality_vocab, max_surname_length)

我们在本例中使用的模型是使用我们在“卷积神经网络”中介绍的方法构建的。实际上,我们在该部分中创建的用于测试卷积层的“人工”数据与姓氏数据集中使用本例中的矢量化器的数据张量的大小完全匹配。正如在示例4-19中所看到的,它与我们在“卷积神经网络”中引入的Conv1d序列既有相似之处,也有需要解释的新添加内容。具体来说,该模型类似于“卷积神经网络”,它使用一系列一维卷积来增量地计算更多的特征,从而得到一个单特征向量。

然而,本例中的新内容是使用sequence和ELU PyTorch模块。序列模块是封装线性操作序列的方便包装器。在这种情况下,我们使用它来封装Conv1d序列的应用程序。ELU是类似于实验3中介绍的ReLU的非线性函数,但是它不是将值裁剪到0以下,而是对它们求幂。ELU已经被证明是卷积层之间使用的一种很有前途的非线性(Clevert et al., 2015)。

在本例中,我们将每个卷积的通道数与num_channels超参数绑定。我们可以选择不同数量的通道分别进行卷积运算。这样做需要优化更多的超参数。我们发现256足够大,可以使模型达到合理的性能。

import torch.nn as nn
import torch.nn.functional as F

class SurnameClassifier(nn.Module):
    def __init__(self, initial_num_channels, num_classes, num_channels):
        """
        初始化姓氏分类器模型。

        Args:
            initial_num_channels (int): 输入特征向量的大小。
            num_classes (int): 输出预测向量的大小(类别数)。
            num_channels (int): 网络中使用的常量通道大小。
        """
        super(SurnameClassifier, self).__init__()

        # 定义卷积网络层
        self.convnet = nn.Sequential(
            nn.Conv1d(in_channels=initial_num_channels,
                      out_channels=num_channels, kernel_size=3),  # 第一卷积层
            nn.ELU(),  # 第一层的激活函数
            nn.Conv1d(in_channels=num_channels, out_channels=num_channels,
                      kernel_size=3, stride=2),  # 第二卷积层
            nn.ELU(),  # 第二层的激活函数
            nn.Conv1d(in_channels=num_channels, out_channels=num_channels,
                      kernel_size=3, stride=2),  # 第三卷积层
            nn.ELU(),  # 第三层的激活函数
            nn.Conv1d(in_channels=num_channels, out_channels=num_channels,
                      kernel_size=3),  # 第四卷积层
            nn.ELU()  # 第四层的激活函数
        )
        # 定义全连接层
        self.fc = nn.Linear(num_channels, num_classes)

    def forward(self, x_surname, apply_softmax=False):
        """
        分类器的前向传播过程。

        Args:
            x_surname (torch.Tensor): 输入数据张量。
                x_surname 的形状应为 (batch, initial_num_channels, max_surname_length)。
            apply_softmax (bool): 是否应用 softmax 激活函数的标志。
                如果与交叉熵损失函数一起使用,应为 False。

        Returns:
            torch.Tensor: 结果张量。张量形状应为 (batch, num_classes)。
        """
        # 使用卷积网络层处理输入数据,并压缩维度
        features = self.convnet(x_surname).squeeze(dim=2)
        # 使用全连接层进行分类
        prediction_vector = self.fc(features)

        # 如果 apply_softmax 为真,应用 softmax 激活函数
        if apply_softmax:
            prediction_vector = F.softmax(prediction_vector, dim=1)

        return prediction_vector

训练程序包括以下似曾相识的的操作序列:实例化数据集,实例化模型,实例化损失函数,实例化优化器,遍历数据集的训练分区和更新模型参数,遍历数据集的验证分区和测量性能,然后重复数据集迭代一定次数。此时,这是本书到目前为止的第三个训练例程实现,应该将这个操作序列内部化。对于这个例子,我们将不再详细描述具体的训练例程,因为它与“示例:带有多层感知器的姓氏分类”中的例程完全相同。但是,输入参数是不同的,可以在中看到

args = Namespace(
    # 数据和路径信息
    surname_csv="data/surnames/surnames_with_splits.csv",  # 姓氏数据集的 CSV 文件路径
    vectorizer_file="vectorizer.json",  # 向量器的保存文件路径
    model_state_file="model.pth",  # 模型状态的保存文件路径
    save_dir="model_storage/ch4/cnn",  # 模型保存的目录路径

    # 模型超参数
    hidden_dim=100,  # 隐藏层的大小
    num_channels=256,  # CNN 中的通道数

    # 训练超参数
    seed=1337,  # 随机种子
    learning_rate=0.001,  # 学习率
    batch_size=128,  # 批量大小
    num_epochs=100,  # 训练的总轮数
    early_stopping_criteria=5,  # 提前停止训练的标准
    dropout_p=0.1,  # Dropout 概率

    # 运行时参数已省略 ...
)

要理解模型的性能,需要对性能进行定量和定性的度量。下面将描述这两个度量的基本组件。建议你扩展它们,以探索该模型及其所学习到的内容。

正如:带有多层感知器的姓氏分类”中的示例与本示例之间的训练例程没有变化一样,执行评估的代码也没有变化。总之,调用分类器的`eval()`方法来防止反向传播,并迭代测试数据集。与 MLP 约 50% 的性能相比,该模型的测试集性能准确率约为56%。尽管这些性能数字绝不是这些特定架构的上限,但是通过一个相对简单的CNN模型获得的改进应该足以让您在文本数据上尝试CNNs。

在本例中,`predict_nationality()`函数的一部分发生了更改,如示例4-21所示:我们没有使用视图方法重塑新创建的数据张量以添加批处理维度,而是使用PyTorch的`unsqueeze()`函数在批处理应该在的位置添加大小为1的维度。相同的更改反映在`predict_topk_nationality()`函数中。

def predict_nationality(surname, classifier, vectorizer):
    """
    从新的姓氏预测国籍。

    Args:
        surname (str): 要分类的姓氏。
        classifier (SurnameClassifer): 分类器的实例。
        vectorizer (SurnameVectorizer): 相应的向量器。

    Returns:
        dict: 包含最可能的国籍及其概率的字典。
    """
    # 将姓氏向量化
    vectorized_surname = vectorizer.vectorize(surname)
    vectorized_surname = torch.tensor(vectorized_surname).unsqueeze(0)  # 添加批次维度
    # 使用分类器进行预测
    result = classifier(vectorized_surname, apply_softmax=True)

    # 获取最大概率值及其对应的索引
    probability_values, indices = result.max(dim=1)
    index = indices.item()

    # 查找预测国籍对应的标签
    predicted_nationality = vectorizer.nationality_vocab.lookup_index(index)
    probability_value = probability_values.item()

    # 返回预测的国籍及其概率值
    return {'nationality': predicted_nationality, 'probability': probability_value}

池化

Pooling是将高维特征映射总结为低维特征映射的操作。卷积的输出是一个特征映射。feature map中的值总结了输入的一些区域。由于卷积计算的重叠性,许多计算出的特征可能是冗余的。Pooling是一种将高维(可能是冗余的)特征映射总结为低维特征映射的方法。在形式上,池是一种像sum、mean或max这样的算术运算符,系统地应用于feature map中的局部区域,得到的池操作分别称为sum pooling、average pooling和max pooling。池还可以作为一种方法,将较大但较弱的feature map的统计强度改进为较小但较强的feature map。图4-13说明了Pooling。

批处理标准化

批处理标准化是设计网络时经常使用的一种工具。BatchNorm对CNN的输出进行转换,方法是将激活量缩放为零均值和单位方差。它用于Z-transform的平均值和方差值每批更新一次,这样任何单个批中的波动都不会太大地移动或影响它。BatchNorm允许模型对参数的初始化不那么敏感,并且简化了学习速率的调整(Ioffe and Szegedy, 2015)。在PyTorch中,批处理规范是在nn模块中定义的。例4-22展示了如何用卷积和线性层实例化和使用批处理规范。

# 初始化第一个卷积层和批量归一化层
self.conv1 = nn.Conv1d(in_channels=1, out_channels=10, kernel_size=5, stride=1)
self.conv1_bn = nn.BatchNorm1d(num_features=10)

def forward(self, x):
    """
    前向传播方法

    Args:
        x (torch.Tensor): 输入数据张量,形状为 (batch_size, input_channels, sequence_length)。

    Returns:
        torch.Tensor: 输出数据张量,形状根据模型结构不同而不同。
    """
    # 使用ReLU激活函数对第一个卷积层的输出进行非线性变换
    x = F.relu(self.conv1(x))
    # 对第一个卷积层的输出进行批量归一化
    x = self.conv1_bn(x)
    # 这里可能还有其他层和操作

    return x

Network-in-Network (NiN)连接是具有`kernel_size=1`的卷积内核,具有一些有趣的特性。具体来说,1x1卷积就像通道之间的一个完全连通的线性层。这在从多通道feature map映射到更浅的feature map时非常有用。在图14中,我们展示了一个应用于输入矩阵的NiN连接。它将两个通道简化为一个通道。因此,NiN或1x1卷积提供了一种廉价的方法来合并参数较少的额外非线性(Lin et al., 2013)。

CNNs中最重要的趋势之一是Residual connection,它支持真正深层的网络(超过100层)。它也称为skip connection。

然而,这个操作有一个隐含的技巧,如图4-15所示。对于要添加到卷积输出的输入,它们必须具有相同的形状。为此,标准做法是在卷积之前应用填充。在图5中,填充尺寸为1,卷积大小为3。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值