文本分类任务
本周的内容是使用CNN进行意图分类任务,关键是掌握怎么处理NLP问题,以及里面可能会遇到的一些小问题
task1&2 CNN进行意图分类
[!question] task1: 根据给定的代码与数据,参考课件内容,填充完整models.py的代码;
[!question] task2: 为关键代码添加注释
主函数
这里task1只需要填充相关的代码,不过如果要理解整个项目的话,最好从main.py
开始看起:
1 | # main.py |
准备内容
intent_num
给出了分类,共16类- 创建了一个
ArgumentParser
对象parser,用于解析命令行参数 - args是一个字典,存储parser解析的内容
- cfg是按照args.config给出的路径读取的yaml参数(包括embed_dim, batch_size, lr, vocab_size等),存放在cfg字典里(其实更习惯命令行参数把这些参数全都传入)
- 加载来自yaml的data_path的数据,这里用
torch.load
进行加载,因为数据已经在预处理过程中转化成pth文件
数据预处理
初始的数据是json,形式是这样的:
1 | { |
[!question] 但是为什么main里面直接加载的是pth呢?是怎么从json文件处理成pth的?pth还能读出来’train’,’test’和’val’?
来看看预处理部分:
1 | # data_preprocessing.py |
encode_data
函数
- 该函数的作用是将文本数据和意图标签转换成模型可以理解的格式。具体来说:
- tokens的编码:
- 使用
BertTokenizerFast
对每个数据项的tokens
进行编码。BertTokenizerFast
是BERT模型的一部分,用来将文本转换为BERT模型输入需要的ID(即input_ids
)。 is_split_to_word=True
,告诉Bert输入已经是分词的列表- 对于每个
tokens
,会进行padding(填充),使所有的输入序列长度一致,最长为max_len
,超过的部分会被截断。 return_tensors='pt'
表示返回PyTorch的张量(tensor),这使得数据可以直接用于PyTorch模型。return_offsets_mapping=True
的作用是返回词汇的位置信息(这个在一些任务中会用到,但在当前代码中没有进一步处理)。- 返回一个包含多个键值对的字典
encoding
,其中包括了input_ids
、attention_mask
、token_type_ids
等input_ids
:这是一个包含编码后token的ID列表。对于每个token,tokenizer会找到它在BERT词汇表中的对应ID。- 由于我们设置了
return_tensors='pt'
,这个input_ids
是一个PyTorch张量(tensor),它的形状通常是[batch_size, sequence_length]
,但由于每次调用编码时只处理一个句子,batch_size
为1。
- 使用
- 意图标签编码:
- 将意图标签转换为数字ID。字典
intents_num
定义了每个意图对应的数字ID。 - 如果在
intents_num
字典中找不到该意图(比如意图是'None'
),则返回默认值-1
。
- 将意图标签转换为数字ID。字典
- 返回结果:
- 返回一个字典,其中包含:
input_ids
:所有数据项的token ID的stack。intent_labels
:每个数据项的意图标签(数字ID)的stack。
- 这些数据将用于训练、验证和测试模型。
- 返回一个字典,其中包含:
encoding['input_ids'][0]
encoding['input_ids']
是一个形状为[1, max_len]
的张量,表示单个输入句子的token ID。[0]
表示从这个张量中取出第一个元素,即获取第一个句子的input_ids
- 由于在每次编码时我们只处理一个句子,所以
encoding['input_ids'][0]
就是一个一维的张量,包含了该句子的所有token的ID
torch.stack
torch.stack(input_ids)
是一个 PyTorch 操作,它将多个张量沿着新维度进行堆叠(stack)torch.stack
会将输入的多个张量(input_ids
)沿着一个新的维度进行堆叠,生成一个新的张量。
假设 input_ids
是一个包含多个 PyTorch 张量(例如,每个句子的 token IDs 的张量)的列表或其他可迭代对象。
输入:
- 每个元素(例如每个句子)通常是一个形状为
[sequence_length]
的一维张量。 torch.stack(input_ids)
会沿着新维度堆叠这些一维张量,形成一个新的张量。
输出:torch.stack(input_ids)
将会返回一个形状为[batch_size, sequence_length]
的二维张量,其中batch_size
是输入张量的数量(即句子的数量),sequence_length
是每个句子的长度(即每个句子的 token 数量)。- 具体来说,如果
input_ids
中有n
个句子,每个句子的长度为L
,那么堆叠后得到的张量的形状就是[n, L]
。
假设我们有以下三个句子的 input_ids
:
1 | input_ids = [ |
每个张量表示一个句子,[101, 2023, 2003, 1037, 2742]
是一个句子的 token ID 列表。
执行 torch.stack(input_ids)
后,得到一个新的张量:
1 | stacked_tensor = torch.stack(input_ids) |
输出结果可能是:
1 | tensor([[ 101, 2023, 2003, 1037, 2742], |
stacked_tensor
的形状是 [3, 6]
,表示三个句子的 token ID 被堆叠成一个二维张量,其中 batch_size = 3
(句子的数量),sequence_length = 6
(最长句子的长度)。如果某些句子的长度较短(如第一个句子只有5个token),则 PyTorch 会按最大长度进行对齐,这会影响实际的输入张量的大小。
总结
torch.stack(input_ids)
会将多个一维张量(每个句子的input_ids
)堆叠成一个二维张量。- 结果的形状是
[batch_size, sequence_length]
,其中batch_size
是句子数量,sequence_length
是每个句子的最大长度。
pth文件
.pth
文件是PyTorch的专用格式,通常用于保存模型或张量数据。torch.save()
函数能够将数据保存为PyTorch支持的格式,可以保存模型权重、优化器状态、训练进度等信息。- 在代码中,
encode_data
函数处理过的数据是PyTorch张量。使用torch.save()
将数据保存为.pth
文件,便于后续训练阶段直接加载。.pth
文件的优点是:- 它可以存储大量数据(例如训练集、验证集、测试集的编码数据),这些数据是PyTorch张量,因此可以直接用于模型训练。
.pth
文件格式与PyTorch深度学习框架兼容,因此加载速度快,且方便在不同阶段读取、修改数据。
创建数据加载器
- 为训练数据、测试数据创建数据集
- 创建DataLoader用于数据加载
进行训练和评测
- 在每个epoch调用
train_epoch
进行训练 - 调用
evaluate
进行评测,找到最好的模型
dataset
[!question] 在
main.py
的时候,会看到这句话:print(f"训练集: {len(train_dataset)}, 测试集: {len(test_dataset)}")
,但是,按照数据预处理返回的字典,似乎有input_ids
和intent_labels
两个键,那这样不是只会返回2么?
这涉及到Dataset的处理
1 | import torch |
这个 SLUDataset
类是 PyTorch 中用于构建自定义数据集的核心组件,专门为语义理解(SLU)任务设计。它的核心功能是将预处理后的数据封装成 PyTorch 可识别的数据集格式。以下是对这个类的逐层解析:
类结构图示
1 | classDiagram |
核心方法详解
1. __init__(self, encodings)
作用:初始化数据集对象
参数说明:
encodings
:字典类型,必须包含以下两个键:'input_ids'
:形状为[num_samples, max_seq_len]
的 tensor,存储 token 索引'intent_labels'
:形状为[num_samples]
的 tensor,存储意图标签的数值编码
示例输入:
1 | encodings = { |
2. __len__(self)
作用:返回数据集样本总数
关键点:
- 假设
input_ids
和intent_labels
长度严格一致 - 如果存在其他数据字段(如
attention_mask
),也需要保证长度一致
3. __getitem__(self, idx)
作用:根据索引获取单个样本
实现逻辑:
1 | def __getitem__(self, idx): |
数据流示例:
1 | # 假设idx=5 |
与DataLoader的配合
1 | from torch.utils.data import DataLoader |
典型应用场景
1 | # 查看单个样本 |
model
回到model.py
1 | # models.py |
这段代码定义了两个模型,CNN
和 CNNSeq
,都用于自然语言处理中的意图分类任务。我们将逐步解析这两个模型的结构、nn.Embedding
和 AdaptiveMaxPool1d
的作用,并讨论它们的区别。
1. nn.Embedding(vocab_size, embed_dim)
的作用
nn.Embedding
是 PyTorch 中的一个层,用于将词索引(即单词的整数标识符)映射到词向量(嵌入向量)空间中。- 输入:
vocab_size
表示词汇表的大小(即总共有多少个不同的单词,通常默认使用30522),embed_dim
表示词向量的维度(即每个单词被嵌入为一个embed_dim
维的向量)。 - 输出:
nn.Embedding
层会返回一个形状为[batch_size, seq_len, embed_dim]
的张量,其中:batch_size
是输入的句子数量。seq_len
是每个句子的单词数量(即每个句子的最大长度)。embed_dim
是每个单词的嵌入维度。
- 原本的输入句子将每个token转化为了索引,然后通过embedding嵌入到embed_dim维度的空间中
功能:这层的作用是将每个输入的词(由词索引表示)转换为一个固定维度的词向量。在 NLP 中,词嵌入是为了捕捉单词之间的语义关系和语法信息。
[!note]
permute(0, 2, 1)
的作用
因为PyTorch的Conv1d期望输入维度为[batch, channels, sequence]
,而Embedding输出是[batch, sequence, channels]
,因此需要调整维度
2. AdaptiveMaxPool1d(1)
的作用
AdaptiveMaxPool1d
是一个池化层,主要用于降低输入的维度,保留最重要的特征。
- 输入:输入是一个形状为
[batch_size, embed_dim, seq_len]
的张量,其中:batch_size
是批量大小,表示输入句子的数量。embed_dim
是词向量的维度。seq_len
是每个句子的长度。
- 输出:
AdaptiveMaxPool1d(1)
会对输入数据进行池化,输出形状为[batch_size, embed_dim, 1]
,即每个特征维度都被压缩到长度为1。这是通过对seq_len
长度的特征进行最大池化操作(从中选取最大值)来实现的,池化的目的是保留最重要的特征信息。- 因为是最大池化(MaxPooling),它会从输入的每个特征维度中选取该维度的最大值。
Adaptive
表示该层会根据输入的尺寸自适应地调整池化的窗口和步长,以确保输出的大小符合要求。
在这段代码中,AdaptiveMaxPool1d(1)
会将每个句子的嵌入表示池化为一个单一的标量(最大池化值),从而将句子表示的维度从 [batch_size, embed_dim, seq_len]
压缩到 [batch_size, embed_dim, 1]
。
3. CNN
模型
CNN
模型是一个卷积神经网络(CNN),用于文本的意图分类任务。主要结构如下:
- Embedding层:将输入的词索引转化为词向量。
- 卷积层(conv1, conv2, conv3):使用三个不同的卷积层来提取文本中的特征。
conv1
:使用 3x3 的卷积核,输出维度为embed_dim
,然后进行批标准化(Batch Normalization),激活(ReLU)和丢弃(Dropout)。conv2
:使用 3x3 的卷积核,输出维度为embed_dim * 2
,然后进行批标准化和激活。conv3
:使用 3x3 的卷积核,输出维度为embed_dim
,然后进行批标准化和激活。
- 池化层:
AdaptiveMaxPool1d(1)
进行最大池化,将每个句子的特征向量压缩成一个标量。 - 分类层:将池化后的特征传递给全连接层(
intent_classifier
),输出意图类别的概率分布。
卷积操作:
- 该模型使用了 1D 卷积,通常用于处理序列数据。卷积操作的目的是提取局部特征,例如在文本中提取特定的n-gram模式。
4. CNNSeq
模型
CNNSeq
模型与 CNN
模型类似,但有一些关键的不同点:
- 多个卷积核:
CNNSeq
使用了四个不同大小的卷积核(大小为 2, 3, 4, 5)。每个卷积核捕捉不同范围的局部特征,这有助于模型捕捉更丰富的文本特征。cnn_2
,cnn_3
,cnn_4
,cnn_5
分别是不同大小的卷积核,处理文本中的不同特征。
- 池化:每个卷积层后都有一个池化操作,通过
AdaptiveMaxPool1d(1)
将每个卷积层的输出压缩为一个标量。 - 特征拼接:最后,模型将来自不同卷积核的池化结果拼接在一起,形成一个完整的特征向量。拼接后的张量维度是
[batch_size, embed_dim]
,然后通过全连接层分类。
关键区别:
CNNSeq
在卷积部分使用了多个不同大小的卷积核,旨在捕捉不同的文本特征,而CNN
只有一个卷积层。CNNSeq
对多个卷积核的输出进行了拼接,而CNN
只使用一个卷积层的输出。
两个模型区别
感觉是这样的:
如何理解神经网络中通过add的方式融合特征? - 知乎
总结:
- **
nn.Embedding(vocab_size, embed_dim)
**:将词索引转换为词向量,用于表示文本中的每个词。 - **
AdaptiveMaxPool1d(1)
**:通过最大池化操作将序列的特征压缩为单个标量,保留最重要的特征。 CNN
和CNNSeq
的区别:CNN
使用三个卷积层提取文本特征,池化后通过全连接层分类。CNNSeq
使用多个不同大小的卷积核,并将各个卷积核的池化结果拼接在一起,然后通过全连接层进行分类。
tokens 的全部变化过程:
1 | # 输入文本(已分词) |
trainer
1 | # trainer.py |
- 注意训练的时候要开
model.train()
,测试的时候要开model.eval()
it_logits = model(input_ids)
实际上就是在执行forward
函数,返回每个intent的置信概率- 然后需要与真实标签计算交叉熵损失,进行梯度的反向传播和优化器步进
- 测试的时候,由于不知道真实标签,所以不能计算损失,采用F1等指标进行衡量准确率
train和eval模式区别
在PyTorch中,model.train()
和 model.eval()
是控制模型行为模式的两个关键方法,它们的主要区别体现在模型内部特定层的计算逻辑和梯度计算机制上。以下是它们的核心区别和具体影响:
1. 核心功能对比
方法 | model.train() |
model.eval() |
---|---|---|
模式 | 训练模式 | 评估模式(推理模式) |
主要目的 | 启用训练所需的行为(如随机性) | 关闭训练时的随机性,稳定输出 |
典型场景 | 模型训练(loss.backward() ) |
模型验证、测试、推理 |
2. 对具体模块的影响
(1) Dropout 层
model.train()
Dropout 会按照设定的p
值随机屏蔽神经元,通过引入噪声防止过拟合。
例如:输入为[1,2,3,4]
,可能变为[1,0,3,0]
(p=0.5
)。model.eval()
Dropout 完全关闭,所有神经元保留,输出直接传递:[1,2,3,4]
→[1,2,3,4]
。
(2) Batch Normalization 层
model.train()
使用当前批次的均值和方差统计量,并更新全局移动平均(running_mean
和running_var
)。
公式:
$$
\text{BN}(x) = \gamma \cdot \frac{x - \mu_{\text{batch}}}{\sqrt{\sigma_{\text{batch}}^2 + \epsilon}} + \beta
$$model.eval()
使用预计算的全局移动平均(running_mean
和running_var
),停止更新统计量。
公式:
$$
\text{BN}(x) = \gamma \cdot \frac{x - \mu_{\text{running}}}{\sqrt{\sigma_{\text{running}}^2 + \epsilon}} + \beta
$$
3. 梯度计算机制
方法 | 梯度计算状态 | 内存占用 |
---|---|---|
model.train() |
默认启用梯度跟踪(requires_grad=True ) |
较高(保存中间变量) |
model.eval() |
不自动关闭梯度(需手动使用 torch.no_grad() ) |
较低 |
4. 典型使用场景
训练阶段
1 | model.train() # 切换到训练模式 |
验证/测试阶段
1 | model.eval() # 切换到评估模式 |
5. 常见错误及后果
在验证时忘记调用
model.eval()
- Dropout 仍会随机屏蔽神经元 → 预测结果不稳定
- BatchNorm 使用当前小批量统计 → 指标波动大
- 示例:验证准确率从 85% 波动到 70%
在训练时误用
model.eval()
- Dropout 失效 → 模型容易过拟合
- BatchNorm 停止更新统计量 → 模型无法学习数据分布
- 示例:训练损失停滞不降,准确率无法提升
总结
维度 | model.train() |
model.eval() |
---|---|---|
随机性 | 启用(Dropout、数据增强等) | 关闭(稳定输出) |
统计量更新 | 实时更新(BatchNorm) | 固定预计算值 |
梯度计算 | 启用 | 需配合 torch.no_grad() 关闭 |
适用阶段 | 训练 | 验证、测试、推理 |
正确使用这两个模式是保证模型性能的关键步骤,尤其是在需要稳定推理结果的场景(如模型部署)中,model.eval()
必不可少。
实验结果
task1:运行指令
1 | python main.py --model cnn |
CNN:
…
CNN_seq:
…
task2相关注释已保存在文件
task3 超参数调优
[!question] 探究不同超参数(学习率lr,batch_size,模型维度embed_dim)对模型的影响(在configs/config.yaml中修改超参数,利用验证集(val.json,对应代码中的data[‘val’])检验性能变化),并画这三种超参数对模型性能的影响的图表(折线图或柱状图均可)
- 没有特别好说的,注意是在验证集上调优,因为我们并不知道对于测试集来说,什么样子的超参数是最好的(如果知道了就等同于作弊),因此需要在验证集上找到最优的超参数。
- 代码只需要写个for循环遍历所有超参数即可。通常最理想的方法是做网格搜索,不过这里因为需要画图,所以就选择了固定其他两个超参数,单独调整某个超参数,查看最后指标(我选的是ACC,不过选其他的应该也可以?)变化情况
又是一些跟优化理论相关的内容了。。。
- 小batch理论上效果会好,因为是对梯度取平均后下降,所以直观上来看小batch下降会更容易些,但是缺点是不稳定和时间更长,实际上batch大小还是一个trade off
- embedding_dim不能设的太大,不然嵌入的空间太稀疏了,模型处理比较困难,而且冗余的信息不一定会很好地帮助训练,and容易过拟合
- learning rate动态变化会好,因为epoch小的时候下降幅度太小了,当epoch到训练末期容易陷入山谷来回颠簸,不容易下降到最低点