文本分类任务

本周的内容是使用CNN进行意图分类任务,关键是掌握怎么处理NLP问题,以及里面可能会遇到的一些小问题

task1&2 CNN进行意图分类

[!question] task1: 根据给定的代码与数据,参考课件内容,填充完整models.py的代码;

[!question] task2: 为关键代码添加注释

主函数

这里task1只需要填充相关的代码,不过如果要理解整个项目的话,最好从main.py开始看起:

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
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# main.py

import torch
import argparse
import yaml
from torch.utils.data import DataLoader

from trainer import train_epoch, evaluate
from models import (
CNN,
CNNSeq
)
from data_utils import SLUDataset # 导入相关模块
import torch.nn as nn

intents_num = {'企业性质查询': 0,
'营业利润查询': 1,
'企业负债查询': 2,
'项目成交状况查询': 3,
'企业营业成本查询': 4,
'小区绿化率查询': 5,
'建筑密度查询': 6,
'小区成交均价查询': 7,
'营业总收入查询': 8,
'地块总价查询': 9,
'地块成交时间查询': 10,
'企业债务违约查询': 11,
'容积率查询': 12,
'企业风险查询': 13,
'地块归属查询': 14,
'项目开发商信息查询': 15
}

def main():
# 解析命令行参数
parser = argparse.ArgumentParser(description="SLU main entry")
parser.add_argument('--config', type=str, default='configs/config.yaml', help='Path to config file')
parser.add_argument('-m', '--model_type', type=str, default='cnn', choices=['cnn', 'cnn_seq'])
args = parser.parse_args()

# 读取args参数中的config路径对应的yaml文件
with open(args.config, 'r', encoding='utf-8') as f:
cfg = yaml.safe_load(f)

# 读取yaml文件中的参数
data_path = cfg['data_path']
model_name = cfg.get('model_name', 'bert-base-chinese') # 取model_name参数,如果没有则取bert-base-chinese
batch_size = cfg.get('batch_size', 16) # 取batch_size参数,如果没有则取16
epochs = cfg.get('epochs', 3) # 取epochs参数,如果没有则取3
lr = cfg.get('lr', 2e-5) # 取lr参数,如果没有则取2e-5
# 取其他参数,如果没有则取默认值
vocab_size = cfg.get('vocab_size', 30522) # 词表大小
embed_dim = cfg.get('embed_dim', 128) # 词向量维度

print("加载预处理后的数据:", data_path)
# 加载预处理后的数据
data = torch.load(data_path)
train_encodings = data['train']
test_encodings = data['test']

# 创建Dataset和DataLoader
train_dataset = SLUDataset(train_encodings)
test_dataset = SLUDataset(test_encodings)
train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False)

print(f"训练集: {len(train_dataset)}, 测试集: {len(test_dataset)}")

# 设置模型类型
print("选择模型类型:", args.model_type)

# 设置在cpu还是gpu上运行
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

# 选择模型类型
if args.model_type == 'cnn':
model = CNN(
vocab_size=vocab_size,
embed_dim=embed_dim,
num_intent_labels=len(intents_num),
)
elif args.model_type == 'cnn_seq':
model = CNNSeq(
vocab_size=vocab_size,
embed_dim=embed_dim,
num_intent_labels=len(intents_num),
)

# 加载模型到device
model.to(device)

# 定义损失函数
intent_loss_fn = nn.CrossEntropyLoss()
# 优化器选择AdamW
optimizer = torch.optim.AdamW(model.parameters(), lr=float(lr))

# 训练模型
for epoch in range(epochs):
print(f"\nEpoch {epoch+1}/{epochs} - {args.model_type}")
train_epoch(model, train_loader, optimizer, intent_loss_fn, device)
evaluate(model, test_loader, device, intent_loss_fn, args.model_type)

# 保存模型
ckpt_path = f"checkpoints/{args.model_type}_model.pth"
torch.save(model.state_dict(), ckpt_path)
print(f"模型已保存到 {ckpt_path}")

if __name__ == "__main__":
main()

准备内容

  • 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
2
3
4
5
6
7
8
{
"intent": "小区绿化率查询+容积率查询",
"query": "请问在南京市鼓楼区和武汉市硚口区这两个地区,绿化率排名前五的地块中,各自的容积率最低的是多少?",
"tokens": [
'请', '问', '在', '南', '京', '市', '鼓', '楼', '区', '和', '武', '汉', '市', '硚', '口', '区', '这', '两', '个', '地', '区', ',', '绿', '化', '率', '排', '名', '前', '五', '的', '地', '块', '中', ',', '各', '自', '的', '容', '积', '率', '最', '低', '的', '是', '多', '少', '?'
],
"slots": "O O O B-city I-city I-city B-district I-district I-district O B-city I-city I-city O I-district I-district O O O O O O O O O O O O O O O O O O O O O O O O O O O O O O O"
}

[!question] 但是为什么main里面直接加载的是pth呢?是怎么从json文件处理成pth的?pth还能读出来’train’,’test’和’val’?

来看看预处理部分:

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
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
# data_preprocessing.py

import json
import torch
from transformers import BertTokenizerFast
import os

intents_num = {'企业性质查询': 0,
'营业利润查询': 1,
'企业负债查询': 2,
'项目成交状况查询': 3,
'企业营业成本查询': 4,
'小区绿化率查询': 5,
'建筑密度查询': 6,
'小区成交均价查询': 7,
'营业总收入查询': 8,
'地块总价查询': 9,
'地块成交时间查询': 10,
'企业债务违约查询': 11,
'容积率查询': 12,
'企业风险查询': 13,
'地块归属查询': 14,
'项目开发商信息查询': 15
}

def load_json(file_path):
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f) # 加载json文件

processed_data = []
for idx, example in enumerate(data):
intent_str = example.get('intent', 'None') # 取出意图
tokens = example.get('tokens', []) # 取出tokens

# 处理意图,多个意图用“+”隔开,取第一个
intents = [intent.strip() for intent in intent_str.split('+')]
intent = intents[0]

# 处理processed_data
processed_data.append({
'tokens': tokens,
'intent': intent,
})
return processed_data

def encode_data(data, tokenizer, max_len=128):
input_ids = []
intent_labels = []

# 遍历json文件数据
for item in data:
tokens = item['tokens']
intent = item['intent']

# 编码tokens,使用BertTokenizerFast进行tokenize
encoding = tokenizer(
tokens,
is_split_into_words=True, # 输入已经分词
padding='max_length', # 填充到最大长度
truncation=True,
max_length=max_len,
return_offsets_mapping=True,
return_tensors='pt'
)
# 将token编码后的input_ids添加到input_ids
input_ids.append(encoding['input_ids'][0])

# 单标签意图编码
intent_id = intents_num.get(intent, -1) # 找到对应的id
intent_labels.append(torch.tensor(intent_id, dtype=torch.long)) # 添加到列表

return {
'input_ids': torch.stack(input_ids),
'intent_labels': torch.stack(intent_labels),
}

def main():
train_file = 'train.json'
val_file = 'val.json'
test_file = 'test.json'
save_path = 'data_preprocessed.pth'

if not os.path.exists(train_file) or not os.path.exists(test_file):
print("Error: train.json or test.json not found.")
return

# 分别加载数据
train_data = load_json(train_file)
val_data = load_json(val_file)
test_data = load_json(test_file)

# 加载tokenizer
tokenizer = BertTokenizerFast.from_pretrained('../../bert-base-chinese') ## 如果是正常下载的huggingface版本,则直接引用“bert-base-chinese”,如果下载到本地,则引用本地地址

# 分别编码数据
print("Encoding training data...")
train_encodings = encode_data(train_data, tokenizer)
print("Encoding validation data...")
val_encodings = encode_data(val_data, tokenizer)
print("Encoding test data...")
test_encodings = encode_data(test_data, tokenizer)

# 保存数据,以pth格式保存
torch.save({
'train': train_encodings,
'val': val_encodings,
'test': test_encodings,
}, save_path)
print(f"Preprocessed data saved to {save_path}")

if __name__ == "__main__":
main()
encode_data函数
  • 该函数的作用是将文本数据和意图标签转换成模型可以理解的格式。具体来说:
  1. 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_idsattention_masktoken_type_ids
      • input_ids:这是一个包含编码后token的ID列表。对于每个token,tokenizer会找到它在BERT词汇表中的对应ID。
      • 由于我们设置了return_tensors='pt',这个input_ids是一个PyTorch张量(tensor),它的形状通常是 [batch_size, sequence_length],但由于每次调用编码时只处理一个句子,batch_size为1。
  2. 意图标签编码
    • 将意图标签转换为数字ID。字典intents_num定义了每个意图对应的数字ID。
    • 如果在intents_num字典中找不到该意图(比如意图是'None'),则返回默认值-1
  3. 返回结果
    • 返回一个字典,其中包含:
      • 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
    image.png
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
2
3
4
5
input_ids = [
torch.tensor([101, 2023, 2003, 1037, 2742]), # 第一个句子
torch.tensor([101, 2023, 2003, 1037, 2742, 102]), # 第二个句子
torch.tensor([101, 2023, 2003, 2742, 102]) # 第三个句子
]

每个张量表示一个句子,[101, 2023, 2003, 1037, 2742] 是一个句子的 token ID 列表。
执行 torch.stack(input_ids) 后,得到一个新的张量:

1
2
stacked_tensor = torch.stack(input_ids)
print(stacked_tensor)

输出结果可能是:

1
2
3
tensor([[  101,  2023,  2003,  1037,  2742],
[ 101, 2023, 2003, 1037, 2742, 102],
[ 101, 2023, 2003, 2742, 102]])

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文件的优点是:
      1. 它可以存储大量数据(例如训练集、验证集、测试集的编码数据),这些数据是PyTorch张量,因此可以直接用于模型训练。
      2. .pth文件格式与PyTorch深度学习框架兼容,因此加载速度快,且方便在不同阶段读取、修改数据。

创建数据加载器

  • 为训练数据、测试数据创建数据集
  • 创建DataLoader用于数据加载

进行训练和评测

  • 在每个epoch调用train_epoch进行训练
  • 调用evaluate进行评测,找到最好的模型

dataset

[!question] 在main.py的时候,会看到这句话:print(f"训练集: {len(train_dataset)}, 测试集: {len(test_dataset)}"),但是,按照数据预处理返回的字典,似乎有input_idsintent_labels两个键,那这样不是只会返回2么?

这涉及到Dataset的处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import torch
from torch.utils.data import Dataset

class SLUDataset(Dataset):
# 继承自Dataset类,用于加载数据集,对__len__和__getitem__方法进行重写
def __init__(self, encodings):
self.encodings = encodings

def __len__(self):
return len(self.encodings['input_ids']) # 返回 input_ids键对应的值(一个一维tensor)的长度

def __getitem__(self, idx):
# item = {key: self.encodings[key][idx] for key in
# ['input_ids']}
# item['intent_labels'] = self.encodings['intent_labels'][idx]
# return item
# 这样好理解一点
return {
'input_ids': self.encodings['input_ids'][idx],
'intent_labels': self.encodings['intent_labels'][idx]
}

这个 SLUDataset 类是 PyTorch 中用于构建自定义数据集的核心组件,专门为语义理解(SLU)任务设计。它的核心功能是将预处理后的数据封装成 PyTorch 可识别的数据集格式。以下是对这个类的逐层解析:

类结构图示

1
2
3
4
5
6
7
classDiagram
class SLUDataset {
- encodings: dict
+ __init__(encodings)
+ __len__() int
+ __getitem__(idx) dict
}

核心方法详解

1. __init__(self, encodings)

作用:初始化数据集对象
参数说明

  • encodings:字典类型,必须包含以下两个键:
    • 'input_ids':形状为 [num_samples, max_seq_len] 的 tensor,存储 token 索引
    • 'intent_labels':形状为 [num_samples] 的 tensor,存储意图标签的数值编码

示例输入

1
2
3
4
5
encodings = {
'input_ids': torch.tensor([[101, 2345, ...], ...]), # 假设有100个样本
'intent_labels': torch.tensor([0, 3, 8, ...]) # 100个标签
}
dataset = SLUDataset(encodings)

2. __len__(self)

作用:返回数据集样本总数
关键点

  • 假设 input_idsintent_labels 长度严格一致
  • 如果存在其他数据字段(如 attention_mask),也需要保证长度一致

3. __getitem__(self, idx)

作用:根据索引获取单个样本
实现逻辑

1
2
3
4
5
6
7
def __getitem__(self, idx):
# 构建样本字典
item = {
'input_ids': self.encodings['input_ids'][idx], # 取第idx个样本的token序列
'intent_labels': self.encodings['intent_labels'][idx] # 对应标签
}
return item

数据流示例

1
2
3
4
5
# 假设idx=5
item = {
'input_ids': tensor([ 101, 2345, 3456, ..., 0]), # 长度=max_seq_len
'intent_labels': tensor(3) # 数值型标签
}

与DataLoader的配合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from torch.utils.data import DataLoader

# 创建数据集实例
dataset = SLUDataset(encodings)

# 创建DataLoader
loader = DataLoader(
dataset,
batch_size=16,
shuffle=True
)

# 迭代获取批次数据
for batch in loader:
print(batch['input_ids'].shape) # torch.Size([16, 128])
print(batch['intent_labels'].shape # torch.Size([16])

典型应用场景

1
2
3
4
5
6
7
8
9
# 查看单个样本
sample = dataset[10]
print(f"Token IDs: {sample['input_ids']}")
print(f"Intent Label: {sample['intent_labels']}")

# 统计数据集信息
print(f"总样本数: {len(dataset)}")
print(f"输入维度: {dataset[0]['input_ids'].shape}")
print(f"标签示例: {dataset[0]['intent_labels'].item()}")

model

回到model.py

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
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
# models.py

import torch
from torch import nn

#######################################
# CNN原始模型
#######################################

class CNN(nn.Module):
def __init__(self, vocab_size, embed_dim, num_intent_labels):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embed_dim) # 词嵌入层,输入为词索引,输出为词向量
self.pool = nn.AdaptiveMaxPool1d(1) # 池化层,输入为词向量,输出为句子的最大池化结果

self.conv1 = nn.Sequential(
nn.Conv1d(in_channels=embed_dim, out_channels=embed_dim, kernel_size=3, padding=1),
nn.BatchNorm1d(embed_dim),
nn.ReLU(),
nn.Dropout(0.5)
)

self.conv2 = nn.Sequential(
nn.Conv1d(in_channels=embed_dim, out_channels=embed_dim * 2, kernel_size=3, padding=1),
nn.BatchNorm1d(embed_dim * 2),
nn.ReLU(),
)

self.conv3 = nn.Sequential(
nn.Conv1d(in_channels=embed_dim * 2, out_channels=embed_dim, kernel_size=3, padding=1),
nn.BatchNorm1d(embed_dim),
nn.ReLU(),
)

self.intent_classifier = nn.Linear(embed_dim, num_intent_labels) # 分类层,输入为句子的最大池化结果,输出为意图标签的概率分布

def forward(self, input_ids):
'''
input_ids: [batch_size, seq_len],这是在传入时就设置的,即模型的输入
'''

# 词嵌入层
embeds = self.embedding(input_ids) # [batch_size, seq_len, embed_dim]
embeds = embeds.permute(0, 2, 1) # [batch_size, embed_dim, seq_len] 转换为CNN输入的格式,需要卷积的维度放在后面,为seq_len

# 卷积层
conv1_out = self.conv1(embeds) # [batch_size, embed_dim, seq_len]
conv2_out = self.conv2(conv1_out) # [batch_size, embed_dim * 2, seq_len]
conv3_out = self.conv3(conv2_out) # [batch_size, embed_dim, seq_len]

# 池化层
pooled = self.pool(conv3_out) # [batch_size, embed_dim, 1]
result = pooled.squeeze(-1) # [batch_size, embed_dim] 去除最后一个维度

# 分类
it_logits = self.intent_classifier(result) # [batch_size, num_intent_labels]

return it_logits

#######################################
# CNNSeq原始模型
#######################################

class CNNSeq(nn.Module):
def __init__(self, vocab_size, embed_dim, num_intent_labels):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embed_dim) # 词嵌入层,输入为词索引,输出为词向量

self.cnn_2 = nn.Conv1d(in_channels=embed_dim, out_channels=embed_dim // 4, kernel_size=2)
self.cnn_3 = nn.Conv1d(in_channels=embed_dim, out_channels=embed_dim // 4, kernel_size=3)
self.cnn_4 = nn.Conv1d(in_channels=embed_dim, out_channels=embed_dim // 4, kernel_size=4)
self.cnn_5 = nn.Conv1d(in_channels=embed_dim, out_channels=embed_dim // 4, kernel_size=5)

self.pool = nn.AdaptiveMaxPool1d(1) # 池化层,输入为词向量,输出为句子的最大池化结果

self.intent_classifier = nn.Linear(embed_dim, num_intent_labels) # 分类层,输入为句子的最大池化结果,输出为意图标签的概率分布

def forward(self, input_ids):
# 词嵌入
embeds = self.embedding(input_ids)
embeds = embeds.permute(0, 2, 1) # [batch_size, embed_dim, seq_len] 转换为CNN输入的格式,需要卷积的维度放在后面,为seq_len

# 卷积
cout_2 = self.cnn_2(embeds) # [batch_size, embed_dim // 4, seq_len - 1]
pool_2 = self.pool(cout_2) # [batch_size, embed_dim // 4, 1]

cout_3 = self.cnn_3(embeds) # [batch_size, embed_dim // 4, seq_len - 2]
pool_3 = self.pool(cout_3) # [batch_size, embed_dim // 4, 1]

cout_4 = self.cnn_4(embeds) # [batch_size, embed_dim // 4, seq_len - 3]
pool_4 = self.pool(cout_4) # [batch_size, embed_dim // 4, 1]

cout_5 = self.cnn_5(embeds) # [batch_size, embed_dim // 4, seq_len - 4]
pool_5 = self.pool(cout_5) # [batch_size, embed_dim // 4, 1]

# 拼接
pooled = torch.cat([pool_2.squeeze(-1), pool_3.squeeze(-1), pool_4.squeeze(-1), pool_5.squeeze(-1)], dim=1) # [batch_size, embed_dim]

# 分类
it_logits = self.intent_classifier(pooled) # [batch_size, num_intent_labels]

return it_logits

这段代码定义了两个模型,CNNCNNSeq,都用于自然语言处理中的意图分类任务。我们将逐步解析这两个模型的结构、nn.EmbeddingAdaptiveMaxPool1d 的作用,并讨论它们的区别。

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 中,词嵌入是为了捕捉单词之间的语义关系和语法信息。

image.png

[!note] permute(0, 2, 1) 的作用
因为PyTorch的Conv1d期望输入维度为[batch, channels, sequence],而Embedding输出是[batch, sequence, channels],因此需要调整维度
image.png

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]

image.png
image.png

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的方式融合特征? - 知乎

image.png

总结:

  • **nn.Embedding(vocab_size, embed_dim)**:将词索引转换为词向量,用于表示文本中的每个词。
  • **AdaptiveMaxPool1d(1)**:通过最大池化操作将序列的特征压缩为单个标量,保留最重要的特征。
  • CNNCNNSeq 的区别
    • CNN 使用三个卷积层提取文本特征,池化后通过全连接层分类。
    • CNNSeq 使用多个不同大小的卷积核,并将各个卷积核的池化结果拼接在一起,然后通过全连接层进行分类。

tokens 的全部变化过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 输入文本(已分词)
tokens = ['请', '问', '在', '南',...] # 假设长度=47

# 经过tokenizer编码(max_len=128)
input_ids = [101, 2345, 567, 890,..., 0, 0] # 长度128的向量

# 进入DataLoader后
batch = {
'input_ids': torch.Size([16, 128]), # 假设batch_size=16
'intent_labels': torch.Size([16])
}

# 经过Embedding层(embed_dim=128)
embeds = embedding(input_ids) # [16, 128, 128]

# CNN模型中的维度转换:
embeds.permute(0, 2, 1) # [16, 128, 128] → [16, 128, 128](此处可能设计不合理)
conv1_out = self.conv1(embeds) # 保持[16, 128, 128]
pooled = self.pool(conv3_out) # [16, 128, 1]
result = pooled.squeeze(-1) # [16, 128]

trainer

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
# trainer.py

import torch
import numpy as np
from tqdm import tqdm
from seqeval.metrics import classification_report, precision_score, recall_score, f1_score
from sklearn.metrics import accuracy_score, precision_recall_fscore_support

def train_epoch(model, loader, optimizer, intent_loss_fn, device):
# 训练模式
model.train()
total_loss = 0
for batch in tqdm(loader, desc="Training", leave=False):
optimizer.zero_grad()
# 导入数据
input_ids = batch['input_ids'].to(device) # [batch_size, seq_len]
intent_labels = batch['intent_labels'].to(device) # [batch_size]

it_logits = model(input_ids) # [batch_size, num_intents]

# 计算损失
loss_it = intent_loss_fn(it_logits, intent_labels)
loss_it.backward()
optimizer.step()

total_loss += loss_it.item()

avg_loss = total_loss / len(loader)
print(f"训练损失: {avg_loss:.4f}")
return avg_loss


def evaluate(model, loader, device, intent_loss_fn, model_type="cnn"):
# eval不开梯度回传,关闭dropout等
model.eval()

intent_preds, intent_true = [], []

with torch.no_grad():
for batch in loader:
input_ids = batch['input_ids'].to(device) # [batch_size, seq_len]
intent_labels = batch['intent_labels'].to(device) # [batch_size]

it_logits = model(input_ids) # [batch_size, num_intents]

# print('label size: ', intent_labels.shape)
# print('pred size: ', it_logits.shape)
it_logits_cpu = it_logits.cpu().numpy().argmax(axis=1) # [batch_size],取最大值索引作为预测标签
it_labels_cpu = intent_labels.cpu().numpy() # [batch_size], 真实标签

intent_preds.extend(it_logits_cpu) # 使用extend方法将预测标签添加到列表中
intent_true.extend(it_labels_cpu)
# print('label cpu size: ', intent_labels.shape)
# print('pred cpu size: ', it_logits.shape)

# 重新将列表转为numpy数组
it_true_np = np.array(intent_true)
it_pred_np = np.array(intent_preds)
# print(it_true_np)
# print(it_pred_np)
it_acc = accuracy_score(it_true_np, it_pred_np)
# 调用sklearn的函数计算宏平均精度、精确度、召回率、F1值
it_pre, it_rec, it_f1, _ = precision_recall_fscore_support(
it_true_np, it_pred_np, average='macro', zero_division=0
)

print("\n=== 意图识别 ===")
print(f"Acc: {it_acc:.4f}, MacroP: {it_pre:.4f}, MacroR: {it_rec:.4f}, MacroF1: {it_f1:.4f}")

return {
'intent_accuracy': it_acc,
'intent_precision': it_pre,
'intent_recall': it_rec,
'intent_f1': it_f1,
}
  • 注意训练的时候要开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_meanrunning_var)。
    公式:
    $$
    \text{BN}(x) = \gamma \cdot \frac{x - \mu_{\text{batch}}}{\sqrt{\sigma_{\text{batch}}^2 + \epsilon}} + \beta
    $$
  • model.eval()
    使用预计算的全局移动平均(running_meanrunning_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
2
3
4
5
6
7
model.train()  # 切换到训练模式
for data, target in train_loader:
optimizer.zero_grad()
output = model(data)
loss = criterion(output, target)
loss.backward()
optimizer.step() # 参数更新
验证/测试阶段
1
2
3
4
5
model.eval()  # 切换到评估模式
with torch.no_grad(): # 关闭梯度计算
for data, target in val_loader:
output = model(data)
accuracy = calculate_accuracy(output, target)

5. 常见错误及后果

  1. 在验证时忘记调用 model.eval()

    • Dropout 仍会随机屏蔽神经元 → 预测结果不稳定
    • BatchNorm 使用当前小批量统计 → 指标波动大
    • 示例:验证准确率从 85% 波动到 70%
  2. 在训练时误用 model.eval()

    • Dropout 失效 → 模型容易过拟合
    • BatchNorm 停止更新统计量 → 模型无法学习数据分布
    • 示例:训练损失停滞不降,准确率无法提升

总结

维度 model.train() model.eval()
随机性 启用(Dropout、数据增强等) 关闭(稳定输出)
统计量更新 实时更新(BatchNorm) 固定预计算值
梯度计算 启用 需配合 torch.no_grad() 关闭
适用阶段 训练 验证、测试、推理

正确使用这两个模式是保证模型性能的关键步骤,尤其是在需要稳定推理结果的场景(如模型部署)中,model.eval() 必不可少。

实验结果

task1:运行指令

1
2
python main.py --model cnn  
python main.py --model cnn_seq

CNN:
image.png

image.png

CNN_seq:
image.png

image.png

task2相关注释已保存在文件

task3 超参数调优

[!question] 探究不同超参数(学习率lr,batch_size,模型维度embed_dim)对模型的影响(在configs/config.yaml中修改超参数,利用验证集(val.json,对应代码中的data[‘val’])检验性能变化),并画这三种超参数对模型性能的影响的图表(折线图或柱状图均可)

  • 没有特别好说的,注意是在验证集上调优,因为我们并不知道对于测试集来说,什么样子的超参数是最好的(如果知道了就等同于作弊),因此需要在验证集上找到最优的超参数。
  • 代码只需要写个for循环遍历所有超参数即可。通常最理想的方法是做网格搜索,不过这里因为需要画图,所以就选择了固定其他两个超参数,单独调整某个超参数,查看最后指标(我选的是ACC,不过选其他的应该也可以?)变化情况

batch_size.png|475
embedding_dim.png|475
learning_rate.png|475

又是一些跟优化理论相关的内容了。。。

  • 小batch理论上效果会好,因为是对梯度取平均后下降,所以直观上来看小batch下降会更容易些,但是缺点是不稳定和时间更长,实际上batch大小还是一个trade off
  • embedding_dim不能设的太大,不然嵌入的空间太稀疏了,模型处理比较困难,而且冗余的信息不一定会很好地帮助训练,and容易过拟合
  • learning rate动态变化会好,因为epoch小的时候下降幅度太小了,当epoch到训练末期容易陷入山谷来回颠簸,不容易下降到最低点