1. 从原始文本到模型可读数据文本预处理到底在做什么很多人第一次接触文本预处理会觉得这一步“没什么技术含量”——不就是把句子切开、把词映射成数字吗但实际做过几个项目之后你会发现文本预处理的质量直接决定了模型效果的上限。你后面用多复杂的网络结构、调多精细的超参数如果输入的数据本身就是一团乱麻那结果一定好不了。这一章我就把文本预处理这条链路拆开讲清楚每一步在干什么、为什么这么干、以及实际写代码时容易踩的坑。1.1 文本预处理的核心任务链路先把整体流程理一遍。一份原始文本要变成模型能吃的张量通常要经过这么几个阶段读取原始文本把语料从文件里加载进来得到一个长字符串或者字符串列表。分词Tokenization把长文本切分成一个个词元token。中文和英文的处理方式差别很大英文天然有空格分隔中文则需要专门的分词工具或者按字符切分。构建词表Vocabulary统计所有出现过的token给每个token分配一个唯一的整数索引。这一步会涉及低频词的处理策略。数值化Numericalization把每条文本序列里的token替换成对应的索引得到整数序列。截断与填充把长短不一的序列统一到固定长度方便批量处理。这五步看起来简单但每一步都有讲究。我见过不少人直接拿原始文本去训练结果模型loss不下降排查半天才发现是词表构建时把特殊符号全滤掉了导致大量token变成了未知标记。1.2 分词策略的选择按词、按字符还是子词分词是预处理里第一个需要做决策的地方。不同语言、不同任务适合的分词粒度完全不同。英文场景下最朴素的做法是按空格和标点切分。比如Hello, world!切成[Hello, ,, world, !]。但这样切出来的token会带空格、大小写不统一所以通常还要做规范化转小写、去掉多余空白、统一标点符号。更成熟的做法是用spaCy或者NLTK的分词器它们能处理缩写、连字符等边界情况。中文场景下按空格切分完全行不通。常见的选择有三种分词粒度做法优点缺点按字符每个汉字作为一个token词表小不会出现OOV序列长语义单元太细按词用jieba等工具切词语义单元合理词表大新词容易OOV按子词BPE、WordPiece等平衡词表和OOV实现复杂可解释性差对于学习阶段的小型项目我一般建议中文按字符切分英文按空格加标点切分。原因很简单字符级分词不需要额外依赖词表规模可控而且不会因为分词工具的错误切分引入噪声。等你对模型行为有了直觉之后再换更精细的分词方案做对比实验。注意无论选哪种分词方式都要保证训练集和测试集使用完全相同的分词逻辑和词表。我见过有人在训练时用jieba分词推理时忘了加载同样的自定义词典导致同一个词被切成了不同形式模型直接懵掉。1.3 词表构建中的低频词与特殊标记处理词表构建的核心逻辑是遍历所有文本统计每个token的出现频率然后按频率从高到低排序取前N个组成词表。但这里有几个关键决策点。低频词怎么处理如果某个词在整个语料里只出现了一两次把它保留在词表里意义不大——模型见得太少学不到有效表示反而增加了参数量。常见做法是设置一个最小频率阈值比如5低于这个阈值的词统一映射为unk未知词标记。特殊标记要预留哪些至少需要这几类unk未知词处理词表外的tokenpad填充标记把短序列补齐到统一长度bos序列起始标记某些任务需要eos序列结束标记某些任务需要这些特殊标记要占用词表的前几个位置索引从0开始分配。为什么要放在前面因为这样在后续处理中可以用简单的条件判断来识别它们比如if idx num_special_tokens。词表大小怎么定没有标准答案。太小会导致大量OOV太大会增加嵌入层参数量和内存占用。对于学习项目中文按字符切分的话常用汉字加标点大概在5000到10000之间英文按词切分的话30000到50000比较常见。你可以先统计一下语料的实际token分布看看覆盖95%以上token需要多大的词表以此作为参考。1.4 从文本到索引序列的完整实现把上面的步骤串起来一个完整的预处理流程大概长这样import re from collections import Counter def tokenize(text, modechar): 分词函数支持字符级和词级 if mode char: # 中文按字符切分英文按空格和标点切分 tokens re.findall(r[\u4e00-\u9fff]|[a-zA-Z]|[^\s], text) else: tokens text.lower().split() return tokens def build_vocab(token_lists, min_freq2, max_size10000): 构建词表 counter Counter() for tokens in token_lists: counter.update(tokens) # 特殊标记 special_tokens [unk, pad, bos, eos] vocab {token: idx for idx, token in enumerate(special_tokens)} # 按频率排序过滤低频词 for token, freq in counter.most_common(): if freq min_freq: break if len(vocab) max_size: break if token not in vocab: vocab[token] len(vocab) return vocab def numericalize(tokens, vocab): 把token序列转成索引序列 return [vocab.get(token, vocab[unk]) for token in tokens]这段代码看起来简单但有几个细节值得说。re.findall里的正则表达式把中文、英文单词、非空白字符分别匹配出来这样能保证中英文混排时不会出错。build_vocab里用most_common()按频率排序遇到低频词就break因为后面的频率只会更低。numericalize里用vocab.get(token, vocab[unk])来处理词表外的token这是最稳妥的写法。实际跑的时候你可能会发现某些标点符号或者特殊字符被切成了单独的token频率还不低。这时候可以考虑在分词阶段就把它们规范化掉比如把各种引号统一成一种、把连续空白合并成一个空格。这些清洗工作看起来琐碎但能显著减少词表里的噪声。2. 语言模型让机器学会“猜下一个词”文本预处理解决的是“怎么把文字变成数字”的问题而语言模型解决的是“怎么让模型理解这些数字序列”的问题。语言模型的核心目标非常朴素给定前面的词预测下一个词是什么。这个看似简单的任务实际上是所有文本生成、机器翻译、语音识别系统的基石。2.1 语言模型到底在建模什么从概率论的角度看一个语言模型定义了一个序列的联合概率分布$$P(w_1, w_2, ..., w_T) \prod_{t1}^{T} P(w_t | w_1, ..., w_{t-1})$$这个公式的意思是一段文本出现的概率等于每个位置上的词在给定前面所有词的条件下出现概率的乘积。比如“今天天气真好”这句话的概率就是 $P(今天) \times P(天气|今天) \times P(真|今天天气) \times P(好|今天天气真)$。模型训练的目标就是最大化训练语料上这个联合概率等价于最小化负对数似然损失。在实际实现中我们通常用交叉熵损失来衡量预测分布和真实分布的差异。为什么这个建模方式有效因为语言本身是有规律的。虽然自然语言看起来千变万化但词与词之间的搭配、句法结构、语义约束都使得某些序列比另一些序列更可能出现。语言模型要做的就是把这些规律从数据里学出来编码成参数。2.2 从N-gram到神经网络为什么需要RNN在深度学习普及之前主流的语言模型是N-gram模型。它的假设很简单第t个词只依赖于前面N-1个词而不是全部历史。比如三元模型N3只考虑前两个词。N-gram的问题很明显稀疏性很多N元组合在训练语料里根本没出现过概率估计为零需要各种平滑技巧来补救。固定窗口只能看固定长度的历史无法捕捉长距离依赖。参数爆炸随着N增大需要存储的N元组合数量指数级增长。循环神经网络RNN的出现就是为了解决这些问题。RNN的核心思想是引入一个隐藏状态它在每个时间步更新并携带之前所有时间步的信息。这样理论上RNN可以看到任意长的历史而且参数量不随序列长度增加。用生活化的类比来说N-gram就像一个人只能记住最近两三个词然后就要做判断而RNN就像一个带着笔记本的人每读一个词就在笔记本上记一笔笔记本上的内容就是隐藏状态它浓缩了之前读过的所有信息。2.3 RNN的计算过程与隐藏状态更新RNN在每个时间步t的计算可以写成$$h_t \tanh(W_{hh} h_{t-1} W_{xh} x_t b_h)$$ $$\hat{y}t W{hy} h_t b_y$$其中 $x_t$ 是当前时间步的输入通常是词嵌入向量$h_{t-1}$ 是上一时刻的隐藏状态$h_t$ 是当前隐藏状态$\hat{y}_t$ 是输出层的预测。这里的关键是参数共享$W_{hh}$、$W_{xh}$、$b_h$ 在所有时间步都是同一组参数。这意味着RNN处理序列的方式是“循环使用同一套规则”而不是为每个位置单独学一套参数。这也是RNN能处理变长序列的原因。隐藏状态 $h_t$ 可以理解为一个“记忆向量”它把从序列开始到当前位置的所有信息压缩成了一个固定维度的向量。维度通常设为128、256或512维度越大能承载的信息越多但计算量也越大。激活函数为什么用tanh因为tanh的输出范围是(-1, 1)有正有负且导数在0附近接近1有利于梯度传播。相比之下sigmoid的输出恒为正容易导致隐藏状态在多次迭代后偏向某一方向。2.4 困惑度衡量语言模型好坏的标尺语言模型的评估指标通常用困惑度Perplexity, PPL$$PPL \exp\left(-\frac{1}{T}\sum_{t1}^{T} \log P(w_t | w_1, ..., w_{t-1})\right)$$困惑度可以直观理解为模型在每一步预测时平均面临多少个“等可能”的候选词。困惑度越低说明模型对下一个词的预测越确定、越准确。一个随机猜测的模型困惑度等于词表大小一个完美的模型困惑度等于1。在实际训练中我们通常监控验证集上的困惑度。如果训练集困惑度持续下降但验证集困惑度开始上升说明模型过拟合了需要考虑增加正则化或者减少模型复杂度。提示困惑度是交叉熵损失的指数形式。如果你在代码里用的是交叉熵损失直接取torch.exp(loss)就能得到困惑度。注意要在整个验证集上计算平均损失后再取指数而不是对每个batch的困惑度再平均。3. 循环神经网络结构、训练与那些绕不开的坑上一章讲了RNN的基本计算过程这一章我们深入看看RNN在实际使用中的各种变体、训练技巧以及那些让初学者头疼的问题。说实话RNN是我见过“理论很优雅、实践很折磨”的典型代表很多坑只有真正跑过代码才能体会。3.1 从简单RNN到门控机制LSTM和GRU的动机简单RNN最大的问题是梯度消失和梯度爆炸。当序列较长时反向传播的梯度需要穿过很多时间步每穿过一步都要乘以权重矩阵和激活函数的导数。如果这些因子的乘积持续小于1梯度会指数衰减到接近零导致前面的时间步几乎收不到有效的梯度信号模型学不到长距离依赖。LSTM长短期记忆网络通过引入门控机制来解决这个问题。它有三个门遗忘门决定从上一时刻的细胞状态中丢弃哪些信息输入门决定当前时刻的哪些新信息要写入细胞状态输出门决定细胞状态的哪些部分要输出到隐藏状态细胞状态 $c_t$ 是LSTM的核心它像一条“传送带”梯度可以沿着它比较顺畅地传播从而缓解梯度消失。GRU门控循环单元是LSTM的简化版只有两个门重置门和更新门。参数量更少训练更快在很多任务上效果和LSTM相当。对于学习项目我通常建议先用GRU跑通流程再换LSTM对比效果。3.2 手写一个RNN语言模型代码结构与关键细节下面是一个用PyTorch实现的简单RNN语言模型的核心代码import torch import torch.nn as nn class RNNLanguageModel(nn.Module): def __init__(self, vocab_size, embed_dim, hidden_dim, num_layers1): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim) self.rnn nn.RNN(embed_dim, hidden_dim, num_layers, batch_firstTrue, dropout0.2 if num_layers 1 else 0) self.fc nn.Linear(hidden_dim, vocab_size) self.hidden_dim hidden_dim self.num_layers num_layers def forward(self, x, hiddenNone): # x: (batch_size, seq_len) embed self.embedding(x) # (batch, seq_len, embed_dim) if hidden is None: hidden torch.zeros(self.num_layers, x.size(0), self.hidden_dim) output, hidden self.rnn(embed, hidden) # output: (batch, seq_len, hidden_dim) logits self.fc(output) # (batch, seq_len, vocab_size) return logits, hidden几个关键点batch_firstTrue这个参数决定输入张量的维度顺序。设为True时输入是(batch, seq_len, features)默认False时是(seq_len, batch, features)。我强烈建议统一用batch_firstTrue因为这样更符合直觉调试时不容易搞混。隐藏状态的初始化初始隐藏状态通常设为零张量。注意维度是(num_layers, batch_size, hidden_dim)不要漏掉num_layers这一维。输出层的处理RNN的输出output包含了每个时间步的隐藏状态我们把它送进全连接层得到每个位置上对词表中所有词的预测分数logits。注意这里不需要softmax因为PyTorch的CrossEntropyLoss内部会做log_softmax。3.3 训练循环中的截断与状态传递语言模型的训练数据通常是长文本不可能一次性把整本书塞进网络。常见的做法是按固定长度截断比如每50个词作为一个训练样本。但这样会切断序列的连续性丢失跨样本的上下文。更优雅的做法是带状态的截断把长文本切成连续的片段每个片段作为一个batch但隐藏状态在片段之间传递。这样模型在训练时能“记住”前一个片段的信息。def train_epoch(model, data, batch_size, seq_len, optimizer, criterion): model.train() hidden None total_loss 0 num_batches 0 for i in range(0, len(data) - seq_len, seq_len): # 构造输入和目标目标就是输入向右移一位 inputs data[i:iseq_len].unsqueeze(0) targets data[i1:iseq_len1].unsqueeze(0) # 前向传播 logits, hidden model(inputs, hidden) # 分离隐藏状态防止梯度穿过太长的历史 hidden hidden.detach() # 计算损失 loss criterion(logits.view(-1, logits.size(-1)), targets.view(-1)) optimizer.zero_grad() loss.backward() # 梯度裁剪防止梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5) optimizer.step() total_loss loss.item() num_batches 1 return total_loss / num_batches这里有两个关键操作hidden.detach()把隐藏状态从计算图中分离出来。如果不这样做反向传播会一直追溯到序列的最开始计算图会变得极其庞大显存爆炸。detach之后梯度只会在当前片段内传播隐藏状态本身仍然携带历史信息但不会通过它回传梯度。梯度裁剪clip_grad_norm_把所有参数的梯度范数限制在max_norm以内。这是训练RNN的标配操作因为RNN的梯度很容易爆炸。max_norm通常设1到5之间具体值可以试。3.4 梯度消失与梯度爆炸的实操判断怎么判断训练中遇到了梯度问题几个信号梯度爆炸loss突然变成NaN或者loss剧烈震荡。这时候检查梯度范数如果经常超过几十甚至上百就是爆炸了。梯度消失loss下降非常慢或者模型只能学到短距离依赖比如只能根据前两三个词预测再远的信息完全用不上。可以打印每层的梯度范数如果前面层的梯度比后面层小好几个数量级就是消失了。应对梯度爆炸最直接的就是梯度裁剪。应对梯度消失可以换用LSTM或GRU或者减小序列长度或者用残差连接。注意梯度裁剪要在loss.backward()之后、optimizer.step()之前调用。顺序反了就没效果了。4. 把三个模块串起来一个可运行的字符级语言模型实战前面三章分别讲了文本预处理、语言模型理论和RNN结构。这一章我把它们串起来给一个完整的、可以直接跑的字符级语言模型实现。这个实战会覆盖从原始文本到模型生成的全流程中间遇到的坑我也会一一说明。4.1 数据准备与词表构建的完整流程假设我们有一段中文文本目标是训练一个能生成类似文本的字符级语言模型。完整流程如下import torch import re from collections import Counter # 1. 读取文本 with open(corpus.txt, r, encodingutf-8) as f: raw_text f.read() # 2. 清洗去掉多余空白保留中文、英文、常用标点 raw_text re.sub(r\s, , raw_text) raw_text re.sub(r[^\u4e00-\u9fff\u3000-\u303fa-zA-Z0-9。、《》], , raw_text) # 3. 按字符切分 tokens list(raw_text) # 4. 构建词表 counter Counter(tokens) special_tokens [unk, pad, bos, eos] vocab {token: idx for idx, token in enumerate(special_tokens)} for token, freq in counter.most_common(): if freq 2: break if token not in vocab: vocab[token] len(vocab) # 5. 数值化 idx_to_token {idx: token for token, idx in vocab.items()} data torch.tensor([vocab.get(t, vocab[unk]) for t in tokens], dtypetorch.long) print(f词表大小: {len(vocab)}) print(f总字符数: {len(data)})这段代码里清洗步骤的正则表达式保留了中文、英文、数字和常用中文标点。实际使用时你可以根据语料特点调整这个字符集。词表构建时频率低于2的字符被过滤掉映射为unk。4.2 模型定义与训练配置的取舍模型定义我用了GRU而不是简单RNN因为GRU在大多数任务上效果更好训练也更稳定class CharLM(nn.Module): def __init__(self, vocab_size, embed_dim128, hidden_dim256, num_layers2): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim) self.rnn nn.GRU(embed_dim, hidden_dim, num_layers, batch_firstTrue, dropout0.3) self.fc nn.Linear(hidden_dim, vocab_size) self.hidden_dim hidden_dim self.num_layers num_layers def forward(self, x, hiddenNone): embed self.embedding(x) if hidden is None: hidden torch.zeros(self.num_layers, x.size(0), self.hidden_dim) output, hidden self.rnn(embed, hidden) logits self.fc(output) return logits, hidden超参数的选择参数取值理由embed_dim128字符级任务不需要太大128足够hidden_dim256平衡表达能力和计算量num_layers2两层能学到更抽象的表示再多容易过拟合dropout0.3防止过拟合注意只在层间加seq_len64字符级序列64个字符能覆盖一个完整句子batch_size32根据显存调整lr0.002Adam的常用学习率4.3 生成文本从模型输出到可读文字训练完之后最激动人心的就是看模型能生成什么。生成过程是自回归的每次预测下一个字符然后把预测结果作为输入继续预测下一个。def generate(model, start_text, vocab, idx_to_token, max_len100, temperature1.0): model.eval() tokens [vocab.get(c, vocab[unk]) for c in start_text] input_tensor torch.tensor(tokens, dtypetorch.long).unsqueeze(0) hidden None generated list(start_text) with torch.no_grad(): for _ in range(max_len): logits, hidden model(input_tensor, hidden) # 取最后一个时间步的输出 last_logits logits[0, -1, :] / temperature probs torch.softmax(last_logits, dim0) # 按概率采样 next_idx torch.multinomial(probs, 1).item() next_char idx_to_token.get(next_idx, unk) if next_char eos: break generated.append(next_char) # 把新字符作为下一步的输入 input_tensor torch.tensor([[next_idx]], dtypetorch.long) return .join(generated)temperature参数控制生成的随机性。temperature1.0时按原始概率分布采样temperature1.0会让分布更尖锐生成更保守、更常见的文本temperature1.0会让分布更平坦生成更多样但可能不通顺的文本。实际使用时0.7到0.9之间通常效果比较好。4.4 训练中常见的loss异常与排查思路训练语言模型时loss曲线是最重要的诊断工具。几种典型情况loss一直不下降先检查数据有没有问题。把数值化后的序列打印出来看看是不是大部分都是unk的索引。如果是说明词表构建有问题可能是清洗太狠把有效字符都滤掉了。再检查学习率是不是太大或太小。loss下降后突然变成NaN典型的梯度爆炸。加上梯度裁剪把max_norm设小一点试试。如果还不行降低学习率。训练loss下降但生成效果很差过拟合了。增加dropout、减小模型、增加训练数据。另外检查一下生成时的temperature是不是设得太高。loss在某个值附近震荡不下降可能是学习率太大模型在最优解附近跳来跳去。试试学习率衰减策略比如每训练几个epoch就把学习率乘以0.5。提示训练语言模型时建议每隔几个epoch就生成一段文本看看效果。有时候loss看起来降得不错但生成的东西完全不通顺这说明模型可能学到了表面统计规律但没有真正理解语言结构。生成样本是比loss更直观的评估方式。5. 几个容易被忽略但很关键的实践细节这一章我单独拎出几个细节来讲它们不属于某个特定模块但在实际项目中经常成为“卡住一整天”的问题。5.1 词表大小与嵌入维度之间的匹配关系词表大小和嵌入维度之间存在一个经验性的匹配关系。词表越大嵌入维度通常也应该越大因为需要更多的维度来区分不同的token。但这不是线性的。一个粗略的参考词表 1000嵌入维度64到128词表 1000到10000嵌入维度128到256词表 10000到50000嵌入维度256到512词表 50000嵌入维度512以上嵌入层的参数量是vocab_size × embed_dim。如果词表是50000嵌入维度是512那嵌入层就有2560万个参数。对于小型项目来说这个参数量可能已经超过了模型其他部分的总和。所以如果语料不大不要盲目用大词表和大嵌入维度。5.2 序列长度对训练效率和效果的影响序列长度seq_len的选择需要权衡太短模型看不到足够的上下文学不到长距离依赖。比如seq_len10模型只能根据前10个字符预测再远的信息就丢了。太长计算量随序列长度线性增长RNN或平方增长Transformer显存占用也大幅增加。而且太长的序列中梯度传播路径变长训练难度增加。对于字符级中文语言模型seq_len在32到128之间比较合适。你可以从64开始试如果发现模型生成的文本经常出现前后矛盾说明上下文不够可以适当加长如果训练太慢或者显存不够就缩短。另外不同batch内的序列长度最好相近避免大量padding。如果语料中句子长短差异很大可以按长度分桶bucketing把长度相近的样本放在同一个batch里。5.3 模型评估除了困惑度还能看什么困惑度是语言模型的标准指标但它不是唯一的。实际项目中我还会关注生成样本的多样性用不同的起始文本生成多段文字看看模型是不是只会重复训练集里的原句。如果生成的文本和训练集完全一样说明模型在死记硬背没有真正学到语言的生成规律。对不同temperature的鲁棒性好的模型在temperature从0.5到1.2的范围内都应该能生成通顺的文本。如果只在某个特定temperature下表现好说明模型对概率分布的建模不够准确。长距离一致性让模型生成较长的文本比如200个字符以上看看它在后半段是否还能保持主题和风格的一致性。很多模型在生成前几十个字符时表现不错但越往后越混乱这说明隐藏状态的信息承载能力有限。5.4 从学习项目到实际应用的差距最后说一点个人体会。学习项目里我们通常用几百KB到几MB的语料训练几十分钟就能看到效果。但实际应用中语料规模可能是几十GB模型参数量是几亿到几十亿训练需要几十张GPU跑好几天。这中间的差距不仅仅是量级上的还涉及分布式训练、混合精度、梯度累积、数据并行等一系列工程问题。但好消息是核心概念是一样的。文本预处理的流程、语言模型的建模思路、RNN的梯度问题这些在小规模和大规模场景下本质相同。把学习项目里的每个细节都吃透知道每一步为什么这么做、不这么做会怎样这些经验在扩展到大规模系统时依然适用。我在实际使用中发现那些在学习阶段就养成好习惯的人——比如坚持写单元测试验证预处理逻辑、坚持监控梯度范数、坚持用生成样本做定性评估——在进入实际项目后上手明显更快。因为这些习惯背后是对问题本质的理解而不是对特定工具或框架的依赖。