基于机器学习与LSTM的古诗生成与情感分析实践
简介这套基于机器学习与自然语言处理的古诗自动生成与情感分析实战项目完整覆盖语料爬取、文本预处理、词频统计与关键词提取、规则作诗及机器学习写诗等环节。面向具备一定Python基础、希望深入NLP实战的开发者或研究者可广泛应用于课程设计、毕业设计或入行项目练习。压缩包共156个文件包含43个Python源码脚本、73个txt语料与输出结果以及模型检查点、索引文件和词向量bin文件等训练产物压缩包整体约337MB既便于直接运行也支持从数据采集到模型训练的全流程复现。目前已有293人学习下载。借助预训练检查点可快速加载生成模型结合爬虫与处理脚本可重新梳理全链路进而理解古诗词生成与情感分析的技术要点快速搭建属于自己的古诗生成实验环境。1. 古诗自动生成与情感分析先想清楚系统两个字的分量基于机器学习与自然语言处理的古诗自动生成与情感分析系统这个标题在毕设和课设里出现频率很高但它并不是把两个模型文件放在一起就叫系统。生成端要做自回归语言模型按概率预测下一个字分析端要做文本情感分类把诗映射到少数几个情感标签。两者目标不同却要在一个工程里协作这才是这个标题真正练手的地方。先给一个反直觉结论模型结构对最终效果的影响不如数据清洗和评测方法大。很多项目把LSTM和BERT都跑通最后仍交不出一首能通读的诗问题几乎都出在数据和验证上。下面按选型、数据、训练、集成、避坑的顺序讲清楚新手能直接照做熟手也能看到参数边界。2. 选型和数据准备为什么LSTM仍是古诗生成的高性价比起点2.1 生成模型该选LSTM还是Transformer先看数据量古诗生成本质是字符级语言模型问题给定前面的字预测下一个字。这和输入法的联想思路一脉相承只是上下文关系更强属于自然语言处理nlp里最经典的序列建模场景。传统机器学习算法比如HMM和最大熵模型都能建模序列但它们对超过一二十个字符的依赖基本记不住。古诗虽然短但春风又绿江南岸里又绿需要回头参考春风岸又呼应江依赖距离在10字以上传统模型已经吃力。LSTM用隐状态把历史信息压缩成一个向量天然适合这种中等距离依赖。七言绝句最长28字律诗也就56字LSTM在处理这个长度时并不会有远距离遗忘问题。很多人一上来就上Transformer觉得不用白不用。但Transformer在小数据上容易过拟合还要调学习率warmup、位置编码、层数、头数语料只有几千首时它的优势发挥不出来训练成本却高得多。我一般把LSTM作为第一版跑通系统链路后再考虑是否升级。这也符合《机器学习》周志华那本书里反复讲的偏差-方差取舍模型容量必须和数据规模匹配不要拿大炮打蚊子。这里要顺手避掉一个常见误用直接用别人在新闻、知乎语料上训练好的词向量初始化embedding然后冻结不更新。古诗用字习惯和现代汉语差异极大通用词向量会把怎一个愁字了得里的了学成时态助词但它在词牌里只是衬字。词向量要从古诗语料重新训练至少也要随主模型一起微调不要把它当黑匣子一样固定住。2.2 情感分析选型情感词典传统机器学习是稳妥起点情感分析模块有三条路线词典规则、传统机器学习和预训练模型。古诗情感表达含蓄大漠孤烟直字面写景情感却是沉郁壮阔春风得意马蹄疾字面写景情感是喜悦。直接用情感词典做规则只能覆盖愁悲喜这类显性词覆盖率很低。传统机器学习则可以把字频、词典得分、否定词数量、诗长都作为特征交给朴素贝叶斯、逻辑回归或支持向量机去学权重。这条路线的优势是训练快、可解释、不容易翻车。逻辑回归的系数还能直接告诉你哪些字对正面/负面贡献大这在系统演示时很有价值。朴素贝叶斯也可以做但它假设特征独立对否定词情感词的组合不敏感。比如不悲在朴素贝叶斯眼里可能同时出现否定词和负面词得分互相抵消逻辑回归至少能通过特征交叉学到否定词出现时常翻转情感方向。所以我更推荐逻辑回归做基线。如果数据量超过一万条再上BERT微调。注意现在讨论里流行把文本情感分析往多模态情感分析方向带但本系统输入只有古诗文本没有图像、语音强行引入多模态会分散主干不划算。2.3 数据准备把古诗语料转成模型可读的序列格式数据准备是系统里最容易被低估的部分。从网上下载的全唐诗txt通常混着诗名、作者、注释、词牌名不洗掉这些杂讯模型的标签空间会被污染。我习惯把原始文件按空行分段再逐行去除非诗行下面这段代码是常用清洗方案# preprocessing.py import re import json RAW_PATH poems_raw.txt OUT_PATH poems_clean.json def clean_poem(text: str) - str: lines [] for line in text.splitlines(): line line.strip() if not line: continue # 跳过包含书名号、括号、数字的标题/注释行 if re.search(r[《》\[\]()0-9a-zA-Z], line): continue # 只保留汉字和中文标点 line re.sub(r[^\u4e00-\u9fa5。], , line) if len(line) 5: continue lines.append(line) return .join(lines) poems [] with open(RAW_PATH, encodingutf-8) as f: for block in f.read().split(\n\n): cleaned clean_poem(block) if cleaned and 10 len(cleaned) 80: poems.append(cleaned)这里两个阈值参数需要解释len(cleaned) 10过滤掉残句len(cleaned) 80避免长篇杂言诗混入如果你专门做五言绝句甚至可以收紧到20 len 40。接着做低频字过滤和统一繁体/简体。为什么要过滤低频字古诗中生僻字在字符级模型里会占用词表却很少作为预测目标学不到可靠表示我一般丢弃出现次数低于5次的字。from collections import Counter char_counter Counter(.join(poems)) valid_chars {c for c, n in char_counter.items() if n 5} filtered_poems [] for p in poems: if all(c in valid_chars or c in 。 for c in p): filtered_poems.append(p) with open(OUT_PATH, w, encodingutf-8) as f: json.dump(filtered_poems, f, ensure_asciiFalse, indent2) print(f清洗完成保留 {len(filtered_poems)} 首诗) print(f词表大小: {len(valid_chars)})为了确认清洗效果再跑一个ASCII残留自检ascii_hits [p for p in filtered_poems if re.search(r[a-zA-Z0-9], p)] print(f残留英文或数字的样本数: {len(ascii_hits)}) for p in ascii_hits[:5]: print(p)如果ascii_hits不为0说明清洗逻辑有漏洞模型会把英文当成一个字来学。如果清洗后诗总数不足1000首就要回源头找更多语料或者把低频字阈值从5降到3、长度上限从80放到100。有一点要特别强调简体繁体混用会让词表膨胀比如云和雲会被当成两个不同字白白增加参数量。最好统一转成简体或者全部保留繁体但不要混用。最后把数据保存成JSON简单且方便后面生成和情感模块复用。数据准备好之后还要划出一部分做验证集。不能拿全部数据训练否则你无法判断模型是通读了还是背下来了。通常的做法是随机留出10%作为验证集等训练完再单独算困惑度如果语料本身有诗人和年代标记最好按年代切分避免验证集和训练集出现同题诗或重名诗这里不展开但切分逻辑一定要写进系统说明里。3. 用LSTM生成古诗最小训练流程与关键参数3.1 字符级tokenizer和序列样本构造生成模型的输入通常是字符ID序列。对古诗而言按字符切分比按词切分更好因为文言文分词没有统一标准分词错误会直接灌进生成模型。下面代码从上一章的JSON读入诗构造输入前缀下一个字的训练对# dataset.py import json import torch from torch.utils.data import Dataset with open(poems_clean.json, encodingutf-8) as f: poems json.load(f) chars sorted(set(.join(poems))) char2idx {c: i for i, c in enumerate(chars)} idx2char {i: c for c, i in char2idx.items()} VOCAB_SIZE len(chars) MAX_LEN 32 def make_sequences(poems, max_lenMAX_LEN): sequences [] for p in poems: p p.replace(, ).replace(。, ) ids [char2idx[c] for c in p] if len(ids) 2: continue for i in range(1, len(ids)): seq_in ids[max(0, i - max_len):i] seq_target ids[i] sequences.append((seq_in, seq_target)) return sequences这里的核心是滑窗构造每个位置都作为一次训练样本输入是它之前的至多32个字符。比如春风吹又生会被拆成春→风、春风→吹、春风吹→又这样一组连续样本。模型要学的不是在标点位置停顿而是根据前面的字推断下一个字这样在生成时才能连续输出。输入前文目标字说明春风学习用“春”预测“风”春风吹用“春风”预测“吹”春风吹又用前三个字预测“又”MAX_LEN32对七言绝句足够如果做律诗可以调到64。调参时要优先关注这个值不要一开始就给很大的值否则样本变长、训练变慢而且超过50字后LSTM的收益会明显下降。处理标点要提前想清楚上面代码把逗号句号都删了生成时也不会有标点如果你希望生成七言四句的排版就要在预处理时保留标点并单独分配token让模型自己学习在哪一句停顿。两种做法都能跑通区别是后者对数据清洗要求更高因为残句的标点常常不完整会把噪声带进来。3.2 模型结构与训练参数网络结构我用两层LSTM加一个全连接输出层这是古诗生成里最常见的配置。单层LSTM表达力偏弱三层以上在小数据上容易过拟合两层是折中。# model.py import torch.nn as nn class PoemLSTM(nn.Module): def __init__(self, vocab_size, embed_dim128, hidden_size256, num_layers2): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim) self.lstm nn.LSTM(embed_dim, hidden_size, num_layersnum_layers, batch_firstTrue) self.fc nn.Linear(hidden_size, vocab_size) def forward(self, x, hiddenNone): emb self.embedding(x) # (batch, seq_len, embed_dim) out, hidden self.lstm(emb, hidden) # (batch, seq_len, hidden_size) logits self.fc(out) # (batch, seq_len, vocab_size) return logits, hiddenembed_dim128是一个保守值hidden_size256是容量核心在几千首样本时可以支撑。如果效果不够好第一选择是加层数到3第二选择是加大embedding维度不要一上来就跑大模型。训练时用Adam优化器加学习率1e-3并配合梯度裁剪训练循环如下import torch.optim as optim from torch.nn.utils import clip_grad_norm_ optimizer optim.Adam(model.parameters(), lr1e-3) criterion nn.CrossEntropyLoss() for epoch in range(30): for inputs, targets in dataloader: inputs, targets inputs.to(device), targets.to(device) logits, _ model(inputs) # 取最后一个时间步的logits去算loss loss criterion(logits[:, -1, :], targets) optimizer.zero_grad() loss.backward() clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() print(fepoch {epoch}, loss {loss.item():.4f})注意这里logits[:, -1, :]要求每个样本末尾是有效字符所以make_sequences生成的序列是变长的batch内需要按长度排序并做动态paddingor直接用pack_padded_sequence。如果你不想写动态padding就把所有序列固定到MAX_LEN右侧用pad_idx补齐但计算loss时要用mask把padding部分去掉否则模型会拼命学习预测padding。训练轮数不要死盯30轮要看验证集loss连续5轮不降就早停。如果训练loss低、验证loss高是过拟合可以在embedding后加dropout层或者往数据里加一点随机字符替换做噪声注入。另一个容易忽略的参数是teacher forcing。我在训练时习惯以50%概率把真实前文作为下一步输入其余时间用模型自己的输出作为输入这样能减少生成时误差累积。如果你100%使用真实前文训练会收敛得很快但生成时容易崩。3.3 生成策略温度采样、重复惩罚与停止符训练结束后生成策略决定结果下限。贪心搜索每次都取概率最大的字很容易陷入春春春春的循环随机采样又可能产生不通顺的字。我用温度采样加重复惩罚生成函数如下# generate.py def generate(model, start_str春, max_new_chars30, temperature0.8): model.eval() chars list(start_str) input_ids [char2idx.get(c, char2idx[]) for c in chars] with torch.no_grad(): for _ in range(max_new_chars): x torch.tensor([input_ids[-MAX_LEN:]], devicedevice) logits, _ model(x) next_logits logits[0, -1, :] / temperature probs torch.softmax(next_logits, dim-1).cpu().numpy() # 重复惩罚已出现过的字概率乘0.6 for c in set(chars): if c in char2idx: probs[char2idx[c]] * 0.6 probs / probs.sum() next_idx torch.multinomial(torch.tensor(probs), 1).item() next_char idx2char[next_idx] if next_char in 。: break chars.append(next_char) input_ids.append(next_idx) return .join(chars)temperature控制分布形状0.8是我的常用值生成结果既稳又不死板低于0.5会变得保守且同质高于1.2容易碎句。重复惩罚系数0.6是保守系数如果仍然重复可以调到0.3如果出现的是整句重复说明模型学到了短循环应该回数据环节检查五言七言是否混训。另一个技巧是在数据里加入起始符和结束符比如每首诗文本左右加[和]生成时看到]才停止。这样比硬性限制max_new_chars自然也能避免生成到一半情感基调突然变化。如果要做一个藏头诗模式就把start_str改成多个首字并在生成时强制前几个位置输出对应字其余位置照常采样代价是藏头限制了模型自由发挥通顺度会下降需要在UI上给用户明确预期。4. 情感分析模块标签体系、特征工程与模型训练4.1 标签体系怎么定情感分析的第一步是定义标签。古诗不像商品评价那样可以直接打好评/差评它往往同时包含时序变化前两句写景后两句抒情。常见做法是给整首诗打一个整体情感倾向标签集设成正面、中性、负面三分类。如果做七分类比如思乡、离别、讽谏、闲适数据标注成本会成倍上升而且标注一致性很难保证。我自己的经验是先做三分类让标注者能给出可复现的标签后续如果用户反馈想区分思乡和离别再扩展标签体系。如果手头没有标注好的情感标签可以先让情感词典给每首诗打分自动生成伪标签再做人工抽样修正。具体做法是统计诗中正面词和负面词的出现次数正面减负面大于等于2判为正面小于等于-2判为负面其余为中性。这样能用低成本建出初始训练集但必须抽查修正否则词典覆盖不到的隐喻会被错误归入中性。情感标签的噪声会直接决定分类模型上限这一点比模型选型更重要。4.2 用情感词典机器学习分类器跑通基线我喜欢的基线是特征工程加逻辑回归。特征工程虽然老但可解释性很强适合系统里的分析展示。下面的代码构造每条诗的特征向量正面情感词数量、负面情感词数量、诗长度、否定词数量。# sentiment_features.py import jieba import numpy as np POS_WORDS {喜, 乐, 欢, 悦, 欣, 畅, 醉, 闲, 安, 静} NEG_WORDS {愁, 悲, 哀, 怨, 恨, 苦, 孤, 独, 寂, 伤} def extract_features(text: str) - np.ndarray: pos_cnt sum(1 for ch in text if ch in POS_WORDS) neg_cnt sum(1 for ch in text if ch in NEG_WORDS) negation sum(1 for w in jieba.lcut(text) if w in {不, 无, 未, 莫, 勿}) return np.array([pos_cnt, neg_cnt, len(text), negation])这里的POS_WORDS和NEG_WORDS可以继续扩充比如归梦老在很多古诗里带负向情感新晴生往往偏正向。但不要一次性把词表铺太大先跑通再迭代。得到特征后用逻辑回归训练并加上class_weightbalanced处理标签不均衡# train_sentiment.py from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split from sklearn.metrics import classification_report X np.array([extract_features(p) for p in poems]) y np.array(labels) X_train, X_test, y_train, y_test train_test_split(X, y, test_size0.2, random_state42) clf LogisticRegression(max_iter500, C1.0, class_weightbalanced) clf.fit(X_train, y_train) print(classification_report(y_test, clf.predict(X_test)))max_iter500是为了确保逻辑回归收敛C1.0控制正则强度数据少可以加大C。观察classification_report时不要只看accuracy重点看负面类别的recall和F1。如果数据严重不均衡除了class_weight还可以对中性样本做下采样让三类比例大致接近1:1:1。做完这些把逻辑回归的系数打印出来看一眼是个很好的自检手段feature_names [pos_count, neg_count, poem_len, negation_count] for label_idx, class_name in enumerate([中性, 正面, 负面]): coefs clf.coef_[label_idx] top sorted(zip(feature_names, coefs), keylambda x: -abs(x[1])) print(class_name, top)这一步能直观看到模型是不是靠负面词数量这一个特征做决策。如果neg_count权重极高而poem_len几乎为0说明特征太单调需要补充更多语义特征比如把明月杨柳羌笛这类意象词做成单独维度。4.3 进阶路径BERT微调或复用生成模型表示如果希望情感分析再往前走一步有两条路比较自然。第一条路是用BERT微调模型结构简洁# bert_finetune.py from transformers import BertTokenizer, BertForSequenceClassification tokenizer BertTokenizer.from_pretrained(bert-base-chinese) model BertForSequenceClassification.from_pretrained(bert-base-chinese, num_labels3) texts [春眠不觉晓, 举头望明月] labels torch.tensor([1, 0]) inputs tokenizer(texts, return_tensorspt, paddingTrue, truncationTrue) outputs model(**inputs, labelslabels) loss outputs.loss微调时学习率用2e-5batch size 8-16跑3个epoch。显存紧张可以用albert-chinese。但要注意BERT在古诗上的表现没有现代汉语那么亮眼因为它的预训练语料以白话文为主。第二条路是复用生成LSTM的hidden_state把最后一步隐层接一个线性分类头让情感分析模块和生成模块共享语义表示。这样做能省掉一个预训练模型但对情感分类的提升有限。我的判断标准很简单标注数据超过一万条优先BERT只有两三千条词典逻辑回归已经够用。不要为了技术亮点强行上大模型系统最终看的是稳定性和可解释性。5. 系统集成与避坑排查从模型到可用Demo5.1 用Flask把两个模型封装成统一接口模型本身不能直接给前端用需要包装成HTTP接口。Flask简单、生态好适合课程设计和毕设的轻量系统。先定义一个接口/api/poem接受用户输入的首字或主题词先调用生成模型再对生成结果做情感分析最后返回JSON# app.py from flask import Flask, request, jsonify app Flask(__name__) # 启动时只加载一次模型避免每次请求重复加载 generator_model load_poem_lstm(checkpoint.pth) sentiment_clf load_sentiment_clf(sentiment_model.pkl) app.route(/api/poem, methods[POST]) def poem_api(): data request.get_json() start_char data.get(start, 春) poem generate(generator_model, start_strstart_char, temperature0.8) sentiment, confidence predict_sentiment(sentiment_clf, poem) return jsonify({ poem: poem, sentiment: sentiment, confidence: confidence, start: start_char }) if __name__ __main__: app.run(host0.0.0.0, port5000, debugFalse)注意debugFalse是必须的否则Werkzeug会启动reloader等于加载两份模型不仅慢还可能出奇怪错误。predict_sentiment内部调用extract_features再走clf.predict_proba把最大概率变成置信度返回给前端这样UI可以显示情感倾向正面82%系统观感会专业很多。接口返回结构里加上start字段方便前端追溯用户输入也算是一种防御式接口设计。5.2 四个高频坑现象、原因、解决这些坑是我在这个类型项目里真实踩过的按现象、原因、解决给到你。第一条训练到一半loss变成NaN。现象是几十个epoch后loss突然变NaN生成输出全是啊。原因通常是学习率过高导致LSTM梯度爆炸或者数据里混入未清洗的空字符串进模型后embedding全为零。解决先用梯度裁剪max_norm5.0再把学习率降到3e-4同时回到预处理那一步确认filtered_poems里没有空字符串在make_sequences里用if len(ids) 2: continue做防御。第二条生成结果大量重复。现象是不管输入什么首字模型都在输出春春春或年年年。原因是贪心搜索把概率压在前几个高频字符上或者训练数据里五言七言混杂模型没有学到稳定节奏。解决改用温度采样并加重复惩罚系数数据统一成同一句式。如果问题仍然存在在生成时做连续重复检测连续三个相同字就从概率排名第二的候选中强制采样强制打断循环。第三条情感分析几乎全预测为中性。现象是逻辑回归的三分类结果里超过80%是中性。原因是标签分布本来中性就多且没有设置class_weight或者特征向量太弱模型倾向于输出先验概率大的类别。解决在LogisticRegression里加class_weightbalanced统计正负样本在extract_features上的均值差异如果差异很小则需要扩充情感词典和意象词特征。第四条接口响应时间超过5秒。现象是用户输入一个字后页面长时间无响应。原因是每次请求都重新加载模型或者模型和输入都压在CPU上。解决模型全局加载一次生成时确保输入tensor和模型都在同一个device上。如果只做demo把max_new_chars从30降到20响应能快很多。还有一个容易被忽略的点生成函数里必须用torch.no_grad()否则每次请求都会保存计算图服务跑久了内存持续上涨最后慢到像是死机。5.3 端到端接口自测集成完可以写一个最简单的自测脚本避免每次都要打开前端页面点按钮# test_api.py import requests r requests.post(http://127.0.0.1:5000/api/poem, json{start: 山}) assert r.status_code 200 data r.json() poem data[poem] print(poem, data[sentiment], data[confidence]) assert len(poem) 5这里断言诗长不小于5字符能挡住生成模块崩溃后返回空串的情况。自测脚本放在项目根目录每次改完模型重启服务后跑一遍基本能确认接口没被改坏。如果要模拟多个用户同时请求可以用ab -n 20 -c 5压一下这个Flask服务观察平均响应时间。Flask自带的服务器高并发能力有限如果压测时错误率很高可以先看是不是模型推理占满CPU再决定要不要换gunicorn或把模型换轻量化版本。很多入门项目挂在这最后一步不是模型不好而是接口层没做基础的性能保护。6. 验证与进阶用困惑度、BLEU和人工评测给古诗质量打分训练完模型量化验证是必须的一步否则你分不清loss低和诗读起来通顺是两回事。我用三个层面来验证。第一个层面是困惑度。在留出的验证集上计算交叉熵均值再取指数得到perplexity。验证集困惑度低于80说明模型对古诗用字分布掌握得还可以高于200则大概率数据有问题或模型没收敛。这个指标能快速发现数据泄漏和欠拟合但不能反映意境。第二个层面是BLEU。BLEU在机器翻译评测里常用但在古诗生成上只能粗糙参考因为古诗讲究节律和意象与参考句同字少不代表差我不会把它当硬指标。第三个层面是人工评测也是最关键的。我习惯随机生成20首诗找至少两个人按三个维度打分通顺度、节奏感、情感一致性每项1-5分。下表是简化示例诗号生成文本通顺度节奏情感一致结论01山高月小夜来风444可用02山山山月月流222需修复重复20首里如果平均通顺度低于3分优先回顾数据清洗和MAX_LEN设置如果节奏感普遍低多半是五言七言混训需要重新按句式过滤如果情感一致低可以把情感分析模型接到生成器后面做约束比如在采样时避开会让负面概率突然升高的候选字。这个方向虽然多一层计算但作为进阶功能很有展示价值。再往后可以做押韵匹配在生成末尾句时加入韵部约束让每句最后一个字落在同一韵部这才是古诗生成从能读到像诗的关键一步。我自己的教训是最初做这个系统时只验证了loss和BLEU交给同学试用时一首生成诗被读成绕口令那次翻车后才认真做了人工评测并把结果写进系统文档。以后每次调参都先跑20首人工评测再决定要不要改模型。希望帮到你。本文还有配套的精品资源点击获取