腊月二十九晚上一个朋友发消息问我有没有现成的春联不想用网上那些烂大街的句子。我顺手打开电脑用之前基于飞桨训练的一个对对联模型输入上联“腊梅朵朵迎新岁”几秒钟之后返回了三组下联候选。虽然里面真正能贴到门上的只有一两条但那种“让AI接一句吉祥话”的感觉确实比复制粘贴有意思得多。这个项目其实是NLP入门里非常经典的一类用序列生成模型给上联对下联。它不要求多高的数学门槛也不需要多大的显存但能把文本表示、序列建模、注意力机制、自回归生成这些核心内容全部串起来。这篇文章我会从项目拆解、数据处理、模型设计到训练推理完整记录一遍我在飞桨上复现这个经典项目的做法和踩过的坑。适合已经会一点Python、想动手体验NLP生成任务的读者也适合过年想自己做一个“AI写春联”小工具的朋友参考。1. 项目整体拆解对对联到底是个什么任务对联看起来是文字游戏但真让机器来做它要解决的问题并不简单。上联和下联之间要字数相等、结构对称、词性呼应、平仄相对语义上还要有所关联。这比普通的“句子生成”多了好几层约束这也是为什么早期的规则系统很难做好的原因。1.1 从春节场景到序列生成问题把“对对联”转成NLP任务最直观的思路是把它当作条件文本生成给定上联的一串字符让模型生成下联的一串字符。这跟机器翻译的建模方式非常相似只不过机器翻译是跨语言转换而对对联是同一语言里“语义风格”的转换。以前有人用模板和辞书写一堆平仄规则、词性规则遇到没见过的上联基本就废了。深度学习方案不一样它直接从上联到下联的映射里去学统计规律。只要语料够多模型能自动学到很多“套路”比如“千山秀”对应“万象新”、“辞旧岁”对应“迎新春”。本质上是把上联当作输入条件把下联当作目标输出最大化解码器生成目标序列的概率。任务既然转成序列生成经典解法自然就是Seq2Seq。输入序列经过编码器变成语义向量解码器逐步生成输出序列。为了让生成更稳还要加上注意力机制和Beam Search。这个组合在短文本生成任务里非常成熟尤其适合对联、古诗、标题生成这类场景。1.2 为什么用飞桨做这个经典项目我用过不少深度学习框架飞桨这几年迭代得很快实际用下来有几个点很适合这类项目。动态图编排直观。Seq2Seq模型里有循环、有注意力、有Decoder逐步解码动态图模式下每一行都可以打断点查看中间张量的形状和数值。对新手来说这比静态图友好太多调试成本低一个量级。API设计接近主流习惯。飞桨的paddle.nn里Embedding、GRU、Linear这些模块用起来和主流框架的写法很接近。如果你之前看过PyTorch的Seq2Seq教程迁移到飞桨基本没有障碍只需要改改import和少部分函数名。还有一个很实际的优点是AI Studio上可以直接开项目跑不需要本地配CUDA。对很多只想复现效果、不想折腾环境的人来说这几乎是零门槛。我自己最初也是先在在线环境里跑通再在本地跑全量数据的。在本项目里我最终选择的模型组合是GRU Attention Beam Search而不是一上来就上Transformer。原因是对联句子短GRU在这种长度下效果足够稳定几十万条语料对Transformer来说仍然偏少数据量不足时反而不如GRU收敛得快Attention可以帮Decoder定位上联里的关键位置而Beam Search能同时保留多个候选正好符合“对联要让人挑”的场景。2. 数据工程先让模型“读”懂对联很多人在NLP项目里只盯着模型结构却忽略数据工程。但对对联这个项目数据清洗和词表设计的重要性不亚于模型。我在复现时花了差不多三分之一的时间在处理数据上后面模型能很快收敛靠的就是相对干净的数据。2.1 对联语料长什么样公开的对联语料常见格式是TSV每行两列中间用制表符分隔前一列是上联后一列是下联。样例如下爆竹声声辞旧岁 春风阵阵送吉祥 春回大地千山秀 福满人间万象新 天增岁月人增寿 春满乾坤福满门数据量一般在几十万条量级如果只用CPU跑建议不要全量上可以先抽样5到10万条做实验。单条对联的字数不等短的三五个字长的二十多个字。训练时为了控制batch里的padding长度我会把上联和下联都限制在20个字符以内。还有一个细节上联和下联的字数差。真正工整的对联上下联字数严格相等。但语料里偶尔会出现上联8个字、下联6个字的情况这类“不对仗”样本如果太多模型会学着偷懒生成短一截的句子。所以我做了一个长度差距过滤逻辑上下联长度之差超过4个字就丢掉。2.2 清洗、词表与批量加载先写一个清洗函数按顺序处理每一行去掉行首行尾空白检查是否刚好通过制表符分成两列过滤含英文字母、数字、URL等非中文内容的行合并连续空白清理全角空格对上联和下联分别做长度限制最后去重。清洗之后要构建词表。这里我强烈建议用“字级词表”而不是“词级词表”。对联讲究用字很多对仗关系发生在单字层面词级词表会引入分词误差而且词表规模大、未登录词多。字级词表通常几千个字就够用。from collections import Counter counter Counter() with open(couplet_train.txt, r, encodingutf-8) as f: for line in f: parts line.strip().split(\t) if len(parts) ! 2: continue up, down parts[0], parts[1] counter.update(up) counter.update(down) token2id {pad: 0, bos: 1, eos: 2, unk: 3} for ch, freq in counter.items(): if freq 2: token2id[ch] len(token2id) id2token {i: ch for ch, i in token2id.items()} print(vocab size:, len(token2id))有一点容易被忽略词表要在训练集上构建而且上下联都要加入统计。不能只统计上联否则下联里频繁出现的喜庆词全变成unk。接下来写Dataset。每条样本包含两段序列source是上联的字ID数组target是“bos 下联字ID eos”。import paddle class CoupletDataset(paddle.io.Dataset): def __init__(self, path, token2id, max_len20): self.data [] with open(path, r, encodingutf-8) as f: for line in f: parts line.strip().split(\t) if len(parts) ! 2: continue up, down parts[0], parts[1] if len(up) max_len or len(down) max_len: continue if abs(len(up) - len(down)) 4: continue source [token2id.get(c, token2id[unk]) for c in up] target [ token2id.get(c, token2id[unk]) for c in down ] target [token2id[bos]] target [token2id[eos]] self.data.append((source, target)) def __getitem__(self, idx): source, target self.data[idx] return source, target def __len__(self): return len(self.data)DataLoader里的collate_fn要自己做padding因为同一个batch里句子长度不同。padding值要填pad对应的0。为了让解码方便source pad后的序列保持原长target pad 后还要拆出“decoder输入”和“decoder目标输出”。def pad_sequences(sequences, pad_value): max_len max(len(seq) for seq in sequences) padded [] for seq in sequences: padded.append(seq [pad_value] * (max_len - len(seq))) return paddle.to_tensor(padded, dtypeint64) def collate_fn(batch): sources [item[0] for item in batch] targets [item[1] for item in batch] src pad_sequences(sources, token2id[pad]) tgt pad_sequences(targets, token2id[pad]) tgt_input tgt[:, :-1] tgt_output tgt[:, 1:] return src, tgt_input, tgt_output这里target整体是“bos下联eos”训练Decoder时输入用tgt[:, :-1]标签用tgt[:, 1:]。这样每个位置的Decoder输入是前一个位置的“标准答案”也就是Teacher Forcing。2.3 设计数据处理时容易忽略的点我复现时踩过几个和数据相关的坑单独列出来第一目标端必须加bos和eos。如果不加训练时Decoder不知道什么时候开始生成、什么时候停止。推理时模型生成到eos就停这个标记绝对不能省。第二损失计算里必须忽略pad位置。否则padding位置会产生很多无意义的损失模型为了降低损失会倾向于输出大量pad最终推理时生成一堆空白。第三不要把所有生僻字都硬塞进词表。出现频率只有一次的“死字”模型大概率学不好只会让词表变大、训练变慢。我一般设置最小词频为2低于这个阈值统一映射成unk。第四上联和下联长度差距过大的样本应该过滤。这不是为了省显存而是为了“对仗”这个目标。模型很聪明如果训练数据里有大量长短不一的对联它会学着生成长度不匹配的句子后面再想纠正就难了。第五验证集和测试集里的字最好都在词表范围内。如果词表只在训练集上构建测试时遇到生僻字会全部变成unk这会影响对最终效果的判断。3. 模型设计与训练细节数据准备完接下来是核心的模型部分。这个项目的模型不算复杂但每一步都有讲究。我按Encoder、Decoder、训练策略、推理策略四条线来讲。3.1 Encoder、Decoder和Attention的基本结构Encoder我用的是“字嵌入 单层GRU”。每个上联字符先查Embedding表变成固定维度的向量然后逐个送入GRU。GRU每个时间步会输出一个隐状态这些隐状态序列会留给后面的Attention使用。Decoder结构类似也是“字嵌入 单层GRU”。不同的是Decoder每个时间步除了接收上一个词向量还要接收一个Attention上下文向量。Attention的作用是让Decoder在生成下联的某个字时能回看Encoder的哪些位置更重要。比如生成“新春”的“新”时模型应该更关注上联里的“旧岁”或“新年”这类词而不是把上联每个字都等权看待。Attention的简单实现方式如下class Attention(paddle.nn.Layer): def __init__(self, hidden_size): super().__init__() self.fc_query paddle.nn.Linear(hidden_size, hidden_size) self.fc_key paddle.nn.Linear(hidden_size, hidden_size) def forward(self, decoder_hidden, encoder_hiddens, maskNone): query self.fc_query(decoder_hidden).unsqueeze(1) # [batch, 1, hidden] key self.fc_key(encoder_hiddens) # [batch, seq_len, hidden] scores paddle.matmul(query, key.transpose([0, 2, 1])).squeeze(1) if mask is not None: scores scores mask weights paddle.nn.functional.softmax(scores, axis-1) context paddle.bmm(weights.unsqueeze(1), encoder_hiddens).squeeze(1) return context, weights这里的mask是为了把Encoder里padding的位置mask掉避免Attention去关注无效的填充字符。实际项目中我还会在Encoder里加一个bidirectionalTrue让模型同时看到左右上下文效果会更好一些。Decoder每个时间步的计算顺序大致是把上一步Decoder隐状态送入Attention计算上下文向量把当前输入词向量和上下文向量拼接送入GRUGRU输出新的隐状态经过一个线性输出层得到词表大小的logits。为什么要拼上下文向量而不是直接用隐状态因为只靠隐状态容易忘掉Encoder里已经出现过的信息把上下文显式喂进去能让Decoder“时刻记得”上联内容减少生成跑偏的概率。3.2 训练策略Teacher Forcing、损失函数与梯度裁剪训练时Decoder使用Teacher Forcing每一步的输入都是真实下联的前一个字而不是模型自己预测的词。这样训练更快、更稳定但也要注意不要让模型完全依赖“标准答案”。我一般设置教师强制的概率在0.9左右剩下的0.1会让Decoder用自己上一步的输出作为输入算是给模型一点“自主纠错”的锻炼。损失函数用交叉熵但必须设置ignore_index来忽略padding位置loss paddle.nn.functional.cross_entropy( logits.reshape([-1, vocab_size]), target.reshape([-1]), ignore_indextoken2id[pad] )优化器我用Adam初始学习率0.001。训练几轮之后如果loss不降会降到0.0003再继续。最关键的还是梯度裁剪这对GRU尤其重要。GRU的循环结构容易在时间步上累积梯度学习率稍微高一点loss就会变成NaN。我直接用clip paddle.nn.ClipGradByNorm(clip_norm1.0) optimizer paddle.optimizer.Adam( learning_rate0.001, parametersmodel.parameters(), grad_clipclip )训练过程中我会记录每200步的平均loss和困惑度perplexity。困惑度可以用exp(loss)估算数值越小说明模型对训练数据的拟合越好。通常从初始的300多掉到20以内只需要几轮迭代。训练轮次不需要太多。10万条数据、batch size 64在单卡GPU上训练10到15轮基本就稳定了。再多容易过拟合生成出的句子反而开始背语料缺少泛化能力。3.3 推理阶段为什么用Beam Search训练结束后推理和训练有本质区别推理时必须逐个字符生成Decoder每一步的输入来自上一步的输出不能用Teacher Forcing。最简单的解码方式是贪心搜索每个时间步只取概率最大的词。问题是错一步后面全跟着错而且容易出现“春春春”这种重复字。更常用的方案是Beam Search。它每一步保留beam size个概率最高的候选序列而不是只留一个。最后综合每个候选的总概率和长度选出最好的几个。Beam size一般取5或10太小接近贪心太大耗时明显增加。为了让生成结果更符合对联的“工整感”我还会做两件事一是加长度惩罚。模型天然倾向于生成较短序列因为每一步概率相乘短序列更容易累积出更高的总概率。用下面的分数来调整score log_prob / (length ^ alpha)alpha在0.6到0.8之间我在项目里取0.7既不过分偏向长句也能避免出一大串冗余字。二是禁止重复n-gram。通过设置no_repeat_ngram_size2模型不会在同一个句子里连续重复两个字的组合。这个参数对对联来说非常实用能大大减少“春风春风”这类病句。Beam Search代码写起来并不复杂核心逻辑可以概括成“扩长、打分、剪枝”三个步骤。如果不想手写也可以直接调用飞桨解码库里的相关接口或者用第三方实现效果差别不大。4. 实际操作从安装到生成下联理论部分讲完下面是一套可以直接照着跑的操作流程。我会说明环境、训练、推理三个环节的具体做法。4.1 环境准备与数据准备先安装飞桨。CPU版本安装命令如下具体版本号以官网为准python -m pip install paddlepaddle2.4.0如果你有NVIDIA GPU建议安装GPU版训练效率会快很多。安装完先检查一下环境import paddle print(paddle.__version__) print(paddle.is_compiled_with_cuda())然后准备数据。把公开的对联语料下载到本地解压成一个文件夹里面至少应该包含train.txt和valid.txt两个文件。如果数据没有划分可以按9比1自己切分注意先随机打乱避免训练集和验证集的上联重复太高。我在本地实验时为了快速验证整套代码能跑通先抽了1万条数据训练5轮。确认loss在下降、推理能出结果之后再换成全量数据。这个习惯对调代码很有帮助能避免一上来就跑大半天才发现数据读取就有问题。4.2 训练与保存模型训练脚本里最重要的几个超参数可以参考batch_size 64 emb_size 256 hidden_size 256 num_layers 1 dropout 0.0 learning_rate 0.001 max_epoch 12 beam_size 5如果显存不够优先减小batch_size到32然后检查是不是序列padding太长导致浪费。我之前因为把max_len设成50结果大部分样本只有10个字等于在batch里塞了一大堆无意义的paddingGPU显存无谓地多占用了一倍多。后来把max_len改成20问题立刻缓解。训练过程中的日志大致长这样epoch 1, step 500, loss 3.42, ppl 30.6 epoch 1, step 1000, loss 3.05, ppl 21.1 epoch 2, step 1500, loss 2.64, ppl 14.0 epoch 3, step 2000, loss 2.33, ppl 10.3看到loss降到2.5以下基本意味着模型已经学会“顺畅地说话”了。之后可以每训练完一个epoch在验证集上随机取几个上联做推理直观判断效果。模型保存要同时保存state_dict和优化器状态paddle.save(model.state_dict(), model.pdparams) paddle.save(optimizer.state_dict(), optimizer.pdopt)只保存模型参数也能推理但保存优化器是为了今后恢复训练不必从头再来。4.3 让模型写春联的效果实录下面是我在复现时记录的一组输出用的是公开语料训练10轮后的模型beam size取5。具体结果会因随机种子和训练数据不同而略有差别但大致风格如下上联候选下联爆竹声声辞旧岁春风阵阵送吉祥春回大地千山秀福满人间万象新天增岁月人增寿春满乾坤福满门瑞雪纷飞辞旧岁红梅绽放迎新春一帆风顺年年好万事如意步步高可以看到经典对子模型几乎都学到了。因为训练语料里这种出现频率很高的对子并不少模型本质上是在做“高频搭配的记忆”加“模式泛化”。更有意思的是让它对自编上联。我输入“腊梅朵朵迎新岁”模型给出的候选中有一条是“春风缕缕送旧年”。字面通顺词性上也大致工整但仔细看平仄“新岁”和“旧年”都是平仄搭配作为下联略有问题。这就是我说“机器能保证通顺但审美还得人来把关”的原因。如果你想要更多样的结果可以把beam size调小一点或者加入随机采样。在推理时用top_p0.9的采样方式模型会不那么“保守”偶尔能蹦出让人眼前一亮的句子但代价是翻车率也会提高。5. 踩坑记录与优化思路最后这部分是实打实的经验。这类NLP生成项目看着简单跑起来问题却不少。我把高频问题和解决办法整理成一张速查表方便你照着排查。5.1 高频问题速查表现象可能原因解决办法loss变成NaN学习率过高或梯度爆炸降低学习率到1e-4加上ClipGradByNorm(1.0)生成结果大量重复字Beam Search缺少去重机制设置no_repeat_ngram_size2或调大长度惩罚系数下联全是unk词表太小数据清洗不到位用字级词表过滤含特殊字符的行增大词表规模生成的下联通顺但不对仗训练数据里有大量长度不匹配样本清洗时过滤上下联长度差过大的行句子长度总是不对没有正确设置eos标记或长度惩罚保证目标端有eos推理时加入长度惩罚项训练很慢padding过长或数据量过大限制max_len减小batch size先在子集上调试模型只会背诵高频对子数据量不足或过拟合增加数据量减少epoch或者加入Dropout这里面最值得说的是第一项。loss变NaN不是模型“笨”而是循环网络里的老毛病。梯度经过多步GRU反向传播很容易指数级增长。我用过一次学习率0.01结果训练不到两百步就NaN了换成0.001并加上梯度裁剪之后训练稳定很多。遇到NaN不要急着换模型先看优化器设置。5.2 想让对联更“对仗”还能怎么调如果基础效果已经满意还想让模型生成的下联更符合格律可以从下面几个方向继续优化一是引入平仄特征。把每个字的拼音声调做成额外输入特征上联是仄声还是平声、下联该怎么匹配模型能更容易学到。这个思路并不复杂只需要用拼音库给每个字符标上声调类别再拼到字向量后面。二是改用双向Encoder。单向GRU只能看到从左到右的信息双向GRU能看到每个字前后文信息。对对联这种严格要求对称结构的任务双向编码能显著提升Attention的对齐质量。三是在解码阶段加上“硬约束”。比如要求下联长度必须和上联相同、末字平仄必须符合规则。不符合条件的候选直接淘汰。这是工程上最常用的兜底手段虽然模型本身没变但最终展示给用户的结果会更“像样”。四是尝试引入预训练语言模型做重排序。先用GRUBeam Search生成10个候选再用一个更强的语言模型或分类器对候选打分把最“像下联”的句子排到前面。效果提升明显但复杂度也更高适合进阶玩家尝试。我个人在实际操作中的体会是这类生成模型不是“生成完就完事”后面还要有人来挑。对联的审美标准很主观机器能保证的是通顺和大概率的对仗真正要贴到门上还是要靠人眼筛选。如果你打算复现这个经典项目我建议顺序别搞反先把数据做干净再调模型结构最后再去看生成效果。数据不干净后面所有折腾都是在给模型“喂毒”。如果过年期间你也想做一个“AI对对联”的小工具我建议拿到训练好的模型后直接写一个最简单的Flask或FastAPI接口把上联传进去返回几个候选下联一家人围在一起挑选春联的画面比手机里刷模板有趣多了。
阅读完成 · 觉得有帮助?