基于循环神经网络的文本分类实践

1.循环神经网络(RNN)

环神经网络(Recurrent Neural Network, RNN)也叫递归神经网络,是专门处理序列数据的神经网络架构,其核心思想是通过循环连接使网络具备“记忆”能力,从而构建序列中时序之间的依赖关系。而处理具有时序或顺序关系的数据(如语言、语音、基因序列等)的核心挑战是理解序列中的上下文依赖关系
RNN有隐藏状态(hidden state),可以保留和传递之前时刻的信息,也就是有记忆功能,从而可实现有上下文依赖性的数据处理:
通俗一点就像是人在读一句话:

  • 读到 “我” → 记住
  • 读到 “今天” → 结合前面的信息
  • 读到 “很” → 继续理解上下文
  • 读到 “开心” → 知道整体含义”我今天很开心“

1.RNN结构

RNN 通过隐藏状态(Hidden State) 存储历史信息,并通过时间步(Time Step)进行递归计算

  • 输入层:接收当前时间步的输入 $x_t$
  • 隐藏层:包含一个循环连接,用于存储历史信息:$h_t = f(W_h h_{t-1} + W_x x_t + b)$
  • 输出层:根据隐藏状态计算输出 $y_t$

2.RNN 处理流程

1
输入序列 → RNN单元(时间步t=1)→ RNN单元(时间步t=2)→ ... → 输出

1.与卷积神经网络(CNN)对比

CNN是通过“卷积操作”提取图片中的局部特征,比如边缘、颜色、形状等,逐步构建对整个图像的理解。每个“卷积核”只看局部区域,不会直接处理整个图片。
CNN就像人类的大脑看图片时的处理方式:

  • 第一层 识别边缘
  • 第二层 识别形状
  • 第三层 识别复杂的物体
  • 最后输出 “这是一只猫🐱”

1.CNN结构

  • 卷积层(Convolutional Layer):使用卷积核(filter)提取图像局部特征
  • 激活函数(ReLU):引入非线性,使网络可以学习复杂模式
  • 池化层(Pooling Layer):减少特征维度,提高计算效率(如最大池化)
  • 全连接层(Fully Connected Layer, FC):将特征映射到最终输出(如分类)

2.CNN处理流程

1
输入图像 → 卷积层 → ReLU → 池化层 → 卷积层 → ReLU → 池化层 → 全连接层 → 输出

3.CNN和RNN对比

特性 卷积神经网络(CNN) 循环神经网络(RNN)
主要用途 主要用于处理图像和空间数据 主要用于处理序列数据和时间依赖数据
数据类型 适用于静态数据(如图像) 适用于动态数据(如时间序列、文本、语音)
架构特点 采用卷积层和池化层提取局部特征 采用循环连接保持时间序列依赖性
计算方式 并行计算(卷积运算可并行化) 依赖前序计算,难以并行化
长期依赖性 无长期依赖性,每个输入独立处理 具有记忆能力,可以捕捉长期依赖关系
梯度消失问题 无梯度消失问题 可能会遇到梯度消失(尤其是普通 RNN)
训练难度 计算高效,易训练 计算较复杂,可能需要 LSTM/GRU 解决梯度问题

2.长短期记忆网络(LSTM)

RNN 本质上有“记忆”能力,但由于 梯度消失问题,它很难记住 较长时间前的信息。LSTM 通过 引入“门控机制”,可以 有效记住长期信息,避免梯度消失,使其能处理更长的序列数据。

1. 通俗理解LSTM vs. RNN

想象一下,你是一名学生,要上 一整天的课,然后参加 测验

1.RNN

RNN就像是一个只有“短期记忆”的学生:

  • 上午 8:00 上数学课,学了 微积分,你记住了一些公式。
  • 上午 10:00 上英语课,学了 语法规则,你还记得大部分内容。
  • 下午 2:00 上历史课,学了 第二次世界大战,但你开始忘记上午学的微积分
  • 下午 4:00 上物理课,学了 电磁学,但你基本已经忘了微积分和语法规则

测验 需要你用 微积分 来解物理题时,RNN 学生发现:“糟糕!我已经不记得微积分怎么用了!” ,RNN 只能记住最近的知识,对于较早学的内容,信息会逐渐丢失(梯度消失问题)。

2.LSTM

LSTM 就像是一个擅长做笔记的学生,有一本“记忆笔记本”

  • 上午 8:00 上数学课,你在笔记本上记录微积分公式
  • 上午 10:00 上英语课,你继续做语法笔记
  • 下午 2:00 上历史课,你决定删掉不重要的细节,但保留关键事件
  • 下午 4:00 上物理课,当你看到电磁学需要用微积分时,你翻开笔记本,找到微积分公式

测验 要求你用微积分解物理题时,LSTM 学生发现:“太好了!我有笔记!我可以回忆起微积分!”

3.总结
  • LSTM 有“记忆笔记本”(细胞状态),可以长期保存重要信息。
  • LSTM 有“遗忘门”,可以丢弃不重要的信息(比如历史课不相关的细节)。
  • LSTM 有“输入门”,可以选择性存入新知识(物理课需要微积分)。
  • LSTM 有“输出门”,可以从记忆中提取正确的信息(在考试时用微积分解题)。
4.对比
对比点 RNN(普通学生) LSTM(做笔记的学生)
能记住的信息量 只能记住最近的信息 可以记住更久的信息
信息丢失 早期学的知识逐渐遗忘 重要信息可以长时间保存
遇到复杂问题 可能忘记关键点 可以回忆笔记,找到答案
适合的任务 短文本、短时间序列 长文本、长时间序列

2.概述

1.数据集

现有train.txt(18万条)、dev.txt(1万条)和test.txt(1万条)三个数据集,分别对应训练集、验证集和测试集,每条数据格式都一样,下面是训练集前6条数据:

1
2
3
4
5
6
金证顾问:过山车行情意味着什么	2
中华女子学院:本科层次仅1专业招男生 3
两天价网站背后重重迷雾:做个网站究竟要多少钱 4
东5环海棠公社230-290平2居准现房98折优惠 1
卡佩罗:告诉你德国脚生猛的原因 不希望英德战踢点球 7
82岁老太为学生做饭扫地44年获授港大荣誉院士 5

金证顾问:过山车行情意味着什么为新闻标题,2为这个新闻对应的类别,对应class.txt中10类别的stocks

目标:通过训练train.txt中的数据,生成模型,再推理test.txt中新闻标题对应类别,并计算准确度。

2.词汇表

vocab.pkl是词汇表,存储词汇到索引的映射,用于将文本转换为神经网络可处理的数字格式,有两个作用:

  1. 模型训练时,它用于将文本转换为索引(tokenization)
  2. 模型预测时,它用于将索引转换回单词(解码)
1
{' ': 0, '0': 1, '1': 2, '2': 3, ':': 4, '大': 5, '国': 6, '图': 7, '(': 8, ')': 9, '3': 10, '人': 11, '年': 12, '5': 13, '中': 14, '新': 15, '9': 16, '生': 17, '金': 18, '高': 19, '《': 20, '》': 21, '4': 22, '上': 23, '8': 24, '不': 25, '考': 26, '一': 27, '6': 28, '日': 29, '元': 30, '开': 31, '美': 32, ...

3.预训练词向量

预训练词向量(如 word2vec、GloVe)是在 海量文本数据(如 Wikipedia、新闻)上训练得到的,它们能够:

  • 捕捉单词的语义关系(如 “king” - “man” + “woman” ≈ “queen”)
  • 处理语境相似的单词(如 “big” 和 “large” 词向量相近)
  • 减少训练数据对模型性能的影响(少量数据也能学得不错的表示)

预训练词向量 = 语言理解的“知识库”,能跨任务共享信息。embedding_SougouNews.npzembedding_Tencent.npz是搜狗和腾讯提供的两个预训练词向量库。

4.训练数据转化流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 1.训练集数据 (新闻标题 对应分类)
金证顾问:过山车行情意味着什么 2

# 2.对应词索引 ([列表, 标签, 序列长度])
[([18, 249, 1086, 438, 4, 268, 169, 121, 46, 143, 274, 1342, 1068, 1046, 1081, 4760, 4760, 4760, 4760, 4760, 4760, 4760, 4760, 4760, 4760, 4760, 4760, 4760, 4760, 4760, 4760, 4760], 2, 15)]

# 3. to tensor (batch_size = 128)
tensor([[18, 249, 1086, ..., 4760, 4760, 4760],
[14, 125, 55, ..., 4760, 4760, 4760],
...,
[160, 1667, 1147, ..., 4760, 4760, 4760],
[31, 75, 4, ..., 4760, 4760, 4760],
[321, 566, 130, ..., 4760, 4760, 4760]]),
tensor([15, 18, 22, 25, 25, 23, 20, 17, 22, 16, 11, 23, 23, 22, 7, 23, 20, 25,
15, 9, 17, 15, 24, 20, 17, 17, ..., 18, 18, 14, 19, 13, 29, 20, 18, 22, 16, 18, 22])),
tensor([2, 3, 4, 1, 7, 5, 5, 9, 1, 8, 4, 3, 7, 5, 1, 8, 1, 1, 8, 4, 4, 6, 7, 1,
9, 4, 2, 9, 4, 2, 2, 9, 8, ..., 5, 9])

# 4. 映射为词向量
tensor([[[ 3.0235e-01, 2.0894e-01, -8.0932e-02, ..., -4.3194e-02,
-3.1051e-01, 1.8790e-01], [ 3.7446e-02, -5.7123e-02, -2.5790e-01, ..., -2.9264e-01, 1.8909e-01, -5.4846e-01], [-2.5890e-02, 1.3263e-01, -4.0175e-01, ..., 3.4654e-01, -5.0803e-01, -1.8250e-01], ..., [ 5.0378e-01, 6.4967e-01, 4.0962e-01, ..., 6.4058e-01, 2.7467e-01, 7.9185e-01], [ 5.0378e-01, 6.4967e-01, 4.0962e-01, ..., 6.4058e-01, 2.7467e-01, 7.9185e-01], [ 5.0378e-01, 6.4967e-01, 4.0962e-01, ..., 6.4058e-01, 2.7467e-01, 7.9185e-01]],
[[ 3.1487e-01, -3.2435e-01, 1.3675e-01, ..., 1.9030e-01, 1.3956e-01, 7.8458e-02], [-1.5683e-02, 9.9436e-02, -4.0968e-01, ..., 2.0924e-01, ...]]], grad_fn=<EmbeddingBackward0>)

2.数据处理

1.命令行参数配置

因为本次文本分类定义了两个模型,Text_CNNText_RNN,同时词向量映射支持搜狗和腾讯的预训练词向量和随机词向量,排列组合后有6种训练方式,为了方便可以使用命令行的方式配置参数:

1
python run.py --model text_rnn --embedding sougou

如果想在PyCharm中配置,Edit Configurations... -> Script parameters中添加:

1
--model text_cnn --embedding tencent
1
2
3
4
5
# 通过命令行的方式指定参数  
parser = argparse.ArgumentParser(description="Classification Text")
parser.add_argument('--model', type=str, required=True, help="choose model:Text_CNN, Text_RNN")
parser.add_argument('--embedding', default='sogou', type=str, help='random or sogou、tencent')
args = parser.parse_args()

args.modelargs.embedding就可以拿到对应参数

1
2
3
4
model_name = args.model  
embedding = args.embedding
print(f"args model:{model_name}, embedding:{embedding}")
# args model:Text_RNN, embedding:tencent

2.资源配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class SourceConfig(object):  
def __init__(self, dataset, _embedding):
self.train_path = dataset + '/data/train.txt' # 训练集路径
self.dev_path = dataset + '/data/dev.txt' # 验证集路径
self.test_path = dataset + '/data/test.txt' # 测试集路径
self.class_list = [x.strip() for x in open(dataset + '/data/class.txt').readlines()] # 分类类别
self.vocab_path = dataset + '/data/vocab.pkl' # 词表路径
self.num_classes = len(self.class_list) # 类别个数
self.embedding_pretrained = (torch.tensor( # 词向量
np.load(dataset + '/data/' + _embedding)["embeddings"].astype('float32'))
if _embedding != 'random' else None # random返回None
) # 词向量
self.embed = ( # 字向量维度, 若使用了预训练词向量,则维度统一
self.embedding_pretrained.size(1)
if self.embedding_pretrained is not None else 300 # 等于None返回300
)
self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') # 设备类型

词向量参数如果设置为了random,词向量会在训练模型初始化的时候随机初始化词向量。

3.随机种子

在深度学习中,很多地方都会用到随机性,比如随机初始化模型参数、数据加载时的随机打乱、Dropout 层的随机性、优化器中的随机梯度下降等。
为了保证每次运行代码都会得到相同的结果,需要设置随机种子,保证每次运行代码时生成的随机数是相同的。

1
2
np.random.seed(1)
print(np.random.rand(3)) # 每次运行输出都是:[4.17022005e-01 7.20324493e-01 1.14374817e-04]
1
2
3
4
5
6
def keep_seed():  
# 固定种子,保证在运行时的随机性和计算过程是可重复的
np.random.seed(1)
torch.manual_seed(1)
torch.cuda.manual_seed_all(1)
torch.backends.cudnn.deterministic = True

4.加载dataset

SourceConfig中添加了训练集、验证集和测试集的路径,还需要加载对应的训练集数据并通过词汇表转换为对应的索引映射。

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
MAX_VOCAB_SIZE = 10000  # 词表长度限制  
UNK, PAD = '<UNK>', '<PAD>' # 未知字,padding符号

def get_time_dif(start_time):
# 获取已使用时间
end_time = time.time()
time_dif = end_time - start_time
return timedelta(seconds=int(round(time_dif)))

def _load_dataset(path, vocab, pad_size=32):
contents = []
with open(path, 'r', encoding='UTF-8') as f:
for line in tqdm(f): # 自动打印进度信息
lin = line.strip() # 去除收尾空格
if not lin: # 跳过空行
continue
content, label = lin.split('\t')
words_line = []
token = [y for y in content] # 分字
seq_len = len(token)
if pad_size:
if len(token) < pad_size:
# 不足补PAD
token.extend([vocab.get(PAD)] * (pad_size - len(token)))
else:
token = token[:pad_size] # 超过最大长度就截断
seq_len = pad_size # 重新设定序列长度
for word in token: # 将单词/字符转换为索引
words_line.append(vocab.get(word, vocab.get(UNK))) # UNK代表未知词
contents.append((words_line, int(label), seq_len))
return contents # 结构:[(词索引列表, 标签, 序列长度)]

def build_dataset(model, config):
vocab = pkl.load(open(config.vocab_path, 'rb'))
print(f"Vocab size: {len(vocab)}")
train = _load_dataset(config.train_path, vocab, model.pad_size)
dev = _load_dataset(config.dev_path, vocab, model.pad_size)
test = _load_dataset(config.test_path, vocab, model.pad_size)
return vocab, train, dev, test

目前pad_size的大小设置是32,如果文本长度小于32的部分补齐PAD,超过部分则截断。如果文本中有字不在词汇表中无法映射,则用UNK替代,UNKPAD分别对应的词汇表映射是4760和4761。
转换前的文本:
金证顾问:过山车行情意味着什么
转换后的文本:

1
[18, 249, 1086, 438, 4, 268, 169, 121, 46, 143, 274, 1342, 1068, 1046, 1081, 4760, 4760, 4760, 4760, 4760, 4760, 4760, 4760, 4760, 4760, 4760, 4760, 4760, 4760, 4760, 4760, 4760]

5.迭代器和toTensor

1.迭代器

可用于遍历可迭代对象(如列表、元组、字典、集合等)。Iterator 通过 __iter__() 和 __next__() 方法实现,允许我们逐个访问元素,也可以自定义一次遍历多个元素,举个简单例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class MyRange:
def __init__(self, start, end):
self.start = start
self.end = end

def __iter__(self):
return self

def __next__(self):
if self.start >= self.end:
raise StopIteration
current = self.start
self.start += 1
return current

# 使用自定义迭代器
my_range = MyRange(1, 5)
for num in my_range:
print(num) # 输出: 1 2 3 4

自定义DatasetIterator迭代器,一次返回batch_size个元素,如果不满足batch_size则返回剩余元素

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
class DatasetIterator(object):  

def __init__(self, dataset, batch_size, device):
self.dataset = dataset
self.batch_size = batch_size
self.device = device
self.index = 0
self.num_batches = len(dataset) // batch_size # batch数量
self.residue = len(self.dataset) % self.num_batches != 0 # batch数量是否正好为整数
def __next__(self): # 迭代器
if self.residue and self.index == self.num_batches:
# 取最后非batch_size大小段
batches = self.dataset[self.index * self.batch_size: len(self.dataset)]
self.index += 1
batches = self._to_tensor(batches)
return batches
elif self.index > self.num_batches:
self.index = 0
raise StopIteration
else:
# 取batch_size下一段
batches = self.dataset[self.index * self.batch_size: (self.index + 1) * self.batch_size]
self.index += 1
batches = self._to_tensor(batches)
return batches

def __iter__(self): # 可迭代对象
return self

def __len__(self): # 容器对象
if self.residue:
return self.num_batches + 1
else:
return self.num_batches

def build_iterator(dataset, batch_size, device):
return DatasetIterator(dataset, batch_size, device)

2.Tensor

 将数据转换为 PyTorch 的张量(Tensor),并移动到指定的设备(CPU/GPU)以备模型训练

1
2
3
4
5
6
7
def _to_tensor(self, datas):  
x = torch.LongTensor([_[0] for _ in datas]).to(self.device)
y = torch.LongTensor([_[1] for _ in datas]).to(self.device)

# pad前的长度(超过pad_size的设为pad_size)
seq_len = torch.LongTensor([_[2] for _ in datas]).to(self.device)
return (x, seq_len), y

这一步可同时完成训练集验证集和测试集的Tensor数据准备

1
2
3
train_iter = build_iterator(train_data, model.batch_size, source_config.device)  
dev_iter = build_iterator(dev_data, model.batch_size, source_config.device)
test_iter = build_iterator(test_data, model.batch_size, source_config.device)

3.模型定义

1.RNN模型

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
class Model(nn.Module):  
def __init__(self, config, dataset):
super(Model, self).__init__()
self.model_name = 'TextRNN'
self.save_path = dataset + '/saved_dict/' + self.model_name + '.ckpt' # 模型训练结果
self.log_path = dataset + '/log/' + self.model_name # 日志路径
self.pad_size = 32 # 每句话处理成的长度(短填长切)
self.dropout = 0.5 # 随机失活
self.require_improvement = 1000 # 若超过1000batch效果还没提升,则提前结束训练
self.n_vocab = 0 # 词表大小,运行时赋值
self.num_epochs = 10 # epoch数
self.batch_size = 128 # mini-batch大小
self.learning_rate = 1e-3 # 学习率
self.hidden_size = 128 # lstm隐藏层
self.num_layers = 2 # lstm层数

# 1.初始化词嵌入层
if config.embedding_pretrained is not None:
self.embedding = nn.Embedding.from_pretrained(config.embedding_pretrained, freeze=False)
else:
vocab = pkl.load(open(config.vocab_path, 'rb'))
self.n_vocab = len(vocab)
self.embedding = nn.Embedding(self.n_vocab, config.embed, padding_idx=self.n_vocab - 1)

# 2. LSTM
self.lstm = nn.LSTM(config.embed, self.hidden_size, self.num_layers, bidirectional=True, batch_first=True, dropout=self.dropout)
# 3. 全连接层
self.fc = nn.Linear(self.hidden_size * 2, config.num_classes)

def forward(self, x):
x, _ = x # 解包输入
out = self.embedding(x)
out, _ = self.lstm(out) # LSTM处理
out = self.fc(out[:, -1, :]) # 取最后时间步的输出,传入全连接层
return out

1.模型属性

1.初始化词嵌入层

在初始化词嵌入层的时候,如果config.embedding_pretrained参数有设置,则使用预训练词向量,否则使用随机初始化的词向量

2.LSTM

用于处理 文本序列,捕捉 长期依赖信息

参数 作用
config.embed 词向量的维度(每个单词的向量表示大小,例如 300)
self.hidden_size LSTM 隐藏层的维度(影响 LSTM 记忆能力,例如 128)
self.num_layers LSTM 堆叠的层数(如 2,表示有 2 层 LSTM)
bidirectional=True 双向 LSTM(前向和后向 LSTM)
batch_first=True 输入数据格式为 (batch_size, seq_len, input_dim),即 batch 维度在第一位
dropout=self.dropout LSTM 层之间的 dropout 率,防止过拟合

双向 LSTM可以同时从 前向和后向 处理句子,增强了对前后文的理解,提高文本分类、命名实体识别等任务的效果

3.全连接层

因为使用了双向 LSTM (bidirectional=True),隐藏层的输出维度是 正向 LSTM 输出 + 反向 LSTM 输出,所以最终LSTM的输出维度是hidden_size * 2
全连接层的输入维度必须是 256,最后将数据映射到 num_classes 个类别

2.前向传播

主要包括:解包输入、词嵌入、LSTM 处理、取最后时间步的输出、通过全连接层

  • x, _ = x输入 x 是一个 元组,包含 (x, seq_len)只取x
  • out = self.embedding(x),把 x 中的词索引转换成词向量
  • out, _ = self.lstm(out),将词向量输入 LSTM,提取序列特征
  • out = self.fc(out[:, -1, :])out[:, -1, :]取序列的最后一个时间步的隐藏状态,形状变为 (batch_size, hidden_size * 2),为什么要取最后时间步?
    • 在文本分类任务中,我们通常只关心整个句子的表示,而不需要每个时间步的输出
    • 方法:用 LSTM 处理整个句子,取最后的隐藏状态作为句子表示,再进行分类

3.模型参数

1
2
3
4
5
<bound method Module.parameters of Model(
(embedding): Embedding(4762, 300)
(lstm): LSTM(300, 128, num_layers=2, batch_first=True, dropout=0.5, bidirectional=True)
(fc): Linear(in_features=256, out_features=10, bias=True)
)>

2.CNN模型

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
def conv_and_pool(x, conv):  
x = F.relu(conv(x)).squeeze(3)
x = F.max_pool1d(x, x.size(2)).squeeze(2)
return x

class Model(nn.Module):

def __init__(self, config, dataset):
super(Model, self).__init__()
self.model_name = 'TextCNN'
self.save_path = dataset + '/saved_dict/' + self.model_name + '.ckpt' # 模型训练结果
self.log_path = dataset + '/log/' + self.model_name # 日志
self.pad_size = 32 # 每句话处理成的长度(短填长切)
self.batch_size = 128
self.dropout = 0.5 # 随机失活
self.require_improvement = 1000 # 若超过1000batch效果还没提升,则提前结束训练
self.n_vocab = 0 # 词表大小,在运行时赋值
self.num_epochs = 20 # epoch数
self.learning_rate = 1e-3 # 学习率
self.filter_sizes = (2, 3, 4) # 卷积核尺寸
self.num_filters = 256 # 卷积核数量(channels数)

if config.embedding_pretrained is not None:
self.embedding = nn.Embedding.from_pretrained(config.embedding_pretrained, freeze=False)
else:
vocab = pkl.load(open(config.vocab_path, 'rb'))
self.n_vocab = len(vocab)
self.embedding = nn.Embedding(self.n_vocab, config.embed, padding_idx=self.n_vocab - 1)
self.conv = nn.ModuleList(
[nn.Conv2d(1, self.num_filters, (k, config.embed)) for k in self.filter_sizes])
self.dropout = nn.Dropout(self.dropout)
self.fc = nn.Linear(self.num_filters * len(self.filter_sizes), config.num_classes)

def forward(self, x):
out = self.embedding(x[0])
out = out.unsqueeze(1)
out = torch.cat([conv_and_pool(out, conv) for conv in self.conv], 1)
out = self.dropout(out)
out = self.fc(out)
return out

1.模型属性

1.初始化词嵌入层

在初始化词嵌入层的时候,如果config.embedding_pretrained参数有设置,则使用预训练词向量,否则使用随机初始化的词向量(同RNN模型)

2.卷积层

Conv2d(1, self.num_filters, (k, config.embed))

  • 1 表示单通道输入(即词向量维度不变)
  • self.num_filters每种卷积核的数量,每个 filter 提取不同的 k-gram 组合(如 bi-gram、tri-gram)
  • (k, config.embed) 卷积核尺寸,k 控制窗口大小,embed 让每个 filter 作用于整个词向量
3.全连接层 & Dropout
  • self.dropout:防止过拟合。
  • self.fc:将所有 filter 提取的特征拼接,然后进行分类:
  • self.num_filters * len(self.filter_sizes):每个 filter 贡献 num_filters 维,多个 filter 拼接在一起

2.前向传播

  • out = self.embedding(x[0]):输入x是 (batch_size, seq_len),其中每个值是词索引
  • out = out.unsqueeze(1): 变成四维,1 代表通道数(适配 Conv2d)
  • out = torch.cat([conv_and_pool(out, conv) for conv in self.conv], 1):进行卷积和池化操作,对每个 filter 进行 conv_and_pool,拼接不同 filter 提取的特征
  • out = self.dropout(out):防止过拟合
  • out = self.fc(out):全连接层,最终输出 out 形状 (batch_size, num_classes),即每个样本的分类得分

3.模型参数

1
2
3
4
5
6
7
8
9
10
<bound method Module.parameters of Model(  
(embedding): Embedding(4762, 300)
(conv): ModuleList(
(0): Conv2d(1, 256, kernel_size=(2, 300), stride=(1, 1))
(1): Conv2d(1, 256, kernel_size=(3, 300), stride=(1, 1))
(2): Conv2d(1, 256, kernel_size=(4, 300), stride=(1, 1))
)
(dropout): Dropout(p=0.5, inplace=False)
(fc): Linear(in_features=768, out_features=10, bias=True)
)>

4.训练模型

1.权重初始化

初始化神经网络中的参数,以提高训练的稳定性和收敛速度

1
2
3
4
5
6
7
8
9
10
11
12
def init_network(model, method='xavier', exclude='embedding'):  
for name, w in model.named_parameters():
if exclude not in name:
if 'weight' in name:
if method == 'xavier':
nn.init.xavier_normal_(w) # 适用于 sigmoid/tanh/RNN 网络,保持输入和输出的方差一致
elif method == 'kaiming':
nn.init.kaiming_normal_(w) # 适用于 ReLU 及其变种,避免梯度消失
else:
nn.init.normal_(w) # 一般情况,但不如 Xavier 或 Kaiming 稳定
elif 'bias' in name:
nn.init.constant_(w, 0) # 偏置一般不需要复杂初始化,设为 0 即可

如果 name 包含 ‘embedding’,则跳过,不进行初始化。原因:

  • 词嵌入层通常使用 预训练词向量(如 word2vec 或 GloVe)。
  • 直接初始化可能会破坏预训练好的词向量结构

只对 权重 (weight) 进行特殊初始化。偏置 (bias) 一般设为 0,避免影响梯度更新。原因是bias 主要用于调整激活函数的输入值,不需要随机初始化。

Xavier 初始化:作用是保证输入和输出的方差一致,防止梯度消失或爆炸。
Kaiming 初始化:作用是避免 ReLU 可能导致的梯度消失问题。
nn.init.normal_(w):使用正态分布随机初始化(均值 = 0,标准差 = 1),但不如 Xavier 或 Kaiming 稳定。

2.模型训练

大致过程:

  • 计算损失和准确率
  • 使用验证集评估模型
  • 动态调整学习率
  • 保存最优模型
  • 支持早停(early stopping)
  • 记录训练日志到 TensorBoard
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
def train(config, model, train_iter, dev_iter, test_iter, writer): 

# 1️⃣ 初始化,记录开始时间,模型为训练模式,使用Adam优化器初始化模型参数
start_time = time.time()
model.train()
optimizer = torch.optim.Adam(model.parameters(), lr=model.learning_rate)

# 2️⃣ 学习率调度器,验证集损失不下降时才调整学习率,避免不必要的衰减
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', factor=0.5, patience=2) # patience=2如果验证损失 **2 个周期没下降**,就调整学习率

# 3️⃣ 训练参数
total_batch = 0 # 记录进行到多少 batch
dev_best_loss = float('inf') # 记录最优验证 loss
last_improve = 0 # 记录上次验证集 loss 下降的 batch 数
flag = False # 记录是否长时间未提升
train_loss_sum, train_acc_sum, batch_count = 0, 0, 0 # 累积 loss 和 acc 计算整个 epoch 的平均值

# 4️⃣ 训练循环epoch
for epoch in range(model.num_epochs):
print(f'Epoch [{epoch + 1}/{model.num_epochs}]')

# 5️⃣ 训练每个批次,每次取 batch_size 批量数据
for _, (trains, labels) in enumerate(train_iter):
outputs = model(trains) # 前向传播,计算输出结果

optimizer.zero_grad() # 梯度清空,防止累计导致的梯度混合

loss = F.cross_entropy(outputs, labels) # 计算损失
loss.backward() # 反向传播 计算损失相对于模型参数的梯度
optimizer.step() # 更新模型参数

# 6️⃣ 计算 batch 级别的训练准确率
labels_cpu = labels.data.cpu()
predict = torch.max(outputs.data, 1)[1].cpu() # 获取预测类别索引
train_acc = metrics.accuracy_score(labels_cpu, predict) # 计算准确率
# 7️⃣ 记录训练数据 累计loss和acc计算整个epoch的平均值
train_loss_sum += loss.item()
train_acc_sum += train_acc
batch_count += 1

# 8️⃣ 每100个batch进行一次验证
if total_batch % 100 == 0:
dev_acc, dev_loss = evaluate(config, model, dev_iter)

if dev_loss < dev_best_loss: # 早停策略
dev_best_loss = dev_loss
torch.save(model.state_dict(), model.save_path) # 保存最优模型
improve = '*' # 记录模型有提升
last_improve = total_batch
else:
improve = ''

time_dif = get_time_dif(start_time)
msg = ('Iter: {0:>6}, Train Loss: {1:>5.2f}, Train Acc: {2:>6.2%},''Val Loss: {3:>5.2f}, Val Acc: {4:>6.2%}, Time: {5} {6}')
print(msg.format(total_batch, loss.item(), train_acc, dev_loss, dev_acc, time_dif, improve))

# 记录 loss 和 acc 到 TensorBoard
writer.add_scalar("loss/train", loss.item(), total_batch)
writer.add_scalar("loss/dev", dev_loss, total_batch)
writer.add_scalar("acc/train", train_acc, total_batch)
writer.add_scalar("acc/dev", dev_acc, total_batch)

model.train()

# 调整学习率 (基于 dev_loss)
scheduler.step(dev_loss) # ReduceLROnPlateau 需要 loss 作为输入

total_batch += 1

# 9️⃣ 早停策略 如果long time no improvement,则early stop
if total_batch - last_improve > model.require_improvement:
print("No optimization for a long time, auto-stopping...")
flag = True
break
if flag:
break

# 🔟 计算整个epoch平均训练loss和acc
avg_train_loss = train_loss_sum / batch_count
avg_train_acc = train_acc_sum / batch_count
print(f"Epoch [{epoch + 1}/{model.num_epochs}] - Avg Train Loss: {avg_train_loss:.4f}," f" Avg Train Acc: {avg_train_acc:.4%}")
# 记录epoch级别指标到TensorBoard
writer.add_scalar("epoch_loss/train", avg_train_loss, epoch)
writer.add_scalar("epoch_acc/train", avg_train_acc, epoch)

# 结束训练
writer.close()
_eval_result(config, model, test_iter)

3.可视化训练过程

SummaryWriter 是 PyTorch 中 ​TensorBoard 的一个接口,用于记录和可视化训练过程中的各种信息(如损失、准确率、模型权重分布、图像、音频等),它可以帮助你更好地理解和调试模型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 导入
from dataset_Iterator import build_iterator

# 创建
writer = SummaryWriter(log_dir=model.log_path + '/' + time.strftime('%m-%d_%H.%M', time.localtime()))

# 记录loss和acc到TensorBoard
writer.add_scalar("loss/train", loss.item(), total_batch)
writer.add_scalar("loss/dev", dev_loss, total_batch)
writer.add_scalar("acc/train", train_acc, total_batch)
writer.add_scalar("acc/dev", dev_acc, total_batch)
# 记录epoch级别指标到TensorBoard
writer.add_scalar("epoch_loss/train", avg_train_loss, epoch)
writer.add_scalar("epoch_acc/train", avg_train_acc, epoch)

# 关闭
writer.close()

下面是对模型CNNRNN的训练过程可视化展示:

5.评估模型

1.评估函数

用于配合模型训练

如果 _test=False,只返回准确率 (accuracy) 和 损失 (loss)
如果 _test=True,返回 完整的分类报告和混淆矩阵

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
def evaluate(config, model, data_iter, _test=False):
# 1️⃣ 评估模式,冻结Dropout和BatchNorm影响,确保预测稳定
model.eval()

# 2️⃣ 初始化变量
loss_total = 0 # 累计损失,用于计算平均损失
predict_all = np.array([], dtype=int) # 存储所有预测标签
labels_all = np.array([], dtype=int) # 存储所有真实标签
with torch.no_grad(): # 不计算梯度,减少显存占用,提高计算效率

# 3️⃣ 逐批处理数据
for texts, labels in data_iter:
outputs = model(texts)
loss = F.cross_entropy(outputs, labels)
loss_total += loss

# 4️⃣ 处理预测结果
labels = labels.data.cpu().numpy()
# 取出最大概率对应的类别索引(即预测类别)
predict = torch.max(outputs.data, 1)[1].cpu().numpy()
labels_all = np.append(labels_all, labels)
predict_all = np.append(predict_all, predict)

# 5️⃣ 计算准确率
acc = metrics.accuracy_score(labels_all, predict_all)
if _test: # 测试模式
report = metrics.classification_report(labels_all, predict_all, target_names=config.class_list, digits=4)
confusion = metrics.confusion_matrix(labels_all, predict_all)
return acc, loss_total / len(data_iter), report, confusion
return acc, loss_total / len(data_iter)

2.评估模型

用于在测试集上评估模型的最终表现

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
def _eval_result(config, model, test_iter):
# 1️⃣ 加载训练好的模型参数
model.load_state_dict(torch.load(model.save_path, weights_only=True))

# 2️⃣ 切换为评估模式
model.eval()

# 3️⃣ 计算测试集上的评估指标
start_time = time.time()
# 测试集准确率 测试集平均损失 分类报告 混淆矩阵
test_acc, test_loss, test_report, test_confusion = evaluate(config, model, test_iter, _test=True)

# 4️⃣ 打印测试结果
msg = 'Test Loss: {0:>5.2}, Test Acc: {1:>6.2%}'
print(msg.format(test_loss, test_acc))

# 5️⃣ 打印分类报告
# Precision(精确率),Recall(召回率),F1-Score(F1分数)
# Precision 关注的是“预测为正类的样本中,有多少是真正的正类”
# Recall 关注的是“所有真实正类的样本中,有多少被正确识别出来”
# F1-Score Precision 和 Recall 有时候会相互矛盾,为了找到平衡点
print("Precision, Recall and F1-Score...")
print(test_report)

# 6️⃣ 打印混淆矩阵,显示真实类别和预测类别的对应关系,横轴:预测类别。纵轴:真实类别
print("Confusion Matrix...")
print(test_confusion)

# 7️⃣ 计算测试时间
time_dif = get_time_dif(start_time)
print("Time usage:", time_dif)

RNN模型评估结果:

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
args model:text_rnn, embedding:sougou
Test Loss: 0.28, Test Acc: 91.16%
Precision, Recall and F1-Score...
precision recall f1-score support
finance 0.9113 0.8840 0.8975 1000
realty 0.9077 0.9340 0.9207 1000
stocks 0.8617 0.8290 0.8451 1000
education 0.9327 0.9570 0.9447 1000
science 0.8635 0.8600 0.8617 1000
society 0.9002 0.9200 0.9100 1000
politics 0.8841 0.8850 0.8846 1000
sports 0.9809 0.9760 0.9784 1000
game 0.9356 0.9300 0.9328 1000
entertainment 0.9363 0.9410 0.9387 1000

accuracy 0.9116 10000
macro avg 0.9114 0.9116 0.9114 10000
weighted avg 0.9114 0.9116 0.9114 10000
Confusion Matrix...
[[ 884 26 55 6 9 5 11 1 0 3]
[ 10 934 14 2 5 17 6 2 2 8]
[ 54 27 829 3 44 3 33 0 5 2]
[ 1 2 0 957 7 17 7 0 1 8]
[ 4 8 29 10 860 17 21 1 40 10]
[ 2 13 0 20 7 920 22 0 5 11]
[ 8 9 23 14 19 29 885 3 2 8]
[ 1 1 2 2 1 3 7 976 0 7]
[ 1 2 8 5 36 5 3 3 930 7]
[ 5 7 2 7 8 6 6 9 9 941]]
Time usage: 0:00:03

CNN模型评估结果:

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
args model:text_cnn, embedding:sougou
Test Loss: 0.28, Test Acc: 91.35%
Precision, Recall and F1-Score...
precision recall f1-score support
finance 0.9205 0.8920 0.9060 1000
realty 0.9202 0.9450 0.9324 1000
stocks 0.8801 0.8590 0.8694 1000
education 0.9539 0.9520 0.9530 1000
science 0.8626 0.8730 0.8678 1000
society 0.8873 0.9210 0.9038 1000
politics 0.9039 0.9030 0.9035 1000
sports 0.9468 0.9610 0.9538 1000
game 0.9267 0.9100 0.9183 1000
entertainment 0.9339 0.9190 0.9264 1000

accuracy 0.9135 10000
macro avg 0.9136 0.9135 0.9134 10000
weighted avg 0.9136 0.9135 0.9134 10000
Confusion Matrix...
[[ 892 17 48 2 11 12 9 5 2 2]
[ 9 945 11 1 3 16 4 3 2 6]
[ 47 22 859 1 29 4 30 3 5 0]
[ 1 3 1 952 5 16 6 5 3 8]
[ 4 9 24 5 873 16 18 2 35 14]
[ 3 19 1 17 10 921 21 1 2 5]
[ 9 5 21 7 18 30 903 3 0 4]
[ 2 1 2 2 4 6 4 961 5 13]
[ 1 1 6 4 49 4 1 11 910 13]
[ 1 5 3 7 10 13 3 21 18 919]]
Time usage: 0:00:03

6.总结

本文涉及到的知识点:

  1. torch
    • torch.nn:用于构建神经网络,如 Embedding、Conv2d、LSTM、Linear、Dropout 等
    • torch.nn.functional:用于计算relu、cross_entropy、max_pool1d 等操作
    • torch.optim:Adam 优化器和 torch.optim.lr_scheduler.ReduceLROnPlateau 进行动态学习率调整
    • torch.Tensor:数据处理,包括 to(device) 用于 GPU 计算
  2. 文本处理
    • 词嵌入 nn.Embedding 处理文本数据,支持 sogou、tencent 预训练词向量
    • LSTM 和 CNN 进行文本分类 (TextRNN vs TextCNN)
    • 词表 vocab.pkl的加载和构建 (pickle 序列化)
    • 文本填充 (pad_size 处理变长文本)
  3. 数据处理
    • DatasetIterator 设计了数据迭代器,支持 batch 训练,并进行 to(device) 加速计算
    • build_iterator() 生成数据加载器,并支持 train/dev/test 迭代
  4. 训练与验证
    • 训练 (train 函数),采用 Adam 优化器, ReduceLROnPlateau 进行学习率动态调整cross_entropy 计算损失,accuracy_score 计算准确率,早停机制 (require_improvement 防止长期无提升)
    • 验证 (evaluate 函数):计算 loss 和 accuracy,在 test 评估时,输出 classification_report 和 confusion_matrix
  5. 日志可视化
    • SummaryWriter 记录 loss 和 accuracy,支持 TensorBoard 可视化
  6. 命令行参数
    • argparse 解析用户输入的 –model 和 –embedding选项

7.备注

环境:

  • mac: 15.2
  • python: 3.12.4
  • pytorch: 2.5.1
  • numpy: 1.26.4
  • tensorBoard : 2.19.0

数据集:
https://github.com/keychankc/dl_code_for_blog/tree/main/005_rnn_classification_text/THUCNews/data

完整代码:
https://github.com/keychankc/dl_code_for_blog/tree/main/005_rnn_classification_text