Python实现作文自动评分:篇章结构特征工程与模型构建

发布时间:2026/8/31 18:27:17
Python实现作文自动评分:篇章结构特征工程与模型构建 简介这是一套面向自然语言处理与教育技术方向学习者的Python自动作文评分实践项目聚焦于利用篇章结构特征提升评分准确性适用于高校NLP课程设计、智能评测系统开发入门及教育AI方向研究参考。资源包共136个文件包含19个核心Python脚本含训练、推理与评估模块、29个文本类中间结果文件、15个纯文本配置与日志文件、8张可视化图表png以及多种模型训练中间产物如*.trainlm、*.result等完整覆盖数据预处理、篇章结构建模、特征提取到评分预测的全流程。压缩包体积为5.42MB结构清晰模块解耦明确便于理解各阶段输出逻辑与调试路径。目前已有42人学习下载读者可直接复现基于段落连贯性、逻辑层次与主题一致性等篇章维度的评分模型并获取配套训练语料组织方式、特征统计脚本.count文件及多组实验结果对比分析框架。 上个月帮朋友的小孩改期末作文我发现一个很有意思的现象大部分孩子不是不会用词而是文章根本不知道该怎么搭骨架。有的开头洋洋洒洒写了三百字到正文反而没话说了有的每一段都在讲道理从头到尾看不到一个具体例子还有的段落之间跳得极快上一段还在写教室下一段突然就到了操场中间没有任何过渡。这些问题语文老师一眼就能看出来但要让程序自动判断难度完全不一样。所以我花了两周时间用 Python 做了一版自动作文评分系统。这个项目的核心不是查病句也不是统计好词好句而是把“篇章结构”拆成一组可以计算的特征让机器学会看文章的骨架。这篇博客就把整个项目的思路、技术选型、代码实现和踩坑过程完整记录下来希望能给正在做文本评分、教育技术系统或者对 NLP 应用感兴趣的读者一些参考。项目本身不需要特别深的机器学习基础但要有 Python 数据处理经验至少要熟悉 jieba 分词和 pandas。1. 从“逐句批改”到“全篇体检”自动评分为什么绕不开篇章结构1.1 现有自动评分系统的短板市面上很多英语作文和中文作文评分工具主要依赖语法错误检测、词汇丰富度、句子平均长度、拼写正确率这类基础指标来打分。这种方案对语言基本功的评判是有效的但一到“结构”维度就变得特别敷衍。常见做法是数段落数量或者检查有没有“首先”“然后”“最后”这类连接词稍微精细一点的系统会判断是否有开头段和结尾段但也就到此为止了。问题在于老师眼里的“结构清晰”远不止“分了段落”这么简单。一篇作文段落数量是够的每段也都有中心句但段落之间逻辑混乱、话题反复横跳这种文章如果只靠段落数量评分很容易拿到一个虚高的分数。反过来说有的文章用了比较新颖的写法段落并不规整但内在逻辑是完整的传统规则又很难识别出它的价值。我查了一些自动评分相关的论文和开源项目发现绝大多数方案都在做“词法—句法”层面真正把“篇章结构”作为独立建模目标的很少。原因也不难理解词汇和语法是局部问题可以靠规则和序列标注解决篇章结构是全局问题需要跨段落建模特征工程容易做得又杂又没效果。但如果不解决这个问题自动评分系统就永远只是一个“高级病句检查器”不是真正的“评分系统”。1.2 篇章结构到底包含什么我的定义与拆解为了让“结构”变成可计算的东西我先把老师的评分标准拆成了三个层次。第一层是宏观结构也就是全文骨架。文章有没有开头、主体、结尾是不是“总分总”或者“分总”“总分”这种常见结构结尾是否真的在总结全文而不是突然跑题。这一层解决的是“文章完整不完整”的问题。第二层是中观结构也就是段落之间的关系。各段是不是围绕同一个中心论点展开段与段之间是并列、递进还是转折有没有明显的话题断层。这一层解决的是“逻辑顺不顺”的问题。第三层是微观衔接也就是句子和段落之间靠什么连接。连接词用得是否恰当代词这、其、此是否过多导致指代不清相邻句子的主语是否连续整体读起来有没有“顺滑感”。这一层解决的是“读起来累不累”的问题。基于这个定义我后续的所有特征工程都围绕这三个层次来展开。事实证明这种拆解对后期模型的可解释性非常有帮助因为每输出一个分数系统都能告诉用户具体是哪一项拉低了得分。2. 系统整体设计与技术选型从作文原文到结构分2.1 技术栈与模块分工这个项目采用经典的“预处理—特征提取—建模—服务化”流水线结构。整体技术栈如下模块使用的库负责的工作基础预处理jieba、pkuseg分词、词性标注、停用词过滤语义相似度gensim、sentence-transformers计算段落间、句子间相似度主题分析jieba.analyse、gensim提取段落主题词、全文字段主题结构规则自定义规则库识别开头结尾、连接词分布、指代密度评分模型scikit-learn、LightGBM结构分回归、等级分类服务端Flask / FastAPI封装接口支持批量打分选 Python 很自然因为整个 NLP 生态和数据处理生态都在这里。词法层面用 jieba 就够了不需要上重型预训练模型。句子相似度部分我一开始用了 TF-IDF 加余弦相似度后来换成了 sentence-transformers 预训练模型做备用方案对比后发现两个版本差异不大。考虑到部署成本最终线上版本还是保留了 TF-IDF 版本预训练模型只作为离线分析工具。结构规则库是整个项目里最花时间的部分因为没有现成的中文作文结构规则可以直接搬。连接词要分类整理开头结尾的典型表达要逐个归纳代词指代的统计规则也要反复调。好在这部分代码写起来不难难的是要把语文老师脑子里那些“感觉”翻译成系统能理解的条件。2.2 数据标注结构评分训练集是最大的工程评分模型不能没有标注数据。前期我收集了 850 篇学生作文包含议论文和记叙文来源是学校考试和日常练习已经做了脱敏处理。我请三位语文老师和两位中文系研究生按照结构维度打分打分范围是 1 到 5 分参考标准就按我前面拆出的三个层次来定。为了提高标注一致性每篇作文都由两个标注者独立打分最后取平均。如果两个人分歧超过 1 分就由第三个人仲裁。最终标注结果的 Cohen’s Kappa 是 0.72属于“高度一致”的范围。这个过程大概花了 20 个人天是整个项目里最耗时但最值得的部分。如果一开始没有这么多人力建议先用规则打一个粗糙的初稿分再人工抽样修正哪怕只有 300 篇也好过没有。因为模型在有监督任务里非常依赖标注质量标注标准不统一的话后面再做多少特征工程都很难救回来。3. 篇章结构特征的提取与实现细节3.1 宏观结构开头是否真的在“总起”结尾是否真的在“总结”许多传统系统判断文章有没有“总起段”只看第一段是否超过一定字数或者是否出现了类似“随着……的发展”这样的套话。这种方式很容易被骗。我换了一种思路把全文的核心主题词提取出来然后看第一段涵盖了其中多少比例。主题词用 jieba.analyse.extract_tags 提取。这种方式基于 TF-IDF 统计不需要额外训练模型。开头段如果包含了全文大部分核心主题词说明它确实起到了“总起”作用。同理结尾段是否在总结全文我主要检测两个信号是否有“总之、总而言之、总的来说、因此、综上所述”这类总结词以及结尾段的主题词与全文主题词的重合度。核心代码如下import jieba.analyse # 假设 paragraphs 已经按段落切分好 full_text \n.join(paragraphs) # 全文主题词 full_topics set(jieba.analyse.extract_tags(full_text, topK10)) # 开头段主题词 first_topics set(jieba.analyse.extract_tags(paragraphs[0], topK5)) # 总起覆盖率 cover_start len(first_topics full_topics) / len(full_topics) # 结尾总结词检测 conclusion_markers [总之, 总而言之, 总的来说, 因此, 综上所述, 由此可见] has_conclusion_marker any(marker in paragraphs[-1] for marker in conclusion_markers) # 结尾主题呼应度 last_topics set(jieba.analyse.extract_tags(paragraphs[-1], topK5)) cover_end len(last_topics full_topics) / len(full_topics)这里有个小技巧千万不要把“全文主题词”直接设定为固定的 topK 值因为在短作文里 topK 太大反而会引入噪声。我最后采用的做法是先用全文长度判断大致区间再决定 topK 取 5 还是 10。例如 200 字以下的文章 topK 取 5600 字以上取 15中间线性插值。3.2 中观结构段落主题聚类与逻辑顺序合理性中观结构的关键是判断段落之间是否“谈的是同一个话题”。我一开始用每段的 TF-IDF 向量计算相邻段落余弦相似度后来发现单纯用词向量在很多情况下会把“重复关键词但逻辑断裂”的文章误判成结构合理。改进版加入了主题词覆盖度每段主题词与全文主题词的重合程度。相邻段落相似度计算代码from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity # tokenized_paragraphs 是每个段落已经分好词、以空格拼接的列表 vectorizer TfidfVectorizer(max_features5000) tfidf vectorizer.fit_transform(tokenized_paragraphs) sim_matrix cosine_similarity(tfidf) adjacent_sims [sim_matrix[i][i 1] for i in range(len(tokenized_paragraphs) - 1)] avg_transition sum(adjacent_sims) / len(adjacent_sims) max_jump max(adjacent_sims, default0) jump_var sum((s - avg_transition) ** 2 for s in adjacent_sims) / len(adjacent_sims)其中 avg_transition 代表段落间平均连接强度max_jump 代表最大跳变jump_var 代表相邻段落相似度的方差。这三个特征在后期模型里都很重要。方差大说明文章在几个段落之间频繁切换话题这种文章往往给人一种“乱”的感觉。只靠相邻相似度还不够还需要看“全文主题一致性”。我计算每个段落与全文主题词集合的 Jaccard 系数再求平均。这个值如果过低说明段落各自为政整体中心不突出过高则说明各段都在重复同样的关键词内容缺乏推进。实际判断时前者比后者更致命。3.3 微观衔接连接词、指代密度与话题延续微观衔接层面我建立了一个中文连接词规则库把连接词分成递进、转折、因果、并列、条件等几大类。系统会统计每类连接词出现的次数再除以文章总句数作为归一化密度避免长文章天然拥有更多连接词。连接词密度计算部分import re conjunction_dict { 递进: [而且, 并且, 甚至, 更重要的是], 转折: [但是, 可是, 然而, 不过], 因果: [因为, 所以, 因此, 既然, 于是], 并列: [同时, 此外, 另外, 一方面, 另一方面], 条件: [如果, 只要, 只有, 无论, 除非], } def count_conjunctions(paragraphs, category): pattern |.join(conjunction_dict.get(category, [])) count 0 for text in paragraphs: count len(re.findall(pattern, text)) return count / max(1, len(paragraphs))这个规则库看起来简单但实际整理时踩了不少坑。比如“不过”这个词在作文里有时做转折有时做程度副词“不过如此”如果单纯按词表匹配会误判。我的处理方式是对每个连接词附上一个“排除规则”例如“不过”后面如果跟着“如此”“尔尔”就不算转折连接词。这类细节不处理特征质量会差很多。指代密度方面我统计“这、其、此、该”等指示代词出现的频率。指标太高意味着大量句子依赖代词回指前文可读性差指标太低则可能句子之间完全没有关联。这个维度单独看效果不明显但和其他特征放在一起能起到互补作用。话题延续的计算则用相邻句子主要名词的重叠率。先把句子分词并保留名词然后统计相邻两个句子的名词集合重叠度取全篇平均值。这个值越高表示行文越紧凑但也可能因为反复说同一个事物而显得啰嗦。因此我同时记录它的标准差用于区分“刻意推进”和“原地打转”。3.4 特征汇总与归一化所有特征最终拼成一张表格。我列出项目最终使用的特征列表特征名含义所在层次cover_start开头段主题词覆盖全文比例宏观cover_end结尾段主题词覆盖全文比例宏观has_conclusion_marker结尾是否出现总结词宏观para_count_norm段落数量归一化到全文长度宏观avg_transition相邻段落平均余弦相似度中观max_jump相邻段落最大相似度跳变中观jump_var相邻段落相似度方差中观topic_consistency各段与全文主题词的平均 Jaccard 系数中观conj_density_transition转折连接词密度微观conj_density_cause因果连接词密度微观pronoun_density指示代词密度微观noun_overlap_mean相邻句子名词重叠率均值微观noun_overlap_std相邻句子名词重叠率标准差微观para_len_std段落长度标准差宏观/中观sentence_len_std句子长度标准差微观特征归一化采用 StandardScaler在训练集上拟合验证集和测试集上只做 transform。这一步要特别注意如果先对全量数据做归一化再划分训练集会产生数据泄漏导致后续评估结果虚高。我把所有涉及统计特性的特征都放在训练集内计算均值方差测试集完全不参与。4. 评分模型构建如何让结构特征真正参与打分4.1 特征有效性的相关性检验拿到特征表后不要急着直接训练模型。我习惯先算一遍每个特征与人工结构分的 Spearman 相关系数。这一步能快速发现哪些特征纯属噪声哪些特征存在明显共线性。在我的实验里cover_start、jump_var、conj_density_transition、noun_overlap_mean 这四个特征和人工评分的相关性都超过了 0.3效果比较明显。但 has_conclusion_marker 单独看相关度只有 0.18一开始我几乎要把它删掉后来发现它和 cover_end 组合在一起时效果很好。原因是很多学生虽然写了“总的来说”但结尾段内容和前文完全脱节所以单独看“有没有总结词”没有意义必须结合“结尾是否呼应主题”来一起判断。相关性检验另一个作用是排查特征共线性。如果两个特征 Spearman 相关系数高于 0.85我会选择保留业务解释更清楚的那个。例如“段落长度均值”和“全文长度”相关性非常高最后我只保留了“段落长度标准差”因为它更能反映结构均衡性。4.2 用 LightGBM 训练结构分回归模型模型选型上我一开始尝试了线性回归和随机森林效果只能说一般。线性回归无法捕捉特征之间的非线性关系随机森林虽然能拟合但在小样本上容易过拟合。最终我用了 LightGBM因为它对中小规模数据集非常友好训练速度快而且能输出特征重要性。训练代码import lightgbm as lgb from sklearn.model_selection import train_test_split from sklearn.metrics import cohen_kappa_score X_train, X_test, y_train, y_test train_test_split( feature_df, score_label, test_size0.2, random_state42 ) params { objective: regression, metric: rmse, learning_rate: 0.05, num_leaves: 31, max_depth: 6, min_child_samples: 5, } model lgb.train( params, lgb.Dataset(X_train, labely_train), num_boost_round500, valid_sets[lgb.Dataset(X_test, labely_test)], callbacks[lgb.early_stopping(50)] ) # 特征重要性 importance sorted( zip(feature_df.columns, model.feature_importance(gain)), keylambda x: x[1], reverseTrue )注意LightGBM 的预测值是连续实数不是整数分。为了与人工评分比较我会把预测结果四舍五入到最近的 0.5 分再计算二次加权 Kappa。如果业务上只要求给出等级优、良、中、差可以改成多分类模型但在实际应用中回归模型更灵活因为最后总分计算需要更细粒度的结构分。4.3 规则与模型融合总分怎么算模型输出结构分后总分还需要融合内容分和语言分。我的系统里总评分公式是总评分 内容分 * 0.4 结构分 * 0.3 语言分 * 0.3其中结构分由模型预测内容分和语言分可以由其他模块或人工输入。如果想把所有维度都自动化内容分可以试试用“扣题度素材丰富度”来做语言分则可以用语法正确率和词汇多样性来近似。但这里重点讲结构分融合规则。模型输出的结构分存在一个问题它会把少数明显结构错误平滑掉。比如一篇作文完全没有分段内容挤成一团训练数据里这类样本很少模型预测的结构分可能还是有 3 分。所以我在模型输出后加了几条硬规则如果全文只有一个段落结构分上限设为 2 分。如果没有结尾段结构分扣 0.5 分。如果相邻段落最大跳变超过 0.9扣 0.3 分。规则和模型融合既保留了模型对复杂模式的判断又保证了极端情况不会失控。这种做法的好处是可解释性强上线之后运营人员也能自己调整规则。5. 实际评测这套系统在真实作文样本上的表现5.1 实验设置与评价指标850 篇作文中每篇都有人工结构评分。我按照 7:1:2 划分训练集、验证集和测试集保证同一学生多次提交的作文不会被分到两个集合中。评价指标采用二次加权 Kappa、Pearson 相关系数和 RMSE。其中二次加权 Kappa 在评分任务中最接近业务实际因为它惩罚“差 1 分”比“差 0.5 分”更重而不是简单当作分类错误。5.2 实验结果结构特征的边际贡献为了验证“篇章结构”特征确实有效我做了消融实验结果如下特征组合QWKPearsonRMSE仅基础内容特征0.4710.5031.02加入宏观结构特征0.5630.6210.87加入中观结构特征0.6210.6720.78加入微观衔接特征0.6690.7120.72加入整套篇章结构特征后QWK 从 0.471 提升到 0.669提升幅度接近 0.2。这个提升在评分任务里非常明显。更重要的是RMSE 从 1.02 降到 0.72说明系统对极端文章的误判次数明显减少。如果只看“内容特征”系统很容易被一篇用词华丽但结构混乱的文章骗到高分融合篇章结构后这类文章会被打到合理区间。5.3 案例分析结构分高分和低分的差距在哪我挑测试集里两篇典型作文做了特征对比。第一篇是高分作文结构分人工给了 4.5。它的 cover_start 达到 0.75说明开头段很好覆盖了全文核心词adjacent_sims 的方差是 0.18说明段落之间推进平滑转折连接词密度是 0.12因果连接词密度是 0.09整体连接词使用恰当名词重叠率均值为 0.35说明句间衔接紧密但不死板。第二篇是低分作文结构分人工给了 2.0。它的 cover_start 只有 0.25开头几乎没有提到全文核心概念adjacent_sims 的方差是 0.62段与段之间跳变很大第二段还在讲运动会第三段突然开始讲学习方法整篇转折连接词密度只有 0.02句子几乎没有衔接词读起来像一个个孤立句子的堆叠。从系统输出的诊断报告看第一篇的问题提示是“部分段落之间的语义推进可以更明显”第二篇的提示是“第 2 段与第 3 段衔接突兀缺少必要过渡结尾段未有效总结全文”。这类提示比单一分数有价值得多也是这个项目最终能交付的关键。6. 复现这套系统最容易踩的四个坑6.1 中文分词导致的特征失真中文作文里有大量高频词和口语化表达比如“意思”“觉得”“知道”“东西”。这些词在 TF-IDF 计算里会被赋予很高的权重导致主题词提取出现偏差。比如“我明白了老师的意思”这句话“意思”会被提取成主题词但它对篇章结构完全没有帮助。解决办法是维护一个领域停用词表把“自己、觉得、知道、东西、事情、真的、特别、非常”这类词筛掉。同时针对作文场景我会保留“老师、同学、妈妈、爸爸、校园、生活”这类话题词因为它们能反映文章是否在同一个语义场内。停用词表需要反复迭代每次跑完特征分析后把 top 主题词里明显不合语义的词加进去。6.2 段落切分依赖换行符很多学生作文其实不分段实际拿到手的数据远比想象中脏。手写作文扫描成文本后经常一整段没有换行或者每个句子都换行。如果直接按 \n 切分段落特征计算会完全失真。我在项目里加了一个预处理阶段先检查段落数量如果全文只有一个自然段就根据句子结尾标点和语义转折点进行自动分段。自动分段的启发式方法是把全文按句号、问号、感叹号切开然后计算相邻句子主题词的相似度如果连续 2 个句子与前后主题相似度都很低就在那里断为新段。这个方法不完美但足够让后续的特征计算恢复基本盘。如果数据量大还可以考虑用预训练模型做段落切分但对作文这种场景来说规则方法更可控。6.3 结构特征和内容质量容易互相纠缠有个典型现象一篇作文明显偏题但结构非常工整模型给的预测结构分依然很高。老师评卷时虽然结构分是单独维度但偏题作文的整体印象分会严重影响结构部分。这其实不是结构模型错了而是我们给模型喂的特征里没有“扣题度”这个信号。我在项目末尾新增了一个 feature全文主题词与作文命题主题词的相似度。命题主题词可以人工配置也可以做成关键词抽取。如果这个相似度低于 0.15就对最终总评分做一个降权处理并且在诊断报告里提示“文章可能偏离主题”。这个特征还能联动宏观结构判断比如开头段虽然完整但主题完全跑偏那么“总起覆盖率”再高也没用。6.4 “结构合理”并不等于“老师喜欢”警惕模板化陷阱模型对结构特征的拟合天然会偏向“总分总 每段中心句 連接词密集”这种工整模板。可实际改作文时语文老师反而经常鼓励学生打破套路比如用倒叙、插叙、悬念式开头。如果系统只看结构特征很可能把一篇很有灵气的文章误判成结构混乱。针对这个问题我在模型输出之后加了一个“结构新颖度”惩罚/奖励项如果文章开篇不是直接总起而是用场景描写或对话引入并且结尾能够呼应这个开篇那么系统会给一个小额奖励分而不是教条式地扣分。这个规则很难一概而论但至少提醒系统结构完整不等于八股文结构偏离也不等于混乱。如果你也想做类似系统建议从宏观结构特征开始先让模型学会判断“有没有总起段和总结段”再逐步加入中观和微观特征。特征解释清楚了模型自然能学会。等有一批人工标注数据后再回头调规则和特征权重效果会非常明显。我现在的版本已经能稳定输出结构诊断报告指出第几段和第几段之间的衔接比较突兀哪些地方缺少总结。对语文老师来说这种可解释的提示比一个冷冰冰的分数更有用。本文还有配套的精品资源点击获取