本文将向大家展示如何使用 PyTorch 中的 RNN(循环神经网络)来构建一个简单的中文歌词生成任务。本项目以周杰伦的歌词为语料,经过分词、构建词表、数据集处理后,训练一个 RNN 模型,再通过预测函数生成新歌词。整个流程清晰明了,非常适合想要深入了解基于循环神经网络文本生成任务的同学学习和实践。
注意:当然同为循环神经网络的RNN,LSTM, GRU 同理,只需要简单改改代码就好了,文末会说代码实现上的区别
一、项目概述
1.1 项目背景
文本生成是 NLP 领域的重要任务,广泛应用于对话系统、机器翻译、自动写作等场景。本文采用最基础的 RNN 网络结构,通过预测下一个词的方式生成歌词文本。虽然在更复杂的应用中我们可能会使用 LSTM、GRU 或 Transformer,但掌握 RNN 的基本原理有助于理解序列建模的核心思想。
1.2 项目流程
整个项目的主要流程包括:
1. 获取并预处理数据:
• 读取 jaychou_lyrics.txt 文件(包含周杰伦歌词)。
• 使用 jieba 分词、去重,构建词表,并生成词与索引之间的映射关系。
• 将每个词转换为索引,形成一个索引序列。
2. 构建数据集对象(LyricsDataset):
• 实现 len 和 getitem 方法,根据指定窗口大小将长序列切分成 (x, y) 样本对。
3. 搭建 RNN 网络模型(TextGenerator):
• 模型包含词嵌入层、RNN 层和全连接层,通过前向传播输出每个时间步的预测分布。
4. 编写训练函数(train_model):
• 构建 DataLoader、计算损失、反向传播并更新参数,训练若干个 epoch 并保存模型。
5. 编写预测函数(predict):
• 从磁盘加载训练好的模型权重,给定起始词,通过循环预测生成指定长度的歌词。
1.3 环境依赖
• Python 3.x
• PyTorch ≥ 1.7
• jieba(中文分词库)
• 其他:time、re 等标准库
请确保在运行前安装所需库:
python
pip install torch jieba matplotlib
二、代码实现
以下所有代码均可放入同一脚本文件(如 jaychou_lyrics_rnn.py),也可根据需要拆分为不同模块。
2.1 构建词表
该部分代码负责读取 data/jaychou/jaychou_lyrics.txt 文件,对每行歌词进行分词、去重,生成词表和词索引映射,同时将整篇文本转为索引序列,供后续数据集构造时使用。
python
# 2.1 构建词表
# 先对歌词数据进行分词、去重,构建 unique_words(去重后的词列表)以及 word_to_index(词到索引的映射)。在遍历每一行歌词后,我们还会把整篇文档的所有词索引记录到 corpus_idx 里,后续做数据集时会用到。
import torch
import re
import jieba
from torch.utils.data import DataLoader
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
import time
def build_vocab():
"""
从 data/jaychou/jaychou_lyrics.txt 文件中读取歌词数据,对每行进行分词并去重,
最终返回去重后的词表、词到索引的映射、词总数以及整篇文档的索引列表。
"""
# 数据集位置
file_name = 'data/jaychou/jaychou_lyrics.txt'
# 用于存储去重后的词表
# unique_words = []
# 在读取数据之前先加入一个特殊符号(例如空格或其他自定义符号),用于自己作为断句用
unique_words = [' ']
# 用于存储所有分词结果(含重复)
all_words = []
# 1. 遍历数据集中的每一行文本
with open(file_name, 'r', encoding='utf-8') as f:
for line in f:
# 使用 jieba 分词, 分割结果是一个列表
words = jieba.lcut(line.strip())
# 将分词结果存入 all_words
all_words.append(words)
# 去重操作:如果该词还不在 unique_words,就添加进去
for w in words:
if w not in unique_words:
unique_words.append(w)
# 2. 词的数量
word_count = len(unique_words)
# 3. 构建词到索引的映射
word_to_index = {word: idx for idx, word in enumerate(unique_words)}
# 4. 将整篇文档的所有词转换为索引,并存到 corpus_idx
corpus_idx = []
for words in all_words:
temp = []
for w in words:
temp.append(word_to_index[w])
# 用一个空格索引(或特殊符号)来标识分割
if ' ' in word_to_index:
temp.append(word_to_index[' '])
corpus_idx.extend(temp)
return unique_words, word_to_index, word_count, corpus_idx
if __name__ == "__main__":
# 测试构建词表
unique_words, word_to_index, word_count, corpus_idx = build_vocab()
print("词的数量:", word_count)
print("去重后的词表示例:", unique_words[:20]) # 只打印前20个做示例
print("每个词的索引示例:", {k: word_to_index[k] for k in list(word_to_index.keys())[:20]})
print("文档索引长度:", len(corpus_idx))
说明:
• 使用 jieba 进行分词,生成的 unique_words 和 word_to_index 为后续构建数据集和模型提供支持。
• 若词表中没有空格 ' ',可以手动添加或用其他特殊符号代替。
输出:

2.2 构建数据集 LyricsDataset
该部分代码通过继承 torch.utils.data.Dataset,将整篇文本索引序列按照固定窗口大小切分为 (x, y) 样本对,方便使用 DataLoader 进行批处理训练。
python
# 2.2 构建数据集 LyricsDataset
#
# 为了方便训练,我们需要把 corpus_idx(整篇文档的索引列表)切分成若干输入序列和目标序列。例如,给定窗口大小 num_chars,我们会把 [i : i+num_chars] 作为输入序列,把 [i+1 : i+1+num_chars] 作为目标序列。
class LyricsDataset(torch.utils.data.Dataset):
"""
自定义 Dataset,用于把长序列分割成 (x, y) 的小片段:
- x: [start, start+num_chars]
- y: [start+1, start+1+num_chars]
"""
def __init__(self, corpus_idx, num_chars):
"""
:param corpus_idx: 包含整篇文档所有词索引的列表
:param num_chars: 每次取多少个词作为一个序列
"""
self.corpus_idx = corpus_idx
self.num_chars = num_chars
self.word_count = len(self.corpus_idx)
# 计算可以切分多少个样本
self.number = self.word_count // self.num_chars
def __len__(self):
"""
返回可切分的样本数
"""
return self.number
def __getitem__(self, idx):
"""
根据给定的 idx,截取一个样本(x, y)
x = [start, start+num_chars]
y = [start+1, start+1+num_chars]
"""
# 为了避免越界,做一个 min-max 限制
start = min(max(idx, 0), self.word_count - self.num_chars - 2)
x = self.corpus_idx[start: start + self.num_chars]
y = self.corpus_idx[start + 1: start + 1 + self.num_chars]
# 转成 tensor 并返回
return torch.tensor(x, dtype=torch.long), torch.tensor(y, dtype=torch.long)
if __name__ == "__main__":
# 测试 Dataset
unique_words, word_to_index, word_count, corpus_idx = build_vocab()
dataset = LyricsDataset(corpus_idx, num_chars=5)
x, y = dataset.__getitem__(0)
print("示例输入 x:", x)
print("示例目标 y:", y)
输出:

2.3 构建网络模型 TextGenerator
模型包含三部分:
-
词嵌入层:将词索引转换成 128 维向量。
-
循环层(RNN):处理序列数据,输出每个时间步的隐藏状态。
-
全连接层:将隐藏状态映射到词表空间,预测下一个词的概率。
python
# 2.3 构建网络模型 TextGenerator
#
# 2.3.1 模型结构
# 1. 词嵌入层 nn.Embedding(word_count, 128)
# • 将词索引映射到一个 128 维的向量空间中。
# 2. 循环层 nn.RNN(128, 128, 1)
# • 这里使用最基础的 RNN,输入维度 128,隐藏层维度 128,层数 1。
# • 你可以尝试增加 num_layers 或使用 nn.LSTM, nn.GRU 来提高效果。
# 3. 全连接层 nn.Linear(128, word_count)
# • 将 RNN 的输出映射回词表空间,用于预测每个词出现的概率。
#
# 2.3.2 前向传播
# • 输入形状 (batch, seq_len) 会先经过 Embedding,得到 (batch, seq_len, embed_dim)。
# • RNN 的默认输入是 (seq_len, batch, input_size),所以需要 transpose(0, 1)。
# • RNN 返回 (seq_len, batch, hidden_size),为了送入全连接,需要 reshape 成 (seq_len * batch, hidden_size)。
# • 全连接输出后维度为 (seq_len * batch, word_count)。
class TextGenerator(nn.Module):
"""
使用 RNN 来做文本(歌词)生成的模型。
"""
def __init__(self, word_count):
super(TextGenerator, self).__init__()
# 词嵌入层:将词索引转换成 128 维向量
self.embedding = nn.Embedding(word_count, 128)
# RNN 层:输入维度 128,隐藏维度 128,1 层
self.rnn = nn.RNN(input_size=128, hidden_size=128, num_layers=1)
# 全连接层:将 RNN 的输出映射到词表大小
self.fc = nn.Linear(128, word_count)
def forward(self, inputs, hidden):
"""
:param inputs: 形状 (batch, seq_len)
:param hidden: 上一个时间步的隐藏状态,形状 (num_layers, batch, hidden_size)
:return:
output: 预测结果,形状 (seq_len*batch, word_count)
hidden: 更新后的隐藏状态
"""
# 1. 词嵌入
embed = self.embedding(inputs) # (batch, seq_len, embed_dim)
# 2. 交换维度以匹配 RNN 要求
embed = embed.transpose(0, 1) # (seq_len, batch, embed_dim)
# 3. RNN 前向计算
output, hidden = self.rnn(embed, hidden)# output: (seq_len, batch, hidden_size)
# 4. 改变形状后送入全连接层
output = output.reshape(-1, output.shape[-1]) # (seq_len*batch, hidden_size)
output = self.fc(output) # (seq_len*batch, word_count)
return output, hidden
def init_hidden(self, batch_size=1):
"""
初始化隐藏状态,全 0
这里只用 1 层的 RNN,所以第一维是 1
"""
return torch.zeros(1, batch_size, 128)
说明:
• 前向传播中先进行词嵌入,再调整维度以适应 RNN,最后 reshape 输出为二维张量送入全连接层。
2.4 训练函数
训练流程主要包括:
-
构建词表、数据集并划分训练集与验证集;
-
实例化模型、定义损失函数与优化器;
-
迭代训练:前向传播、计算交叉熵损失、反向传播、参数更新;
-
每个 epoch 输出日志,并保存模型。
python
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, random_split
import time
import matplotlib.pyplot as plt
def train_model():
"""
训练 RNN 模型用于歌词生成,集成了验证集评估、最佳模型保存以及损失趋势绘图。
主要流程:
1. 构建词表及词索引映射
2. 构建数据集,并划分为训练集和验证集
3. 实例化模型并移动到合适的设备(CPU 或 GPU)
4. 定义损失函数和优化器
5. 进行多个 epoch 的训练,同时在验证集上评估模型表现
6. 每个 epoch 后保存验证集损失最低的模型,并记录损失以便绘图
"""
# 1. 构建词表
# 调用 build_vocab() 函数,返回:
# unique_words: 去重后的词列表
# word_to_index: 词到索引的映射
# word_count: 词表中词的总数
# corpus_idx: 整个文档中每个词对应的索引序列
unique_words, word_to_index, word_count, corpus_idx = build_vocab()
# 2. 构建数据集
# 使用 LyricsDataset 将长文本(词索引序列 corpus_idx)按照窗口大小(num_chars=32)切分成多个样本
dataset = LyricsDataset(corpus_idx, num_chars=32)
# 将数据集划分为训练集和验证集(例如 90% 用于训练,10% 用于验证)
train_size = int(0.9 * len(dataset))
val_size = len(dataset) - train_size
train_dataset, val_dataset = random_split(dataset, [train_size, val_size])
# 构造 DataLoader
# 训练集使用 shuffle=True 打乱顺序,验证集无需打乱
train_dataloader = DataLoader(train_dataset, shuffle=True, batch_size=2)
val_dataloader = DataLoader(val_dataset, shuffle=False, batch_size=2)
# 3. 实例化模型
# 根据词表大小(word_count)创建 TextGenerator 模型,该模型内部包含词嵌入层、RNN 层和全连接层
model = TextGenerator(word_count)
# 设置设备:如果有 GPU,则使用 GPU,否则使用 CPU/mac 用 mps
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model.to(device)
# 4. 定义损失函数和优化器
# 使用交叉熵损失函数(适用于多分类任务),模型需要预测每个时间步的下一个词
criterion = nn.CrossEntropyLoss()
# 使用 Adam 优化器,学习率设为 1e-3
optimizer = optim.Adam(model.parameters(), lr=1e-3)
# 5. 训练超参数设置
epochs = 30 # 总训练轮数
best_val_loss = float('inf') # 用于记录验证集上的最低损失,以便保存最佳模型
# 用于记录每个 epoch 的训练和验证损失,方便后续绘图分析
train_losses = []
val_losses = []
# 6. 开始训练
for epoch in range(epochs):
model.train() # 设定模型为训练模式
start_time = time.time() # 记录本轮训练开始时间
total_loss = 0.0 # 当前 epoch 内累计的训练损失
iter_num = 0 # 当前 epoch 内迭代次数
# 遍历训练数据的每个批次
for x, y in train_dataloader:
# 将当前批次数据移动到设备上(CPU 或 GPU)
x, y = x.to(device), y.to(device)
batch_size = x.shape[0] # 当前批次的样本数
# 初始化 RNN 的隐藏状态,形状为 (num_layers, batch, hidden_size)
hidden = model.init_hidden(batch_size=batch_size).to(device)
# 前向传播:输入 x 和隐藏状态 hidden 得到预测 output 和更新后的 hidden
output, hidden = model(x, hidden)
# 调整目标 y 的形状:
# 原始 y 形状为 (batch, seq_len),先转换为 (seq_len, batch),然后展平为一维向量 (seq_len*batch)
# 这样与模型输出的形状 (seq_len*batch, word_count) 对应,每一行对应一个时间步的预测
y = y.transpose(0, 1).contiguous().view(-1)
# 计算当前批次的损失
loss = criterion(output, y)
# 反向传播前先清零梯度
optimizer.zero_grad()
# 反向传播,计算梯度
loss.backward()
# 更新模型参数
optimizer.step()
# 累计损失和迭代次数
total_loss += loss.item()
iter_num += 1
# 计算本 epoch 平均训练损失
train_loss = total_loss / iter_num
train_losses.append(train_loss)
# 在验证集上评估模型
model.eval() # 切换到评估模式,关闭 dropout 等训练专用操作
total_val_loss = 0.0
val_iter = 0
with torch.no_grad(): # 验证时不需要计算梯度
for x, y in val_dataloader:
x, y = x.to(device), y.to(device)
batch_size = x.shape[0]
hidden = model.init_hidden(batch_size=batch_size).to(device)
output, hidden = model(x, hidden)
y = y.transpose(0, 1).contiguous().view(-1)
loss = criterion(output, y)
total_val_loss += loss.item()
val_iter += 1
# 计算验证集平均损失
val_loss = total_val_loss / val_iter
val_losses.append(val_loss)
elapsed = time.time() - start_time # 本 epoch 耗时
print(f"Epoch [{epoch+1}/{epochs}], Train Loss: {train_loss:.4f}, Val Loss: {val_loss:.4f}, Time: {elapsed:.2f}s")
# 如果当前验证损失低于历史最低值,则保存模型参数为最佳模型
if val_loss < best_val_loss:
best_val_loss = val_loss
torch.save(model.state_dict(), "model/jaychou/best_lyrics_model.pth")
print("保存最佳模型!")
# 训练完成后绘制训练和验证损失曲线
# mac 电脑设置。
# 设置全局字体为 PingFang HK
plt.rcParams['font.family'] = 'PingFang HK'
plt.rcParams['axes.unicode_minus'] = False # 解决负号显示问题
plt.figure(figsize=(8, 6))
plt.plot(train_losses, label='训练集的 Loss')
plt.plot(val_losses, label='验证集的 Loss')
plt.xlabel("Epoch")
plt.ylabel("Loss")
plt.legend()
plt.title("训练集和验证集的 Loss")
plt.show()
if __name__ == "__main__":
train_model()
说明:
• 每个 epoch 后输出训练和验证 Loss,并保存验证 Loss 最低的模型。
• 最后绘制损失曲线便于观察训练趋势。
输出:
2.5 预测函数
预测流程中,我们加载训练好的模型权重,给定起始词,通过循环生成后续词,形成完整的歌词。下面是一个简单的预测示例:
python
def predict(start_word,
sentence_length=50,
model_path='model/jaychou/best_lyrics_model.pth',
use_sampling=False,
temperature=1.0,
top_k=0,
top_p=0.0):
"""
使用训练好的 RNN 模型生成歌词文本,并支持多种采样策略。
参数:
start_word (str): 用于生成歌词的起始词。
sentence_length (int): 生成的词数量(不包括起始词)。
model_path (str): 模型权重文件路径,这里默认使用最佳模型。
use_sampling (bool): 是否使用采样策略生成下一个词。
False:使用贪心策略(每步取概率最高的词)。
True:使用采样策略,引入随机性。
temperature (float): 温度系数,控制采样分布的平滑程度。
温度越高,输出分布越平坦(随机性越大);温度越低,分布更尖锐,更接近贪心策略。
top_k (int): 如果大于 0,则只从预测分布中取概率最高的 top_k 个词进行采样。
top_p (float): 如果大于 0,则使用 nucleus(核)采样,即只保留累计概率达到 top_p 的词进行采样。
采样策略说明:
- 贪心策略:每步都选择概率最高的词,生成文本可能较为重复、单调。
- 采样策略:通过引入温度、top_k 或 top_p 策略,使生成文本更具多样性和创造性。
* 温度采样:通过调整 logits 的温度改变概率分布。
* top_k 采样:限制候选词为概率最高的 k 个。
* top_p(核采样):限制候选词为累计概率达到 p 的最小集合。
工作流程:
1. 加载词表,构建词与索引的映射和反向映射。
2. 实例化模型,加载预训练权重,设置评估模式。
3. 初始化隐藏状态(batch_size=1)。
4. 将起始词转换为对应索引,开始循环生成后续词。
5. 对每一步生成,根据采样策略选择下一个词的索引,并更新隐藏状态。
6. 最终将生成的索引转换为文本输出。
"""
# 1. 加载词表和索引映射
unique_words, word_to_index, word_count, _ = build_vocab()
# 构建 index_to_word:从索引到词的映射,用于最后将生成的索引转换回实际的词
index_to_word = {idx: word for word, idx in word_to_index.items()}
# 2. 实例化模型并加载权重
model = TextGenerator(word_count)
model.load_state_dict(torch.load(model_path))
model.eval() # 设置为评估模式,不启用 dropout 等训练特性
# 3. 初始化隐藏状态(batch_size=1,因为只生成一条序列)
hidden = model.init_hidden(batch_size=1)
# 4. 将起始词转换为对应的索引
# 这段代码保证了在用户输入不合法时,模型仍然可以正常运行,使用一个默认的词(优先选择空格,如果没有则使用词表中第一个词)来作为起始词。
if start_word not in word_to_index:
print(f"警告:词表中不包含 '{start_word}',将使用空格代替。")
start_word = ' ' if ' ' in word_to_index else list(word_to_index.keys())[0]
word_idx = word_to_index[start_word]
# 5. 循环生成后续词
generated_indices = [word_idx]
for _ in range(sentence_length):
# 构造输入张量,形状为 (batch=1, seq_len=1)
input_tensor = torch.tensor([[word_idx]], dtype=torch.long)
# 前向传播:将输入和当前隐藏状态送入模型,得到输出和更新后的隐藏状态
output, hidden = model(input_tensor, hidden)
# output 的形状为 (1, word_count),表示当前时间步对每个词的预测得分
if not use_sampling:
# 贪心策略:直接取输出中得分最高的词
word_idx = torch.argmax(output, dim=1).item()
else:
# 采样策略:对输出进行温度调节,并选择合适的采样方式
# 将 output squeeze 成一维 logits (word_count,)
logits = output.squeeze(0)
# 根据温度调节 logits
logits = logits / temperature
if top_p > 0:
# 使用 nucleus(top-p)采样:
# 1. 对 logits 进行降序排序,得到排序后的 logits 和对应索引
sorted_logits, sorted_indices = torch.sort(logits, descending=True)
# 2. 计算 softmax 后的概率分布
sorted_probs = torch.softmax(sorted_logits, dim=-1)
# 3. 计算累计概率
cumulative_probs = torch.cumsum(sorted_probs, dim=-1)
# 4. 找出累计概率大于 top_p 的位置,注意保留至少一个候选
sorted_indices_to_remove = cumulative_probs > top_p
# 将 mask 向右移动一位,确保至少保留第一个词
sorted_indices_to_remove[1:] = sorted_indices_to_remove[:-1].clone()
sorted_indices_to_remove[0] = 0
# 5. 对应位置的 logits 置为 -infinity,从而在 softmax 时其概率变为 0
sorted_logits[sorted_indices_to_remove] = float('-inf')
# 6. 重新计算概率分布
probs = torch.softmax(sorted_logits, dim=-1)
# 7. 从 top-p 后的候选中采样一个词
sampled_idx = torch.multinomial(probs, 1)
# 对应原始的词索引
word_idx = sorted_indices[sampled_idx].item()
elif top_k > 0:
# 使用 top-k 采样:
# 1. 取出 logits 中最大的 top_k 个值及其索引
topk_values, topk_indices = torch.topk(logits, top_k)
# 2. 计算这 top_k 个值经过 softmax 后的概率分布
probs = torch.softmax(topk_values, dim=-1)
# 3. 从 top_k 候选中采样一个词
sampled_idx = topk_indices[torch.multinomial(probs, 1)].item()
word_idx = sampled_idx
else:
# 如果不使用 top_k 或 top_p,则对全分布进行采样
probs = torch.softmax(logits, dim=-1)
sampled_idx = torch.multinomial(probs, 1).item()
word_idx = sampled_idx
# 将选择的词索引加入生成序列
generated_indices.append(word_idx)
# 6. 将生成的索引序列转换回对应的词
generated_words = [index_to_word[idx] for idx in generated_indices]
# 7. 拼接生成的词为完整字符串,并输出结果
generated_text = "".join(generated_words)
print(generated_text)
if __name__ == "__main__":
# 示例:以 '爱' 为起始词生成 50 个词的歌词,
# 采用采样策略,温度设为 1.0,top_k 设为 0(不使用 top_k),top_p 设为 0.9(使用 nucleus 采样)
# 关于采样策略的理解
# 1. 为什么不用单纯的贪心策略?
# • 贪心策略每一步总选择概率最高的词,这样可能导致生成文本单调、重复、缺乏新意。
# • 例如,贪心策略生成的句子往往在局部最优,但整体上缺乏变化,容易陷入死循环或无聊的重复模式。
# 2. 引入采样策略的意义
# 当温度系数(temperature)设置为 1.0 时,相当于没有进行温度调整。
# 因为 logits 除以 1.0 后不发生变化,生成的概率分布与原始分布一致。
# • 温度采样: 通过调整温度系数,可以平滑或尖锐化输出概率分布。较高温度使得概率分布更平坦,增加随机性;较低温度则更接近贪心选择。
# • top‑k 采样: 限制候选词仅为概率最高的 k 个,从而排除概率很低(可能是噪音或不合理)的词,兼顾随机性和合理性。
# • top‑p(核)采样: 根据累计概率筛选出候选集合,保证候选词总概率至少达到 p,再从中采样,这种方法能动态调整候选集合大小,使生成的文本既有创造性又不会太离谱。
#
# 通过引入这些策略,我们可以在生成文本时平衡确定性和随机性,生成更加多样化、富有创意的文本内容。你可以根据不同任务需求和实验效果,调整温度、top‑k 和 top‑p 的参数,获得最佳的生成效果。
# 在采样模式下,温度系数始终会被应用,用于调节 logits 的平滑程度;而 top_k 和 top_p 则是互斥的选项------也就是说,你可以选择使用 top_k 或者 top_p 中的一种策略,而不必同时使用两者。如果你同时设定了 top_p 和 top_k,代码中会优先检查 top_p(如果 top_p > 0 就走 top_p 逻辑),否则再判断 top_k。
#
# 所以,简单来说:
# • 如果 use_sampling 为 True,则温度参数一定会用到,用来调整分布;
# • 然后你可以选择设置 top_p(核采样)或者 top_k(限制候选词个数)中的一种,或者都不设置(直接从整个分布中采样)。
#
# 这样可以让生成的文本更具多样性和创造性,而不是总选概率最高的词(贪心策略)。
predict('分手', sentence_length=100,
model_path='model/jaychou/best_lyrics_model.pth',
use_sampling=True, temperature=1.0, top_k=0, top_p=0.4)
# use_sampling=False, temperature=1.0, top_k=0, top_p=0.4)
说明:
• 预测时默认使用贪心策略,每步取输出中概率最高的词。
• 可根据需要扩展为采样策略(如温度、top‑k、top‑p 采样)以提高生成多样性。
输出:

三、项目总结与改进方向
3.1 总结
- 数据预处理:
通过 jieba 分词、去重构建词表,并将文本转换为索引序列,为后续数据集构造提供基础。
- 数据集构建:
自定义 Dataset 将长文本切分为 (x, y) 样本对,使得 DataLoader 能够进行批处理训练。
- 模型搭建:
采用基础 RNN 模型(可扩展为 LSTM/GRU)进行序列建模。模型由词嵌入层、RNN 层和全连接层构成。
- 训练流程:
采用 CrossEntropyLoss 和 Adam 优化器,通过前向传播、计算损失、反向传播与参数更新完成模型训练,同时在验证集上评估并保存最佳模型。
- 预测流程:
加载训练好的模型权重,给定起始词,通过循环预测生成后续词,拼接为完整文本输出。
3.2 可能的改进方向
• 使用 LSTM / GRU:
基础 RNN 在长序列记忆力有限,可尝试使用 LSTM 或 GRU 改进模型性能。
• 多层 RNN:
增加网络层数(num_layers)以提升模型深度。
• 采样策略:
在预测过程中引入温度、top‑k 或 top‑p 采样策略,以增加生成文本的多样性和创意性。
• 调整 Embedding 和 Hidden Size:
根据数据量和词表规模,调整向量维度,进一步提升模型表现。
四**、RNN,LSTM,GRU 初始化,训练与预测过程中的区别**
4.1. 初始化网络模型过程示例**(以 RNN,LSTM,GRU 为例)**
python
# 下面给出三种常见循环网络模型(RNN、LSTM、GRU)的实现代码,以及训练和预测过程中隐藏状态初始化和前向传播的不同点。每段代码均附有详细注释,最后总结了它们的区别。
# 1. 使用 RNN 的模型实现
class TextGeneratorRNN(nn.Module):
"""
使用基础 RNN 来进行文本(歌词)生成的模型。
主要结构:
1. 词嵌入层:将词索引转换成 128 维向量表示。
2. RNN 层:对输入序列进行循环处理,输出隐藏状态。
3. 全连接层:将每个时间步的隐藏状态映射到词表大小,
得到预测下一个词的分数分布。
"""
def __init__(self, word_count):
super(TextGeneratorRNN, self).__init__()
# 词嵌入层,将词索引转换为 128 维向量
self.embedding = nn.Embedding(word_count, 128)
# RNN 层:输入 128 维向量,隐藏状态 128 维,1 层
self.rnn = nn.RNN(input_size=128, hidden_size=128, num_layers=1)
# 全连接层:将 128 维隐藏状态映射到词表大小
self.fc = nn.Linear(128, word_count)
def forward(self, inputs, hidden):
"""
前向传播过程:
inputs: (batch, seq_len) ------ 每个元素为词索引
hidden: (num_layers, batch, hidden_size) ------ 初始隐藏状态
过程:
1. 将词索引转换为 128 维向量,输出形状 (batch, seq_len, 128)
2. 转置为 (seq_len, batch, 128),符合 RNN 要求
3. RNN 处理后输出 (seq_len, batch, 128) 和更新后的 hidden
4. 将输出 reshape 成二维 (seq_len*batch, 128)
5. 全连接层映射得到 (seq_len*batch, word_count)
"""
embed = self.embedding(inputs) # (batch, seq_len, 128)
embed = embed.transpose(0, 1) # (seq_len, batch, 128)
output, hidden = self.rnn(embed, hidden) # output: (seq_len, batch, 128)
output = output.reshape(-1, output.shape[-1]) # (seq_len*batch, 128)
output = self.fc(output) # (seq_len*batch, word_count)
return output, hidden
def init_hidden(self, batch_size=1):
# 初始化隐藏状态,全 0,形状为 (1, batch_size, 128)
return torch.zeros(1, batch_size, 128)
# 2. 使用 LSTM 的模型实现
class TextGeneratorLSTM(nn.Module):
"""
使用 LSTM 来进行文本生成的模型。
LSTM 能更好地捕捉长距离依赖,它引入了细胞状态(cell state)。
主要结构:
1. 词嵌入层:将词索引转换成 128 维向量表示。
2. LSTM 层:对序列数据进行处理,返回 (output, (hidden, cell))。
3. 全连接层:将每个时间步的隐藏状态映射到词表大小,得到预测分布。
"""
def __init__(self, word_count):
super(TextGeneratorLSTM, self).__init__()
self.embedding = nn.Embedding(word_count, 128)
# LSTM 层:输入 128,隐藏状态 128,1 层
self.lstm = nn.LSTM(input_size=128, hidden_size=128, num_layers=1)
self.fc = nn.Linear(128, word_count)
def forward(self, inputs, hidden):
"""
前向传播过程:
inputs: (batch, seq_len)
hidden: 一个元组 (hidden, cell),每个形状 (1, batch, 128)
过程与 RNN 类似,只是 LSTM 返回两个状态:
1. 词嵌入得到 (batch, seq_len, 128),转置为 (seq_len, batch, 128)
2. LSTM 得到 output 和 (hidden, cell)
3. reshape 输出为 (seq_len*batch, 128)
4. 全连接层映射到 (seq_len*batch, word_count)
"""
embed = self.embedding(inputs) # (batch, seq_len, 128)
embed = embed.transpose(0, 1) # (seq_len, batch, 128)
output, hidden = self.lstm(embed, hidden) # hidden: tuple (h, c)
output = output.reshape(-1, output.shape[-1]) # (seq_len*batch, 128)
output = self.fc(output) # (seq_len*batch, word_count)
return output, hidden
def init_hidden(self, batch_size=1):
# 初始化 LSTM 的隐藏状态和细胞状态,全 0,形状均为 (1, batch_size, 128)
h = torch.zeros(1, batch_size, 128)
c = torch.zeros(1, batch_size, 128)
return (h, c)
# 3. 使用 GRU 的模型实现
class TextGeneratorGRU(nn.Module):
"""
使用 GRU 来进行文本生成的模型。
GRU 结构比基础 RNN 更复杂,但没有 LSTM 那样的细胞状态,只有隐藏状态,
能够在一定程度上捕捉长距离依赖,同时计算量较低。
主要结构:
1. 词嵌入层:将词索引转换成 128 维向量。
2. GRU 层:对输入序列进行循环处理,返回 (output, hidden)。
3. 全连接层:将 GRU 输出映射到词表大小,得到预测分布。
"""
def __init__(self, word_count):
super(TextGeneratorGRU, self).__init__()
self.embedding = nn.Embedding(word_count, 128)
# GRU 层:输入 128,隐藏状态 128,1 层
self.gru = nn.GRU(input_size=128, hidden_size=128, num_layers=1)
self.fc = nn.Linear(128, word_count)
def forward(self, inputs, hidden):
"""
前向传播过程:
inputs: (batch, seq_len)
hidden: (num_layers, batch, hidden_size)
过程:
1. 词嵌入后 (batch, seq_len, 128),转置为 (seq_len, batch, 128)
2. GRU 处理得到 output 和更新后的 hidden
3. reshape output 为 (seq_len*batch, 128)
4. 全连接层映射输出到 (seq_len*batch, word_count)
"""
embed = self.embedding(inputs) # (batch, seq_len, 128)
embed = embed.transpose(0, 1) # (seq_len, batch, 128)
output, hidden = self.gru(embed, hidden) # output: (seq_len, batch, 128)
output = output.reshape(-1, output.shape[-1]) # (seq_len*batch, 128)
output = self.fc(output) # (seq_len*batch, word_count)
return output, hidden
def init_hidden(self, batch_size=1):
# 初始化 GRU 的隐藏状态,全 0,形状为 (1, batch_size, 128)
return torch.zeros(1, batch_size, 128)
# 训练和预测过程的主要区别
# 1. 隐藏状态的初始化与传递:
# • RNN 与 GRU:
# 只需初始化一个隐藏状态张量,其形状为 (1, batch_size, 128)。
# • LSTM:
# 需要初始化一个元组 (hidden, cell),每个状态都是 (1, batch_size, 128)。在前向传播时,LSTM 返回的隐藏状态也是一个元组,需要在后续迭代中一起传递。
# 2. 前向传播返回值:
# • RNN 和 GRU:
# forward 返回 (output, hidden);其中 output 形状为 (seq_len*batch, word_count),hidden 为最新的隐藏状态。
# • LSTM:
# forward 返回 (output, (hidden, cell));因此在训练或预测时,要注意处理这个元组。
# 3. 训练与预测过程:
# • 训练过程:
# 整个训练流程(加载数据、构造 batch、计算损失、反向传播)在三种模型中基本相同,唯一不同的是初始化隐藏状态时调用的函数不同(对于 LSTM,需要传入和接收元组)。
# • 预测过程:
# 在生成文本时,循环迭代时也需要传递隐藏状态:
# • 对于 RNN 和 GRU,直接将隐藏状态传入下一步;
# • 对于 LSTM,需要传递 (hidden, cell) 元组,并在每一步更新后将其传入下一步。
# 除此之外,预测时的采样策略(如贪心、温度采样、top-k 或 top-p)与模型类型无关,使用方式一致。
#
# 总结
# • 模型选择:
# • RNN: 结构最简单,但容易遇到梯度消失问题,捕捉长距离依赖效果较差。
# • LSTM: 引入 cell state,能有效捕捉长距离依赖,但参数较多,计算量稍大。
# • GRU: 结构比 LSTM 简单(只有隐藏状态),通常能在效果与效率之间取得平衡。
# • 训练和预测过程:
# 基本流程一致,数据预处理、批次处理、前向传播、计算损失、反向传播与参数更新均相同。主要区别在于隐藏状态的初始化和传递方式。
#
# 通过以上三个代码示例和说明,你可以根据任务需求选择不同的循环网络,并相应调整训练和预测代码。
4.2 RNN,LSTM,GRU 训练与预测时隐藏状态初始化与传递的区别
python
# 3. 隐藏状态初始化和传递的区别(RNN、LSTM、GRU)
#
# 下面通过伪代码和详细注释说明训练和预测时各模型在隐藏状态初始化和传递上的不同点:
# ---------------------------
# 对于 RNN 模型:
# ---------------------------
# 训练过程:
# - 初始化隐藏状态:
hidden = torch.zeros(1, batch_size, hidden_size) # 仅一个张量
# - 前向传播:
output, hidden = rnn(input, hidden)
# - 隐藏状态 hidden 直接作为下一个时间步的输入,无需拆分
# 预测过程:
# - 同样初始化:
hidden = torch.zeros(1, 1, hidden_size)
# - 每一步生成后,将更新后的 hidden 传入下一次生成:
for t in range(sentence_length):
output, hidden = rnn(current_input, hidden)
# 选取下一个词后继续使用更新后的 hidden
# ---------------------------
# 对于 LSTM 模型:
# ---------------------------
# 训练过程:
# - 初始化隐藏状态和细胞状态:
h = torch.zeros(1, batch_size, hidden_size)
c = torch.zeros(1, batch_size, hidden_size)
hidden = (h, c) # 隐藏状态为元组 (hidden, cell)
# - 前向传播:
output, hidden = lstm(input, hidden)
# - 隐藏状态 hidden 是一个元组,需要在下一步同时传入 (hidden, cell)
# 预测过程:
# - 初始化同样为元组:
hidden = (torch.zeros(1, 1, hidden_size), torch.zeros(1, 1, hidden_size))
# - 迭代生成过程中,每一步:
for t in range(sentence_length):
output, hidden = lstm(current_input, hidden)
# 更新后的 hidden(包含新 h 和 c)传给下一步生成
# ---------------------------
# 对于 GRU 模型:
# ---------------------------
# GRU 的隐藏状态结构与 RNN 类似,只有一个张量:
# 训练过程:
hidden = torch.zeros(1, batch_size, hidden_size)
output, hidden = gru(input, hidden)
# 预测过程:
hidden = torch.zeros(1, 1, hidden_size)
for t in range(sentence_length):
output, hidden = gru(current_input, hidden)
# ---------------------------
# 总结:
# 1. 训练和预测过程大致相同:数据预处理、批次处理、前向传播、计算损失(训练中)、反向传播与参数更新(训练中)。
# 2. 主要区别在于:
# - 训练过程中需要计算损失并更新参数,而预测过程中不计算损失也不更新参数。
# - 隐藏状态的初始化和传递方式不同:
# * RNN 和 GRU:使用单个隐藏状态张量。
# * LSTM:使用 (hidden, cell) 元组。
# - 在训练时,通常对一个批次数据同时处理;而在预测时,通常是逐步生成(通常 batch_size=1)。
区别说明:
• 训练过程:
• 包括前向传播、计算损失、反向传播和参数更新。
• 隐藏状态会在每个批次开始时初始化,并在反向传播中起到重要作用。
• 预测过程:
• 不计算损失,也不更新参数,重点在于利用上一步的隐藏状态逐步生成下一个词。
• 通常 batch_size 为 1,生成过程按时间步依次传递隐藏状态,确保上下文连续性。
• 隐藏状态的区别:
• RNN 和 GRU: 只需一个隐藏状态张量。
• LSTM: 需要同时传递隐藏状态和细胞状态,即一个元组 (h, c)。
4.3 完整训练与预测过程示例(以 RNN 为例)
python
# 训练过程
def train_rnn_model(model, dataloader, optimizer, criterion, device):
"""
训练 RNN 模型一个 epoch 的示例代码。
参数:
model: 已实例化的 RNN 模型(例如 TextGeneratorRNN)。
dataloader: 训练数据的 DataLoader,形状为 (batch, seq_len)。
optimizer: 优化器,如 Adam。
criterion: 损失函数,如 CrossEntropyLoss。
device: 训练设备('cuda' 或 'cpu')。
流程:
1. 将模型设置为训练模式。
2. 遍历 DataLoader 中的每个批次:
- 将输入数据和目标标签移动到 device。
- 初始化隐藏状态(对于 RNN,只需一个张量)。
- 前向传播得到输出和更新后的隐藏状态。
- 调整目标标签形状((batch, seq_len) → (seq_len, batch) → (seq_len*batch))。
- 计算损失、反向传播并更新参数。
3. 返回平均训练损失。
"""
model.train() # 切换到训练模式
total_loss = 0.0
for x, y in dataloader:
x, y = x.to(device), y.to(device)
batch_size = x.size(0)
# 初始化隐藏状态,RNN 只需要一个隐藏状态张量
hidden = model.init_hidden(batch_size=batch_size).to(device)
optimizer.zero_grad() # 清除梯度
output, hidden = model(x, hidden) # 前向传播
# 调整目标标签形状,使之与输出匹配
y = y.transpose(0, 1).contiguous().view(-1)
loss = criterion(output, y) # 计算交叉熵损失
loss.backward() # 反向传播
optimizer.step() # 更新参数
total_loss += loss.item()
return total_loss / len(dataloader)
# 预测过程
def predict_rnn(model, start_word, word_to_index, index_to_word, sentence_length, device):
"""
使用训练好的 RNN 模型生成文本的示例代码。
参数:
model: 训练好的 RNN 模型。
start_word: 起始词,用于生成文本的开头。
word_to_index: 词到索引的映射字典。
index_to_word: 索引到词的反向映射字典。
sentence_length: 生成的词数量(不包含起始词)。
device: 使用的设备('cuda' 或 'cpu')。
流程:
1. 将模型设置为评估模式,关闭训练时专用的 dropout 等机制。
2. 初始化隐藏状态(batch_size=1)。
3. 将起始词转换为索引,若不存在则选择默认词。
4. 循环生成每个时间步的词:
- 构造输入张量 (1, 1)。
- 前向传播得到输出和更新后的隐藏状态。
- 使用贪心策略(或采样策略)选择下一个词的索引。
- 将选定索引加入生成序列。
5. 将生成的索引转换回词,拼接成完整文本。
"""
model.eval() # 切换到评估模式
with torch.no_grad():
hidden = model.init_hidden(batch_size=1).to(device)
if start_word not in word_to_index:
print(f"警告:词表中不包含 '{start_word}',将使用默认词代替。")
start_word = list(word_to_index.keys())[0]
word_idx = word_to_index[start_word]
generated_indices = [word_idx]
for _ in range(sentence_length):
input_tensor = torch.tensor([[word_idx]], dtype=torch.long).to(device)
output, hidden = model(input_tensor, hidden)
# 贪心策略:取概率最高的词
word_idx = torch.argmax(output, dim=1).item()
generated_indices.append(word_idx)
generated_text = "".join([index_to_word[idx] for idx in generated_indices])
return generated_text
LSTM,GRU ,按照刚刚第二部分说的区别,改一改就好了
五、结语
通过本项目,我们展示了如何使用 PyTorch 中最基本的 RNN 模型完成一个中文歌词生成任务。从数据预处理、数据集构建、模型搭建、训练到预测,每一步都有详细讲解。掌握这些基本流程后,你可以进一步尝试更复杂的网络结构(如 LSTM、GRU、Transformer)以及更加丰富的生成策略。
如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、关注,也欢迎留言交流你的心得和问题。祝你在 NLP 的世界里玩得愉快!
以上内容均为个人对循环神经网络及其在文本生成任务中应用的理解与总结,供大家学习参考。