☰
古诗生成与情感分析闭环系统:基于LSTM和BERT的NLP实践
2026/9/26 19:17:16 网站建设 项目流程

简介:面向自然语言处理与机器学习学习者,这份资源围绕古诗自动生成与情感分析,完整覆盖语料爬取、文本预处理、SPSS数据分析、规则作诗和机器学习写诗五个环节,适合做课程设计、毕业设计或入门NLP项目实践。压缩包内共156个文件,包含43个Python脚本,涵盖爬虫、分词、去停用词、模型训练与推理;73个txt文件保存语料、训练数据和分析结果;6组checkpoint、index和data分片对应不同训练步数的TensorFlow模型,另有词向量bin、可视化png、ttf字体及docx说明文档,整体约337MB。目前已有293人学习下载,适合需要系统参考完整流程的学习者。资源提供了可直接参考的工程目录与训练产物,既有基于格律韵脚的规则引擎,也包含不同步数的机器学习写诗模型,可对比两种生成方式的差异;情感分析数据与词向量产物则有助于研究古诗语言风格与情感映射,对理解中文NLP落地过程很有帮助。

1. 把古诗生成和情感分析放进同一个系统,到底图什么

很多做机器学习项目的同学,一上来就只跑古诗生成,结果生成出来的句子通顺归通顺,情感是散的:上一句还“春风得意”,下一句直接“泪满襟”,读起来非常跳。反过来,只做情感分析的那拨人,把手头的古诗词打上积极、消极标签就算交差,完全没有用到生成能力。拆开看这两件事都不难,难的是让它们在同一个系统里闭环流转:模型先把诗写出来,再用自然语言处理的情感分析模块去验这首诗的情绪强度,和目标不一致就扔回去重写,直到交出情绪稳定的作品。这套系统就是干这件事的,对正在做机器学习相关毕业设计、或者想同时把生成任务和分类任务串起来练手的人来说,是很典型也很有性价比的一个方向。

它不解决“写出传世名篇”的问题,它解决的是“让生成结果可控、可评价、可迭代”的问题。以七言绝句生成为主场景,叠加上中文文本情感分析模块,既能完整演示自然语言处理从数据到模型再到服务的链路,也给“机器写诗写得好不好”提供了一个相对客观的评估尺子。

2. 古诗生成的模型选型:LSTM还是Transformer

2.1 古诗文本的建模难点,以及为什么不能照搬通用模型

古诗和新闻、评论、聊天记录最大的差别在于约束条件多。一首七言绝句,每句固定七个字,还要讲平仄、押韵、对仗,语义上又要保持意象连贯。直接拿现代文本语料预训练好的语言模型来续写,输出经常是“像现代散文的一句诗”,因为它在训练时没见过这么强的格式约束。做这个项目时,我的建议是不要一上来就套大参数模型,尤其别直接依赖通用预训练模型做古诗续写,原因有两个:古诗语料远小于现代汉语语料,预训练模型在古诗场景上的收益不稳定;另外对想在机器学习方向上练手的人来说,模型结构可解释、训练过程可控,比“跑出个高分”更有价值。

所以在选型上,常见做法是先用字符级的 LSTM 把基线跑通,拿到一批能看的输出,再决定要不要切到 Transformer。LSTM 的优势是处理定长序列的古诗很自然,每个 time step 吐一个字,五个字或七个字的诗正好对应五个或七个隐状态;缺点是对长距离依赖的建模能力弱于带自注意力的 Transformer。用在绝句这种短文本上,LSTM 的劣势不明显,反而训练快、显存省、代码也好调。

2.2 数据清洗和字符级预处理:只留五言和七言

古诗生成的数据集不需要太大。常见做法是用全唐诗的整理文本,网上有现成的版本,只需要把每首诗按行拆出来,过滤掉题目、作者和注释行。关键点是过滤逻辑必须严格,不然模型会学到“一行七个字但夹杂着标点和空格”这种坏习惯。

我一般会写这样一个预处理脚本:

import re from collections import Counter def load_poems(path): poems = [] with open(path, 'r', encoding='utf-8') as f: for line in f: line = line.strip() if not line: continue # 去掉常见标点,全半角都要处理 line = re.sub(r'[,。!?、;:“”‘’\s]', '', line) # 只要五言或七言的整句 if len(line) not in (5, 7): continue # 只保留纯汉字行,去掉带数字、英文的噪声 if re.fullmatch(r'[\u4e00-\u9fff]+', line): poems.append(line) char_count = Counter(''.join(poems)) vocab = ['<pad>', '<unk>', '<bos>', '<eos>'] + sorted(char_count.keys()) char2idx = {c: i for i, c in enumerate(vocab)} idx2char = {i: c for c, i in char2idx.items()} return poems, char2idx, idx2char

这段代码的作用不仅仅是清洗,它同时会把语料转成字符级的字典映射。注意我用<bos>和<eos>作为句子的开始和结束标记,这比用<s>、</s>更直观,而且和后面情感分析模块的 tokenizer 格式不冲突。过滤标点时正则里一定要包含中文标点,很多开源语料里夹着全角逗号和句号,漏掉的话字符表会凭空多出一堆低频脏词。

参数说明:len(line) not in (5, 7)这一步看起来很粗暴,但古诗数据集的现状就是这样,行长短不一,先按字数过滤是最可靠的做法。如果你要做的是词或曲,再把长度规则改掉。字符表用sorted排序是为了保证每次运行得到相同字典,避免模型训练时字符顺序不稳定。

2.3 字符级 LSTM 生成模型的最小实现

模型部分我用 PyTorch 写一个两层 LSTM。embedding 维度取 128,隐层维度取 256,两个 LSTM 层叠起来,输出层接一个全连接映射到词表大小。词表大小取决于语料规模,全唐诗整理完之后常见在五千到八千之间,这个规模下模型参数量完全可控,单卡训练也很轻松。

import torch import torch.nn as nn class PoemGenerator(nn.Module): def __init__(self, vocab_size, embed_dim=128, hidden_dim=256, num_layers=2): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim) self.lstm = nn.LSTM(embed_dim, hidden_dim, num_layers, batch_first=True) self.fc = nn.Linear(hidden_dim, vocab_size) def forward(self, x, hidden=None): emb = self.embedding(x) out, hidden = self.lstm(emb, hidden) logits = self.fc(out) return logits, hidden

训练时的核心是 teacher forcing:每个 time step 的输入,按一定概率用真实的下一个字而不是模型上一个时间步的预测结果。我习惯把 teacher forcing 比例设成 0.9 起步,每轮训练结束后衰减 0.02,最低到 0.5。这样模型先跟着真实语料学稳定,再慢慢切换到预测模式,不容易在早期训练阶段就崩出各种不存在的字组合。

损失函数用交叉熵,但要注意掩码。

def compute_loss(logits, targets, pad_idx): loss_fn = nn.CrossEntropyLoss(ignore_index=pad_idx) # logits: (batch, seq_len, vocab_size) # targets: (batch, seq_len) logits = logits.reshape(-1, logits.size(-1)) targets = targets.reshape(-1) return loss_fn(logits, targets)

这里ignore_index=pad_idx很关键,它让模型不需要预测 padding 位置的字符。如果不加,padding 位置会贡献大量无意义的梯度,训练时 loss 曲线看起来挺低,但实际生成质量会莫名其妙变差。

2.4 生成阶段的三个必调参数:温度、重复惩罚、首字约束

模型训练好后,生成不能直接argmax。诗和聊天不同,诗讲究一定的随机性。我用温度采样控制随机程度。

import numpy as np def sample_with_temperature(logits, temperature=0.8): logits = logits / temperature probs = torch.softmax(torch.tensor(logits), dim=-1).numpy() idx = np.random.choice(len(probs), p=probs) return int(idx)

temperature 设 0.8 是折中值。大于 1.0 时生成结果偏散,容易跑出“江水东流月如霜”这种意象拼贴感很强的句子;低于 0.5 时模型过度保守,反复输出训练语料里的高频套话。对五言诗我一般用 0.9,因为五言字数少,需要更多变化;七言诗用 0.7 到 0.8,因为七言本身信息量大,温度太高容易导致后半句失控。

除温度外,还有一个容易被忽略的参数:首字约束。给模型一个起始字或起始词,生成质量和可控性都会提升。比如用户输入“春”字,模型从“春”开始续出整句。这个实现方式是把首字作为序列的第一个 token 喂进模型,然后从第二步开始采样。很多人在这一步想看“从零生成”,但我建议至少给一个主题字,否则模型会倾向输出训练集里出现频率最高的那几个开头,例如“白日”“青山”“故人”。

3. 古诗情感分析:文本分类那套方法在这里要动手改

3.1 古诗情感的特殊性:不是简单分正负面

古诗的情感表达方式比现代汉语含蓄得多。“感时花溅泪,恨别鸟惊心”里没有直接写“悲伤”两个字,但情感强度非常高。常规的文本情感分析模型在电商评论、社交文本上表现不错,一放到古诗上就翻车,原因是古诗的情感更多依靠意象组合,而不是显式的情感词。

所以做这个模块时,我重新定义了情感标签体系,不搞五档或七档,只保留三分类加一个强度值:积极、消极、中性。强度值是连续量,范围从 0 到 1,表示情感表达的强烈程度。为什么这样设计?因为生成系统下游要做的是“把诗的情绪拉回目标区间”,三分类用来做硬过滤,强度值用来做软排序,两件事分开做比混在一起好调。

3.2 用预训练模型做基础情感分类

我用bert-base-chinese作为情感分析的基础模型。古诗语料不多,从头训练不现实,在 BERT 基础上做领域微调是常规做法。先用通用情感数据集让模型学到情感的通用表示,再用古诗语料微调,让模型适应古典词汇。

from transformers import AutoTokenizer, AutoModelForSequenceClassification tokenizer = AutoTokenizer.from_pretrained('bert-base-chinese') model = AutoModelForSequenceClassification.from_pretrained( 'bert-base-chinese', num_labels=3 ) def predict_sentiment(text): inputs = tokenizer( text, return_tensors='pt', max_length=64, truncation=True, padding=True ) logits = model(**inputs).logits label = torch.argmax(logits, dim=-1).item() probs = torch.softmax(logits, dim=-1).tolist()[0] return label, probs

这里我做了两个调整:max_length设 64 而不是常见的 128,因为古诗单句最长七字,绝句整首也就二十八字,64 个 token 足够覆盖整首诗还留有余量;num_labels=3和前面的三分类标签对应。输出的probs是三个类别的概率分布,我把它存下来,后面生成系统要用。

3.3 怎么给古诗语料打情感标签:半自动标注流程

中文古诗可用的现成情感标签数据很少,硬标几百首又费时间。我的做法是先用分词工具统计每首诗里的情感关键词,把强弱关键词加权得到初步标签,然后人工抽检修正。

sentiment_words = { '悲': -0.8, '愁': -0.7, '泪': -0.6, '孤': -0.5, '寒': -0.4, '喜': 0.8, '乐': 0.6, '春': 0.3, '明': 0.3, '暖': 0.4, } def auto_label(poem_text): score = 0.0 for ch in poem_text: if ch in sentiment_words: score += sentiment_words[ch] if score > 0.3: label = 2 # 积极 elif score < -0.3: label = 0 # 消极 else: label = 1 # 中性 intensity = min(1.0, abs(score) / 2.0) return label, intensity

这个方案很粗糙,但作为微调数据的起点够用了。关键是后半句:每首诗自动打完标签后,我会抽 200 首人工复核。复核的指标不是“标签对不对”,而是“四个字的意象组合是否确实传达了这种情绪”。如果错得太多,就把情感词表里权重不准的词删掉重来。

3.4 微调参数怎么设:学习率和训练轮数优先

BERT 微调最容易犯的错是学习率设太高,把预训练权重破坏掉。我习惯用learning_rate=2e-5,batch_size=16,训练 3 到 5 个 epoch。古诗语料小,2-3 个 epoch 后验证集准确率就开始波动,这时候继续练只会过拟合,表现为情感分析在训练集上几乎全对,但真实生成的诗句分类结果乱跳。

另外,微调时要把古诗文本按整首输入,而不是按单句输入。整首诗输入能让模型学到句间的情绪递进关系,按单句输入会把“窗含西岭千秋雪”这种写景句误判成中性。

4. 把生成器和情感分析串成一个可用系统

4.1 系统整体架构:生成、校验、回滚

一个完整的古诗自动生成与情感分析系统,运行时的数据流是这个顺序:

请求进入系统后,先生成一个候选诗,接着调用情感分析模块对整首诗打标签和强度值。系统比较目标标签和实际标签,一致就输出;不一致就触发重新生成。重新生成不是简单的重跑,而是带着温度变化重新采样,让模型探索不同的输出。

def generate_with_sentiment( generator, sentiment_model, seed_char, target_label, target_intensity=0.5, max_attempts=8 ): best_candidate = None best_distance = 1e9 for attempt in range(max_attempts): poem = generate_poem(generator, seed_char, temperature=0.7 + attempt * 0.05) label, probs = predict_sentiment(sentiment_model, poem) if label == target_label: distance = abs(probs[target_label] - target_intensity) if best_candidate is None or distance < best_distance: best_candidate = poem best_distance = distance if distance < 0.2: return poem, label, probs # 记录当前最佳候选,避免全部失败时返回空 if best_candidate is None: best_candidate = poem return best_candidate, label, probs

这段代码的核心逻辑是分步放宽标准。每次生成失败后,temperature 递增 0.05,从 0.7 逐渐升到接近 1.05。这样前几次生成偏向稳定,后几次开始引入更多变化。max_attempts=8是经验值,写五言诗时 8 次通常能找到目标情感,七言诗因为句子长,如果前 4 次失败,后 4 次也大概率失败,这个问题在后面避坑章节会细说。

4.2 命令行接口和最小可运行示例

系统要可复现,直接跑命令就能体验。我不喜欢把接口做得很重,一个main.py搞定生成、情感分析和对比即可。

python3 main.py \ --seed 春 \ --target_label 2 \ --max_attempts 8 \ --temperature 0.8

输出只打印三行:生成的诗句、实际情感标签、加标签的概率分布。这样的接口设计有两个好处:第一,调试时能直观看到一次生成尝试的整体效果;第二,给后面做 Web API 留了余地,命令行参数和 HTTP 请求参数可以一一对应。

如果你打算做成 Web 服务,常见做法是选 FastAPI 包一层,把生成函数和情感分析函数挂到POST /generate接口上。参数校验要注意一点:target_label必须限定在 0、1、2 三个值,不能传负数或浮点数,因为情感分析模型的输出层只有三个类别。

4.3 串起来之后最值得关心的事:不是生成质量本身

系统跑通后,最先要观察的指标不是我之前以为的“生成诗是否通顺”,而是情感分析的稳定性。生成器每跑一次,结果都不同,如果情感分析模型本身的预测方差大,系统就会处于反复重试状态,表现为好几次生成失败后返回一个情感标签完全南辕北辙的结果。解决这个问题的常见做法是把max_attempts从 6 提到 12,再不行就检查情感分析模型的微调数据是不是太偏。

另外要明确一点:情感分析模块和生成模型最好用不同的随机种子。两个模型的随机性独立,生成结果和情感判定才不会出现“生成器只产出一种风格”的耦合偏置。我实际操作时给生成器固定seed=42,给情感分析模型不固定种子,效果比两个都固定好。

5. 避坑:古诗生成与情感分析系统的 5 个常见问题

5.1 现象:生成的诗句反复出现同一句,像是复读机

原因:训练数据里同一首诗出现多遍,或者语料清洗时没去重。很多全唐诗数据集本身就有重复收录,同一首诗在多个卷里出现。模型学到高频句后,teacher forcing 训练让它在生成初期很自信,导致反复输出同一句。

解决:清洗阶段加一步去重,按行内容做哈希过滤。另外在采样时增加重复惩罚项,具体做法是给已经生成过的 token 的 logits 减去一个惩罚值,我习惯减 1.5,效果比调温度更直接。

5.2 现象:情感分析把七言绝句整首诗判成中性,但明显是首悲诗

原因:绝句后两句经常是写景收尾,情感在前两句。整首诗输入时,模型注意力被后两句写景内容分散。我一开始用整首诗输入觉得信息更全,结果反而导致分类被“空山不见人”这类写景句带偏。

解决:同时输入整首诗和前两句,让情感分析模型各出一个预测,最后取两个预测概率的平均值。前两句集中表达情感,后两句拓展意境,平均后预测方差明显下降。

5.3 现象:模型生成到第七个字时总是不够七个字,多一个或少一个

原因:LSTM 的batch_first配置和序列长度掩码没对齐。如果训练时序列长度设为 8,但输入数据里有长度不足 8 的样本,padding 位置被模型当成了有效字符去学习,生成时模型会把<pad>当成一个可预测的字符输出来。

解决:训练时所有样本先按最长句 pad 到固定长度,并在 loss 计算时用ignore_index=pad_idx掩掉 padding 位置。生成阶段强制约束序列长度,不生成<eos>,直接截断到目标长度。这两个改动配合,长度问题基本消失。

5.4 现象:情感分析模型在微调数据上准确率很高,但系统里实际用起来完全不对

原因:微调数据里积极和消极样本比例失衡。古诗里悲诗占多数,积极类的样本自然少。我最初的数据集里消极样本占 60%,积极只有 15%,模型学成了“看见古诗就蒙消极”。

解决:训练前统计标签分布,对积极类样本做上采样,或者采用 weighted loss。我最后用的是后者,把积极类的 loss 权重调成 2.0,中性类调成 1.0,消极类保持 1.0。微调后实际分类准确率提升了大概十个百分点。

5.5 现象:训练 loss 一直降,但生成结果完全没有诗意,只是字与字之间的局部衔接流畅

原因:这是最典型的“模型没崩溃,但也没学到结构”的情况。古诗的格律、押韵、意象是篇章级特征,字符级 LSTM 每个 time step 只看到前面的字,很难把整句的目标(比如押韵)变成梯度信号。loss 在下降是因为它学会了高频字的分布,但对“押韵”这个全局约束无能为力。

解决:在生成阶段加规则校验,不押韵的诗句直接丢弃重来。常见做法是把每句尾字和首句尾字比较韵母,韵母不一致就记为失败。对五言诗和七言诗分别维护一个简单的韵脚表。这个规则虽然机械,但很可靠,比让 LSTM 自己琢磨押韵效率高得多。

6. 更进一步的验证方法:从两个分数和一个习惯入手

系统做完不是终点,怎么向别人证明“这个系统有效”才是关键。我自己的验证方法是两个自动分数加一个手动检查。自动分数一个是生成诗的情感准确率,另一个是 BLEU 和参考诗的相似度。情感准确率很好算,系统输出 200 首诗,人工标出情感标签,和系统预测对比就得到准确率。BLEU 用现成库算,但参考语料要选好,如果拿全唐诗全集做参考,BLEU 会低得离谱,正确做法是拿同一个主题下的几十首诗做参考集。

手动检查是看三个点:字数是否符合格式、韵脚是否压上、情绪是否和目标一致。这三个点分别对应格式约束、语感约束和语义约束,任何一个不满足,系统就需要继续调。我建议把这三个检查项写成一行命令,每次调参后批量跑 50 首样本,快速对比不同参数组合的输出分布。这是我自己调参下来最有效的习惯:先批量产出样本,再统一看失败模式,而不是每次生成一首就调一次参数。

这个项目做下来,我最有体会的一点是:生成任务和分类任务放在同一个系统里时,真正的难点不是模型结构,而是怎么让两个模块的误差互相对齐。情感分析的一点点偏见,会在生成系统的反复重试中被放大。希望这篇文章能帮你少走这段弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询