基于篇章结构的中文自动作文评分:Python特征工程与LightGBM实践 简介这是一套面向自然语言处理初学者与教育技术研究者的Python自动作文评分实践项目聚焦于篇章结构特征建模解决传统语法/词汇维度评分局限性问题适用于智能阅卷系统开发、NLP课程设计或教育AI方向课题研究。资源包共136个文件含19个核心Python脚本实现分句、段落关系抽取、逻辑连贯性计算等、29个text格式中间结果文件、15个txt配置与说明文档以及8张可视化分析图png和多个训练/评测结果文件如ADMResult、BGTrainLM等完整覆盖数据预处理、特征工程、模型训练到评分输出全流程压缩包仅5.42MB轻量易部署。目前已有42人学习下载资源结构清晰包含多组对比实验的训练日志与结果文件便于复现实验、调试参数及理解篇章结构特征在评分中的权重分布是深入掌握教育AI中语义结构建模的优质参考样本。 我最初上手自动作文评分时和大多数人一样先堆了一堆内容特征n-gram 频率、句子长度、词汇丰富度、甚至 BERT 语义相似度。结果发现一个很有意思的现象——用这些特征训练的模型很容易被“辞藻华丽但逻辑混乱”的作文骗到高分。后来我把注意力从“写了什么”转向“怎么组织”用 Python 搭了一套基于篇章结构的自动作文评分系统才算把这类误判慢慢压下来。这篇文章不是纯理论介绍而是把我从特征设计、代码实现、模型训练到踩坑复盘的全过程写出来。目标读者是已经会用 Python、对 NLP 有基本了解但还没系统做过作文评分Automatic Essay ScoringAES的人。你会看到一个可落地的技术路线句子向量 篇章结构特征 LightGBM 回归也能看到哪些环节最容易翻车。1. 这个项目到底在解决什么问题1.1 纯内容相似度的盲区为什么“拼凑长句”就能骗过高分在讲篇章结构之前得先搞清楚传统 AES 模型为什么不够。早期做作文评分最常用的基线是什么把作文转成 TF-IDF 向量然后和参考范文做余弦相似度稍微高级一点用预训练语言模型算两篇文本的语义相似度再高级一点统计词汇多样性、句长、语法错误密度。这些特征有一个共同点只看内容和语言的局部表现不看整体组织。结果就是一篇作文如果把几个背好的长句硬拼在一起词性丰富、句长均匀、主题词也覆盖到了机器很容易给高分。但人类阅卷老师一眼就能看出问题段落之间没有推进关系论点之后没有论据结尾和开头完全脱节。这正是“篇章结构”缺失带来的典型现象。我做这个项目的出发点很简单能不能把老师评分时看重的“组织与结构”拆成一组可计算的特征让模型真正看到文章的骨架而不只是血肉。1.2 从“写了什么”到“怎么组织”篇章结构到底指什么很多人一听“篇章结构”就以为是段落数量、总分总格式其实没那么简单。我把它拆成了五个层面。段落功能文章是不是有清晰的开头、主体、结尾正文段落有没有承担“提出论点—展开论证—得出结论”的功能。论证推进段落之间、句子之间有没有逻辑递进关系而不是原地打转。局部连贯相邻句子是否在语义上自然衔接读起来顺不顺。衔接手段是否合理使用因果、转折、递进等逻辑连接词这些词能不能落到前后句的实际语义变化上。主题一致性全文是否围绕核心话题展开有没有段落在“跑题”。这五层大体对应人类评分标准里的“结构维度”。它们不是独立的比如论证推进依赖局部连贯信号衔接手段又要嵌入到语义变化中去判断。后面我在特征实现时会把它们组合成一组向量。1.3 我选择的技术路线特征工程 机器学习而不是端到端看过一些论文AES 最好的结果基本是 BERT 微调端到端模型但实际做项目时我果断放弃了这条路。原因很现实作文标注数据量不大动辄几千篇就算不错微调一个中文 BERT 很容易过拟合更关键的是端到端模型给不出“这篇文章结构差在哪”的解释产品上没法给学生反馈。所以我的技术路线是用预训练中文句子向量模型把每个句子编码成向量再从向量序列和原始文本中提取篇章结构特征最后用 LightGBM 做回归。句子层面的向量表示交给深度模型篇章层面的组合判断交给可解释的统计特征。这样既吃到了语义信息又保住了结构特征的可解释性。整个系统用 Python 实现核心依赖是transformers、sentence-transformers、scikit-learn、LightGBM。这些库都比较成熟没有特别冷门的东西。2. 从原始作文到篇章结构的特征抽取管线2.1 文本预处理请先解决段落边界问题学生交上来的作文格式往往惨不忍睹。有的全文没有换行有的每隔一行就换一段还有的用空格或缩进来模拟分段。如果直接按换行符切分特征就全乱了。我的做法是先做一次“段落再分块”。思路是基于空行分块再合并异常短的行。具体伪代码如下def split_paragraphs(text: str, min_chars20): # 先按空行切 blocks re.split(r\n\s*\n, text.strip()) paras [] buffer for block in blocks: block block.strip() if not block: continue if len(block) min_chars and buffer: buffer block else: if buffer: paras.append(buffer) buffer paras.append(block) if buffer: paras.append(buffer) return paras这里min_chars不是死值我测试过 1530 之间对结果影响不大但 20 左右比较稳。核心逻辑是一个短行单独成段往往是学生打字换行习惯导致合并到上一段会让结构更接近真实段落。这个预处理步骤特别容易被低估。我后面会专门讲段落切错会让所有基于段落位置的特征全部失真所以这步值得花时间调参。2.2 句子切分与句子向量候选段落分好后要切成句子。这里我没有用 NLP 工具库的句子分割模型而是用正则按中文句末标点切。都算句末引号包含进来。为什么不用现成模型因为作文里标点不规范模型也要靠输入文本判断反而可能引入额外的误差规则切分虽然蠢但稳定、可控、可调试。句子向量我选了中文 Sentence-BERT 系列模型。中文里比较好用的是shibing624/text2vec-base-chinese或GanymedeNil/text2vec-large-chinese如果你的显存不大用前者足够了。计算向量时要注意一个关键点模型输出的向量需要做 L2 归一化否则算余弦相似度时会出现“向量越长相似度越高”的假象。2.3 段落结构骨架提取拿到段落和句子向量之后我先把每篇作文的“结构骨架”提取出来。骨架包括每段的首句、末句向量整段向量均值段落数量是否有明显开头段第一段长度/句数是否属于合理范围是否有明显结尾段末段是否包含总结类表述。这些信息本身还不是最终特征但后面所有篇章特征几乎都要用到它们。所以我写了一个EssayStructure类把一篇作文的段落、句子、向量、位置信息都存起来方便后续按需取用。3. 四类核心篇章特征的实现与细节3.1 段落功能与完整性判断“总—分—总”是否成立第一组特征用来回答一个问题这篇文章的整体框架完整吗我做了几个比较实用的统计量。首段的平均句长和句子数量。一般情况下开头段如果超过全文字数的 40%说明入题太慢如果只有一句话说明可能缺少引入。末段是否包含总结词。我维护了一个小型中文总结词表总之、因此、由此可见、综上所述、总的来说、在我看来。末段向量和这些词向量的最大余弦相似度可以作为一个“总结力度”特征。首段与末段的语义相似度。如果首尾高度相似可能存在首尾重复的问题如果完全不相关可能是结尾没有回扣主题。中间段落长度的变异系数。主体段长度差异过大往往代表详略失衡这是结构问题里很典型的一类。代码上大致是这样def structural_completeness(essay): feats {} if not essay.paragraphs: return feats first_para essay.paragraphs[0] last_para essay.paragraphs[-1] feats[first_para_sent_count] len(first_para.sentences) feats[first_para_char_ratio] first_para.char_count / essay.char_count feats[last_para_summary_prob] max_similarity( last_para.vector, summary_word_vectors ) feats[first_last_sim] cosine(first_para.vector, last_para.vector) body_len [len(p.sentences) for p in essay.paragraphs[1:-1]] if len(body_len) 1: feats[body_len_cv] np.std(body_len) / (np.mean(body_len) 1e-6) else: feats[body_len_cv] 0 return feats这段代码看起来简单但“首尾相似度”这个特征在实验里提升非常明显。它抓住了很多学生作文的通病结尾只会把开头换几个字重新抄一遍。3.2 局部连贯性滑动窗口里的语义流篇章结构最核心的信号其实是相邻句子之间的语义流动。我把它做成“连贯性曲线”。具体做法对第 i 个句子和第 i1 个句子计算向量余弦相似度得到一串数值序列。然后从序列里提取几个统计量整体平均值越高代表相邻句子语义衔接越紧密标准差波动太大说明语义跳跃严重最小值出现断崖式下降的地方往往是段落转折或逻辑断裂点低相似度比例相似度低于 0.6 的相邻句对占比。这里有个坑只算均值不够。两篇结构完全不同的作文可能均值差不多但一篇的相似度曲线稳定在 0.7 到 0.85另一篇在 0.5 和 0.95 之间剧烈波动。所以一定要把方差和低谷比例也放进去。def coherence_features(sentence_vectors): sims [] for i in range(len(sentence_vectors) - 1): s1 sentence_vectors[i] s2 sentence_vectors[i 1] sim np.dot(s1, s2) # 已经归一化 sims.append(sim) arr np.array(sims) return { coherence_mean: arr.mean(), coherence_std: arr.std(), coherence_min: arr.min() if len(arr) else 0, coherence_low_ratio: (arr 0.6).mean() if len(arr) else 0, }再提醒一次句子向量必须归一化。如果不归一化长句子的点积天然会偏大短句子的点积天然偏小连贯性曲线就没法跨句子对比了。3.3 衔接手段与逻辑连接词覆盖率衔接特征是篇章结构里最容易解释的一类也是规则性最强的一类。我整理了一份中文逻辑连接词词典按语义类别分好类别示例词因果因为、所以、因此、由于、于是、由此可见转折但是、然而、不过、尽管、虽然递进而且、况且、甚至、更关键的是并列同时、此外、另外、一方面…另一方面条件如果、只有、只要、除非总结总之、综上所述、总的来说、最后每篇作文会统计出这些特征连接词总密度、每类连接词的归一化频次、连接词是否分布在不同段落中、每个主要段落的开头词是否为连接词。注意连接词数量不代表质量。我之前看到一篇作文用了十多个“但是”前后文完全没有任何转折语义但单纯看连接词密度它反而得分很高。这促使我加了一个“连接词实际有效性”特征连接词所在句和下一句的语义相似度变化量。如果连接词后面跟的内容和前面几乎一样这个连接词再高频也是无效的。def connectives_features(essay): total 0 category_hits {cat: 0 for cat in connective_dict} for sent in essay.sentences: for cat, words in connective_dict.items(): if any(w in sent.text for w in words): category_hits[cat] 1 total 1 feats {fconn_{cat}: v / max(1, essay.sent_count) for cat, v in category_hits.items()} feats[conn_density] total / max(1, essay.sent_count) return feats3.4 主题一致性从正文反推题目语义距离最后一类特征解决的是“跑题”问题。严格来说主题一致性不完全算篇章结构但它和结构密切相关因为跑题往往是从某一段开始越写越远。我做了两个层面的实现。第一层如果有题目文本就计算题目向量和全文向量的余弦相似度再计算每个段落向量和题目向量的相似度看是否有段落明显偏离。第二层如果只有作文没有题目就用 TextRank 从全文中抽取关键词构造一个“伪题目向量”然后计算每个段落向量和这个向量的距离。这里要注意关键词向量要用同一个句子向量模型来编码否则语义空间对不上。主题一致性特征里最有用的是“段落离群度”计算每段向量与全文其他段向量的平均余弦相似度然后取最低值或者取方差。如果某一段突然和全文平均语义距离很远往往就是跑题段。def topical_coherence(essay): para_vecs [p.vector for p in essay.paragraphs] full_vec np.mean(para_vecs, axis0) full_vec full_vec / (np.linalg.norm(full_vec) 1e-6) sims [np.dot(pv, full_vec) for pv in para_vecs] return { topic_mean_sim: np.mean(sims), topic_min_sim: np.min(sims), topic_std_sim: np.std(sims), }实际结果显示topic_min_sim比topic_mean_sim更能捕捉跑题情况。因为大部分段落正常、只有一段跑题时均值下降不多但最小值会非常刺眼。4. 评分模型从特征到分数的最后一公里4.1 为什么用回归而不是分类作文评分通常是一个 06 或者 010 的离散量表。很多人一开始会做成多分类但这里有个问题分类模型把分数当成无序类别丢失了“4 分比 3 分更接近 5 分”的顺序信息。用回归就自然得多输出一个连续值再四舍五入到最近整数。评价指标我有两个一个是 RMSE简单直观另一个是 Quadratic Weighted KappaQWK这是 AES 领域最主流的指标用来衡量预测分档和真实分档之间的一致性。QWK 的核心思想是预测和真实差 1 分比差 2 分可以接受因此按距离加权惩罚。指标含义好模型表现RMSE预测分数与真实分数的均方根误差越低越好QWK预测档位与真实档位的加权一致系数越接近 1 越好4.2 特征表与归一化我把所有特征分成五组方便做消融实验内容组TF-IDF 余弦相似度、TextRank 关键词相似度、平均句长结构组段落数、首尾相似度、主题一致性连贯组相邻句相似度均值/方差/低谷比例衔接组各连接词密度、无效连接词比例语言质量组词汇多样性、平均句子长度、标点使用情况。特征数量加起来大概 50 个左右。树模型LightGBM不需要归一化但我后来加了线性回归做对比基线线性模型必须做归一化。我用的方式是QuantileTransformer它对离群点更鲁棒比 StandardScaler 要稳。4.3 模型选择与训练流程我从三个模型里选线性回归、随机森林、LightGBM。线性回归用来做可解释性基线随机森林和 LightGBM 对比非线性拟合能力。最终我用的是 LightGBM原因很简单训练快、支持缺失值、调参空间大。训练流程里最重要的一步是交叉验证策略。作文数据有一个天然问题同一个学生可能写了多篇作文如果随机划分训练集和测试集同一学生的作文可能同时出现在两边模型会记住学生风格导致 QWK 虚高。所以必须按学生 ID 分组使用GroupKFold做交叉验证。from lightgbm import LGBMRegressor from sklearn.model_selection import GroupKFold gkf GroupKFold(n_splits5) for train_idx, val_idx in gkf.split(X, y, groupsstudent_ids): train_X, val_X X[train_idx], X[val_idx] train_y, val_y y[train_idx], y[val_idx] model LGBMRegressor(n_estimators300, learning_rate0.05) model.fit(train_X, train_y)模型调参上我没有做很重的搜索重点放在n_estimators、learning_rate、num_leaves三个参数上。用小学习率加 300 棵树比大学习率加 100 棵树稳定很多尤其是特征数量多的时候。5. 实验结果、误差分析与真实教训5.1 效果提升与指标对比在我自己整理的一份中文作文数据集上约 1200 篇人工按 06 分打分只使用内容特征和语言质量特征的 LightGBMQWK 大概是 0.62。加入结构和连贯特征之后QWK 提升到 0.69。再加入主题一致性和衔接有效性特征最后稳定在 0.72 左右。特征组合QWKRMSE内容 语言质量0.620.85加入段落结构0.660.79再加入连贯 衔接0.690.75全部特征0.720.72从消融结果来看提升最大的是连贯特征那一组尤其是相邻句相似度方差和低谷比例它们直接改变了模型对“堆砌句”的容忍度。5.2 典型误判样本拆解即便整体指标不错错误样本仍然很值得看。第一类误判结构完整但内容空洞模型容易给高分。这类作文段落功能齐全连接词也正常但每个段落都没有实质信息。内容特征能拉回一点分但结构特征的权重太大时还是会被高估。这提醒我篇章结构特征应该作为内容特征的补充而不是完全替代。第二类误判情感真挚但结构松散被模型压分。学生写记叙文时经常不按“总分总”来开头直接进入叙事结尾也不总结。结构特征会给得很低但人审会觉得情感充实应该在“结构”和“语言”上平衡。现阶段我只能接受这种偏差后续计划专门训练记叙文和议论文两类打分模型。第三类误判使用大量漂亮句子但逻辑断裂修正后效果显著。加了连贯特征后这类作文的分数普遍下降了 0.5 到 1 分和人工评分越来越接近。这也是我坚持做这个项目最直接的动力。5.3 我踩过的三个坑踩坑一段落切分错误让所有结构特征失真。最开始我没有做段落合并直接用换行符切分结果很多学生把“第一第二第三”写在不同行被切成三个极短段落首尾相似度、段间长度变异系数全部扭曲。后来强制做短行合并特征质量立刻提升。踩坑二句子向量没有归一化。有一版实验长句子的点积天然偏大导致“长句多的作文”连贯性得分虚高。发现问题后我把所有句子向量做了 L2 归一化连贯性的区分度才恢复正常。踩坑三数据泄漏导致 QWK 虚高。最开始时我用随机 KFoldQWK 到了 0.78但换上 GroupKFold 后立刻掉到 0.72。虚高不是模型更强而是模型偷看了同一个学生的写作习惯。这个教训对做教育类 AI 项目非常重要尤其当数据按学生收集时分组交叉验证不是可选项是必须项。6. 扩展方向和部署建议6.1 从篇章评分到写作能力画像这套篇章结构特征还有一个额外的好处可以做分维度的能力诊断。结构完整性、连贯性、衔接有效性、主题一致性分别都有对应的特征子集所以我不只给一个总分还能告诉学生“你这篇文章的主要问题是段落间衔接不足”或者“某一段明显跑题”。我设想的产品形态是前端上传作文后端返回总分和四个分维度雷达图每个维度对应一条可解释的原因。这对教育场景很实用老师不需要自己去读一遍就大概知道问题在哪。6.2 线上化要点线上部署我用 FastAPI 封装。预处理、切句和特征提取这步比较耗时尤其是对一篇 800 字作文计算 40 多个句子向量一张普通 GPU 上大概要 0.5 秒左右。我做了两个优化一是把句子向量模型用ONNX Runtime转换CPU 上也能跑到毫秒级二是对同一道题目下经常出现的段落和句向量做缓存重复提交的作文直接走缓存。另一个部署细节是打分稳定性。同一个学生反复提交微调后的作文分数不应该剧烈波动。我在预测分数时用五个随机种子的 LightGBM 平均输出作为最终分方差小了很多。这个技巧很简单但非常实用。最后再分享一条我从这个项目里得到的体会别一上来就追求端到端深度模型。作文评分的价值不仅在于分数准确更在于能解释为什么是这么多分。用 Python 把篇章结构拆成显式特征虽然看起来“传统”但它让你真正理解写作评分中的结构维度到底在衡量什么。等你把这个思路跑通了再去叠加神经网络也不迟。本文还有配套的精品资源点击获取

相关新闻

最新新闻

C# Socket通信框架实战:粘包处理、心跳与断线重连方案

C# Socket通信框架实战:粘包处理、心跳与断线重连方案

简介:这是一套面向C#网络编程初学者与中级开发者的Socket通信实战项目,聚焦解决工业级通信中常见的心跳保活、断线自动重连、粘包拆包、异步收发及多客户端并发管理等核心问题。资源包含WinForm客户端、WinForm服务端及高度解耦的Socket功能类库&#xf…

2026/8/31 17:05:33
gprMax探地雷达模拟实战:从2D到3D空洞检测建模全流程

gprMax探地雷达模拟实战:从2D到3D空洞检测建模全流程

简介:本资源是一套面向地质探测、考古勘察与基础设施无损检测领域的GPR仿真教学资料包,专为科研人员、工程技术人员及高校学生设计,解决地面穿透雷达建模难、参数设置不直观、结果解读门槛高等实际问题。压缩包共133个文件,67.62M…

2026/8/31 17:05:33
形态学处理与连通域分析:基于Matlab的硬币计数方案

形态学处理与连通域分析:基于Matlab的硬币计数方案

简介:本资源是一套面向本科及硕士阶段图像处理教学与实践的硬币计数实验方案,基于MATLAB形态学图像处理技术实现自动计数,适用于数字图像处理、计算机视觉等课程的算法验证与项目实训。压缩包共4个文件(386KB)&#xf…

2026/8/31 17:05:33
MATLAB船舶运动仿真:从横摇建模到RAO分析全攻略

MATLAB船舶运动仿真:从横摇建模到RAO分析全攻略

简介:本资源是一套面向船舶与海洋工程领域研究者及高年级本科生的MATLAB海上运动仿真实践包,聚焦船舶六自由度动力学建模、非线性响应预测与控制策略验证等核心问题。压缩包共8个文件,含5个Simulink模型(.mdl)——涵盖…

2026/8/31 17:05:33
Delphi工业上位机开发:dOPC Client Toolkit构建OPC客户端实践

Delphi工业上位机开发:dOPC Client Toolkit构建OPC客户端实践

简介:本资源是面向工业自动化与过程控制领域Delphi开发者的专业OPC客户端工具包,专为Delphi 6至Delphi 12 Athens版本设计,解决Windows平台下与各类OPC服务器(DA、UA、HDA、XML-DA等)高效通信的开发难题。资源包共1337…

2026/8/31 17:05:33
用一张图和一段音频生成数字人口播视频:Ace Data Cloud Dreamina API 接入指南

用一张图和一段音频生成数字人口播视频:Ace Data Cloud Dreamina API 接入指南

用一张图和一段音频生成数字人口播视频:Ace Data Cloud Dreamina API 接入指南 AI 视频正在从“好看的演示”走向“可集成的生产工具”。对很多团队来说,真正有价值的不是偶尔生成一条视频,而是把视频能力接入到自己的业务系统里&#xff1a…

2026/8/31 17:00:33