字符级 RNN:文本生成
本教程共 60 篇 · 第 42 篇 · 更新于 2026-08-17 · 约 4 分钟阅读
本节目标:理解字符级 RNN 的思路——把单词当字符序列读,分清分类与生成两种任务的输入输出差异,并跑通一个迷你 RNN 和采样循环。
上一章把词变成向量。这一章换更细的粒度:字符(Character)。字符级 RNN 把「Hinton」拆成 H-i-n-t-o-n,一个字符一个字符地读。
好处很明显:字符表很小。大小写字母加几个标点也就 58 个,词表动辄几万。模型小、训练快,还能处理没见过的新词——任何词都能拆成已知字符。如果嫌 one-hot 维度高,可以把字符换成第 41 章的 nn.Embedding 字符嵌入;不过 58 维不算负担,官方教程直接用 one-hot,简单直观。
同一套 RNN,两个方向
官方经典教程用「名字」数据演示了两件事,正好是 RNN 的两种用法:
- 分类(Classification):读完整名字,判断它属于哪种语言,一共 18 种。
- 生成(Generation):给定一种语言,一个字符一个字符往外蹦,造出像该语言的名字。
分类是「读完全部,最后表态」;生成是「读一步,吐一个」。生成任务也叫语言模型(Language Model):预测下一个字符是什么。
换一套数据,这套思路就能干别的:小说系列 → 角色名,词性 → 单词,国家 → 城市。字符级 RNN 的适配能力很强。这一章把两者都过一遍,先分类再生成,顺序和官方教程一致,难度也是递进的。
字符怎么进网络
每个字符先转成 one-hot 向量:维度等于字符表大小,自己那维是 1。一个名字就是一张 (长度, 1, 字符数) 的张量,中间那个 1 是 batch 维度,方便以后扩展。
import string
import torch
all_letters = string.ascii_letters + " .,;'-"
n_letters = len(all_letters) + 1 # 58 个字符 + 1 个 EOS 结束标记
def letter_to_tensor(letter):
tensor = torch.zeros(1, n_letters)
tensor[0][all_letters.find(letter)] = 1
return tensor
def line_to_tensor(line):
tensor = torch.zeros(len(line), 1, n_letters)
for i, letter in enumerate(line):
tensor[i][0][all_letters.find(letter)] = 1
return tensor
print(line_to_tensor("Jones").shape) # torch.Size([5, 1, 59])
没见过的字符怎么办?预处理阶段用 unicode_to_ascii 过滤,字符表外的统统丢掉,保证每个字符都查得到索引。
NoteEOS(End of Sequence)是生成任务的「句号」:训练时每个名字末尾都要预测它,采样时碰到它就停。分类任务不需要 EOS,字符表就少一维。
手搓一个迷你 RNN
官方教程的 RNN 只有两层 Linear,没有黑魔法。把当前字符向量和上一步的隐状态(Hidden State)拼起来,喂给两个线性层:一个产出新隐状态,一个产出输出。
import torch.nn as nn
class CharRNN(nn.Module):
def __init__(self, input_size, hidden_size, output_size):
super().__init__()
self.hidden_size = hidden_size
self.i2h = nn.Linear(input_size + hidden_size, hidden_size)
self.i2o = nn.Linear(input_size + hidden_size, output_size)
self.softmax = nn.LogSoftmax(dim=1)
def forward(self, input, hidden):
combined = torch.cat((input, hidden), 1)
hidden = self.i2h(combined)
output = self.softmax(self.i2o(combined))
return output, hidden
def init_hidden(self):
return torch.zeros(1, self.hidden_size)
rnn = CharRNN(n_letters, 128, 18) # 18 种语言
output, hidden = rnn(letter_to_tensor("A"), rnn.init_hidden())
print(output.shape) # torch.Size([1, 18])
隐状态就是那张「草稿纸」(第 22 章讲过),每读一个字符更新一次。分类任务里,只有最后一个字符的 output 参与损失计算。
Tip损失函数用
nn.NLLLoss配LogSoftmax,或者直接nn.CrossEntropyLoss吃原始输出,效果等价。旧教程手写p.data.add_()更新参数,现在交给优化器就行。
分类训练:最后一步才算损失
分类任务读完全部字符,只有最后一个时间步的输出和类别标签算损失:
import torch.optim as optim
criterion = nn.NLLLoss()
optimizer = optim.SGD(rnn.parameters(), lr=0.005)
def train(category_tensor, line_tensor):
hidden = rnn.init_hidden()
optimizer.zero_grad()
for i in range(line_tensor.size(0)):
output, hidden = rnn(line_tensor[i], hidden)
loss = criterion(output, category_tensor)
loss.backward()
optimizer.step()
return output, loss.item()
每个字符走一遍网络,隐状态接力往后传,最后一步的输出代表整串的「读后感」。预测时用 topk(1) 取最大概率的类别即可。
评估分类器可以画混淆矩阵(Confusion Matrix):横轴是预测语言,纵轴是真实语言,对角线越亮越准。实测里中文名常被猜成韩语,英语名容易和苏格兰名混——拼写重叠多,模型也犯难。
生成任务:每步都预测
生成版输入多一个「类别」:把语言也编码成 one-hot 拼进去。网络加一条输出通路和一个 nn.Dropout(0.1),故意制造一点混乱,让生成结果更多样。
class CharRNNGen(nn.Module):
def __init__(self, n_categories, input_size, hidden_size, output_size):
super().__init__()
self.hidden_size = hidden_size
self.i2h = nn.Linear(n_categories + input_size + hidden_size, hidden_size)
self.i2o = nn.Linear(n_categories + input_size + hidden_size, output_size)
self.o2o = nn.Linear(hidden_size + output_size, output_size)
self.dropout = nn.Dropout(0.1)
self.softmax = nn.LogSoftmax(dim=1)
def forward(self, category, input, hidden):
combined = torch.cat((category, input, hidden), 1)
hidden = self.i2h(combined)
output = self.o2o(torch.cat((hidden, self.i2o(combined)), 1))
return self.dropout(self.softmax(output)), hidden
每个时间步的输入是 (类别, 当前字符, 隐状态),目标是往后移一位的下一个字符,最后一个字符的目标是 EOS。训练时每个时间步都算损失,累加起来一起反向传播,autograd 会自动沿时间步回溯。注意目标张量是整数索引而不是 one-hot,喂给 nn.NLLLoss 前补一个维度(unsqueeze)就行。
对形状再心里过一遍:输入是 (时间步, 1, 字符数),目标是长度相同的一维索引序列,一一对应。
训练数据从哪来
官方教程的「名字」数据是一个 zip 包:data/names 目录下 18 个文本文件,文件名就是语言名,每行一个名字,比如 Russian.txt 里都是 Rovakov 这类俄国名字,全部加起来约两万条。用 glob 遍历文件就能读进来。
原始文件带重音符号,要先转成纯 ASCII:
import glob
import unicodedata
def unicode_to_ascii(s):
return "".join(
c for c in unicodedata.normalize("NFD", s)
if unicodedata.category(c) != "Mn" and c in all_letters)
print(unicode_to_ascii("O'Néàl")) # O'Neal
files = glob.glob("data/names/*.txt") # 18 个语言文件
预处理三步:按行读、Unicode 转 ASCII、按语言归类成字典 {语言: [名字列表]}。
生成:训练与采样
生成版的训练循环和分类版只有两个差别:输入多一个类别张量,损失在每一步都算。一次前向里循环所有时间步,损失累加,最后统一反向传播:
criterion = nn.NLLLoss()
optimizer = optim.SGD(rnn_gen.parameters(), lr=0.0005)
def train_gen(category_tensor, input_tensor, target_tensor):
hidden = rnn_gen.init_hidden()
optimizer.zero_grad()
loss = 0
for i in range(input_tensor.size(0)):
output, hidden = rnn_gen(category_tensor, input_tensor[i], hidden)
loss += criterion(output, target_tensor[i].unsqueeze(-1))
loss.backward()
optimizer.step()
return loss.item() / input_tensor.size(0)
训练数据靠随机采样:每轮随机抽一个语言、一个名字,转成 (类别, 输入, 目标) 三件套。跑 10 万次迭代,几分钟就能看到损失从 3 降到 2 附近。
训完以后采样,模型能吐出 Rovakov、Gerten、Santara 这种像模像样的名字。偶尔也有 Cha、Ha 这种短名字——EOS 来得太早,是模型「急着结束」的小毛病。
采样时没有标准答案。模型吐一个字符,就把它当下一步的输入,循环往复,直到吐出 EOS 或超过最大长度:
def sample(rnn, category, start_letter="A", max_length=20):
with torch.no_grad():
hidden = rnn.init_hidden()
input = letter_to_tensor(start_letter)
name = start_letter
for _ in range(max_length):
output, hidden = rnn(category, input, hidden)
topi = output.topk(1)[1].item() # 概率最大的字符
if topi == n_letters - 1: # EOS,停止
break
name += all_letters[topi]
input = letter_to_tensor(name[-1])
return name
Tip
torch.no_grad()别忘了。采样只做前向,不更新参数,关掉梯度记录省内存。
升级方向
这个迷你 RNN 是教学用的。真实项目里,把 i2h/i2o 换成 nn.LSTM 或 nn.GRU 层,能记住更长的依赖。还有几个常见改进:加深网络;把 one-hot 字符换成第 41 章的字符嵌入,参数量更省;采样时按概率分布随机抽而不是死选第一名,生成的名字更多样。再往上,是第 43 章的 seq2seq 和现在大模型通用的 Transformer。
本章一句话:字符级 RNN 把单词拆成字符逐个读,分类看最后一眼,生成则边说边想,把自己吐的字再喂回去。