首页 / PyTorch 入门教程 / 字符级 RNN:文本生成

PyTorch 入门教程

字符级 RNN:文本生成

本教程共 60 篇 · 第 42 篇 · 更新于 2026-08-17 · 约 4 分钟阅读

PyTorchRNN字符级模型文本生成语言模型NLP

本节目标:理解字符级 RNN 的思路——把单词当字符序列读,分清分类与生成两种任务的输入输出差异,并跑通一个迷你 RNN 和采样循环。

上一章把词变成向量。这一章换更细的粒度:字符(Character)。字符级 RNN 把「Hinton」拆成 H-i-n-t-o-n,一个字符一个字符地读。

好处很明显:字符表很小。大小写字母加几个标点也就 58 个,词表动辄几万。模型小、训练快,还能处理没见过的新词——任何词都能拆成已知字符。如果嫌 one-hot 维度高,可以把字符换成第 41 章的 nn.Embedding 字符嵌入;不过 58 维不算负担,官方教程直接用 one-hot,简单直观。

同一套 RNN,两个方向

官方经典教程用「名字」数据演示了两件事,正好是 RNN 的两种用法:

  1. 分类(Classification):读完整名字,判断它属于哪种语言,一共 18 种。
  2. 生成(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 过滤,字符表外的统统丢掉,保证每个字符都查得到索引。

Note

EOS(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.NLLLossLogSoftmax,或者直接 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.LSTMnn.GRU 层,能记住更长的依赖。还有几个常见改进:加深网络;把 one-hot 字符换成第 41 章的字符嵌入,参数量更省;采样时按概率分布随机抽而不是死选第一名,生成的名字更多样。再往上,是第 43 章的 seq2seq 和现在大模型通用的 Transformer。

本章一句话:字符级 RNN 把单词拆成字符逐个读,分类看最后一眼,生成则边说边想,把自己吐的字再喂回去。