简介:一套基于Python篇章结构自动作文评分系统的完整毕业设计项目,面向计算机相关专业学生、教师及需要快速搭建评分系统的开发者,可直接用于毕设、课设、作业或项目初期演示,也是学习自然语言处理与文本分析的良好实战样例。包体共114个文件,压缩后约2MB,以Python源码(.py)、文本与数据集(txt/text/count)、图表(png)、配置(xml)及表格(xlsx)为主,目录明确区分训练模块与结果模块,便于按需检索与二次开发。整体设计覆盖篇章结构分析与自动评分流程,包含可运行的代码、配套数据集和详细说明文档,从数据预处理、模型训练到效果评估均有对应实现,可直接运行验证,也可以此为基础扩展新功能、优化评分逻辑,为作文自动批改类项目提供完整参考。已有359人学习下载,适合需要参考完整方案、快速上手或完成实践作业的读者。
1. 篇章结构自动作文评分:为什么只看用词还不够
期末作文批改场景里,一套只依赖词频和句法特征的打分器,会在“表面漂亮但段落堆砌”的文章上给出虚高分数:开头、论据、结尾混在一段里,它照样判高分。篇章结构自动作文评分要补的,正是这一层“骨架”感知——把作文里的开头引入、观点展开、段落衔接、收束方式转成可计算的结构指标,再与内容特征一起决定最终得分。做这类系统的人通常是两条路:一是NLP方向的毕业设计,想找完整、好演示、能讲清创新点的文本任务;二是教育产品里做自动批改的工程师,想给评分器加上结构维度。这个标题背后有一套能落到 Python 里的完整方案:数据集建设、篇章结构特征抽取、回归模型训练、一致性评估,以及答辩或产品演示时最需要的结果解释。
2. 篇章结构评分的数据基础:公开数据集、标注口径与预处理
2.1 数据集怎么选:先找开源基准,再补自建语料
自动作文评分任务里,英文公开数据比中文规范得多。常见做法是先用英文基准语料把整个流程跑通,再迁移到中文自建语料上做实验。这个任务常用的不是 iris 那种拿来即用的玩具数据,评分标签要自己定义粒度。
| 数据来源 | 语种 | 规模 | 评分形式 | 获取难度 |
|---|---|---|---|---|
| ASAP(Automated Student Assessment Prize) | 英文 | 8 个命题,约 1.3 万篇 | 整体分 1~6 或 1~4 等 | 需注册申请,注意使用许可 |
| 自建中文作文语料 | 中文 | 500~1000 篇即可起步 | 结构分 1~5 + 整体分 1~5 | 需要人工标注并校验一致性 |
ASAP 是很多复现实验的首选,8 个命题就是 8 个独立评分任务,每个命题样本量从几百篇到一千多篇不等,正好对应毕业设计和中小规模工程验证。中文语料没有同等影响力的公开版本,常见做法是把往届作文、题库范文去标识化后自建,篇幅不必贪多,500 篇左右配合树模型就足以训练出可用的结构评分器。
数据量不大并不致命,因为篇章结构特征通常控制在 30 维以内,特征纬度不高,XGBoost 在这种小样本上反而比深度模型稳定。真正决定系统上限的是“结构分”标得是否一致,这比样本总量更影响最终 QWK。
2.2 结构分标注口径:比总分更细一把尺
自动作文评分通常有两个目标:一个是为最终展示服务的整体分,一个是体现选题价值的结构分。我一般让模型把结构分作为核心回归目标,整体分作为辅助验证。最终给用户的分可以设计成“0.7×结构分 + 0.3×内容分”这种可解释的线性合成,答辩时老师一眼就能看懂。
结构分的标注规范要写进文档。5 分制里可以这样定:
- 5 分:开头引入清晰,正文有明确的论点句与论据句分区,段间有过渡,结尾收束;
- 3 分:段落可读,但论据与观点、例子夹杂,部分段落功能不清;
- 1 分:无明显引言或结论,段落之间跳跃,论点反复出现。
给标注员的表格保持简单,一份 CSV 包含 essay_id、essay_text、structure_score、holistic_score 四列即可。为了让标注可用,至少两个人标同一批数据,用 Cohen's Kappa 计算一致性,在 0.6 以上再进入训练。低于这个值时,问题通常出在“结构分”的定义没有落到句子与段落级别,需要回到标注规范里补充示例。
2.3 预处理与数据泄漏:句子切分和统计量拟合要小心
预处理里最容易犯的错是直接拿原始作文做切分。考号、题目说明、邮箱行都是噪声,先用正则清理干净。句子切分要为中文、英文混杂做准备,re.split(r"[。!?!?]", text)能覆盖中英文句号,但不处理缩写,比如英文 “U.S.A.” 会被切断。对自动评分这种粗粒度任务,简单切分可接受,但要在文档里注明这一限制。
数据泄漏在结构评分里很隐蔽。如果特征抽取阶段用全量语料计算 IDF、停用词表或归一化统计量,再切训练集和测试集,验证集已经“看过”训练分布,QWK 会虚高。正确顺序是先划分集合,再在训练折内部 fit 向量化器与标准化器。下面是最小可用的清洗流程:
import re def clean_essay(raw: str, meta_prefix: str = "") -> str: text = raw.replace("\r\n", "\n") if meta_prefix: text = text.replace(meta_prefix, "") text = re.sub(r"\n{2,}", "\n", text) return text.strip() def split_sentences(text: str) -> list[str]: parts = re.split(r"(?<=[。!?!?])", text) return [p.strip() for p in parts if len(p.strip()) >= 2] def load_essay(raw: str) -> dict: text = clean_essay(raw) paras = [p.strip() for p in text.split("\n") if len(p.strip()) > 0] sentences = [] for para in paras: sentences.extend(split_sentences(para)) return {"paras": paras, "sentences": sentences}load_essay返回段落和句子两份结构,后面特征抽取直接复用。注意split_sentences用零宽断言实现“看到句号后切分但不吞掉句号”,避免句号丢失影响句长计算。如果你后续接 BERT 之类的预训练模型,这里建议换成 spaCy 或 LTP 的句子边界识别,不再用正则。
3. 用 Python 抽取篇章结构特征,把作文拆成可计算指标
3.1 三个层次:衔接、段落布局、全文论证骨架
篇章结构特征的设计必须和人工批改的思考路径一致。我习惯从三个层次组织特征。
第一层是局部衔接,看相邻句子之间的主题词是否有重叠。纯堆砌句子的作文往往词汇重复度很高,漂亮的议论文则会在重复中有推进,所以单纯看相似度还不够,还要结合句长方差。第二层是段落布局,重点看每段首句是否承担中心句功能,段落之间主题是否明显切换。第三层是全文论证骨架,识别引言段、论证段、结论段是否存在,统计以过渡词开头的句子比例。
选特征的原则是“可解释优先”。树模型可以从 30 个手工特征里给出特征重要度,答辩时可以输出图表,说清楚“这篇作文结构分低,是因为 transition_start_ratio 和相邻句相似度同时偏低”。这个优势是 BERT embedding 给不了的。模型精度不够时再往特征里补编码特征,而不是直接换黑盒。
3.2 最小可复现的特征抽取代码
先动手写出三个基础特征,全部基于标准库和 numpy,不依赖大模型:
import re import numpy as np TRANSITIONS = [ "首先", "其次", "最后", "然而", "但是", "因此", "所以", "此外", "更重要的是", "总而言之", "综上所述" ] def paragraph_features(text: str) -> dict: paras = [p.strip() for p in text.split("\n") if len(p.strip()) > 0] sents = [] for p in paras: sents += [s.strip() for s in re.split(r"[。!?!?]", p) if len(s.strip()) > 0] sent_lens = np.array([len(s) for s in sents]) return { "n_paras": len(paras), "n_sents": len(sents), "avg_sent_len": float(sent_lens.mean()) if len(sent_lens) else 0.0, "std_sent_len": float(sent_lens.std()) if len(sent_lens) else 0.0, } def transition_features(text: str) -> dict: sents = [s.strip() for s in re.split(r"[。!?!?]", text) if len(s.strip()) > 0] hit = sum(1 for s in sents if any(s.startswith(t) for t in TRANSITIONS)) return {"transition_start_ratio": hit / max(len(sents), 1)} def local_connectivity(text: str) -> dict: sents = [s.strip() for s in re.split(r"[。!?!?]", text) if len(s.strip()) > 0] wordsets = [set(re.findall(r"[\u4e00-\u9fa5A-Za-z]{2,}", s)) for s in sents] sims = [] for a, b in zip(wordsets[:-1], wordsets[1:]): if len(a | b) == 0: sims.append(0.0) else: sims.append(len(a & b) / len(a | b)) return {"mean_jaccard_conn": float(np.mean(sims)) if sims else 0.0} def extract_all_features(text: str) -> dict: features = {} features.update(paragraph_features(text)) features.update(transition_features(text)) features.update(local_connectivity(text)) return featuresparagraph_features里std_sent_len是特征工程里很容易被忽略但很关键的一个向量。高分段作文的句长通常有节奏变化,长句与短句交替;低分作文容易连续使用同样长度的简单句。transition_start_ratio统计“以过渡词开头”而不是“包含过渡词”,是为了捕捉显式衔接逻辑,避免把正文中间出现的“因此”也算成结构转折。local_connectivity使用相邻句的词汇重合率,实现成本低、可解释强,缺点是会把反复车轱辘话的段落误判为高连贯,所以后续要配合句长方差一并使用。
3.3 特征表与维度控制
把特征固定下来之后,用一张表记录计算方式,避免训练和推理时特征顺序不一致:
| 特征名 | 计算方式 | 在结构评分中的含义 |
|---|---|---|
| n_paras / n_sents | 段落数 / 句子数 | 控制篇幅对分数的干扰 |
| avg_sent_len / std_sent_len | 句长均值 / 标准差 | 句子节奏与控制力 |
| transition_start_ratio | 过渡词开头的句子占比 | 显式衔接是否充足 |
| mean_jaccard_conn | 相邻句词集合 Jaccard 均值 | 局部主题延续性 |
| topic_signal | 段首句与后文相似度(可选扩展) | 段落中心句是否存在 |
特征总数保持在 20~40 维。树模型不需要做标准化,但如果后续把特征拼进神经网络,要用sklearn.preprocessing.StandardScaler,并且只对训练折 fit。先跑通上面的最小特征集,看 QWK 达到基线后再逐步加 topic_signal、段落位置编码等扩展特征,不要一开始就堆到上百维。
4. 篇章结构评分模型怎么训:XGBoost、QWK 与调参陷阱
4.1 为什么不直接微调 BERT
很多初做这个题目的人会直接拿预训练模型微调做回归。在小样本作文评分任务上,这个方案有两个问题:一是 500~1000 篇的规模很容易过拟合,验证 QWK 反复震荡;二是输出不可解释,老师提出“为什么这篇结构分只有 2 分”时无法回答。结构评分更稳的做法是双阶段:先用预训练模型把每句话编码成向量,再和手工篇章结构特征拼接,输入 XGBoost 回归。这样既保留语义信息,又让树模型能直接利用过渡词比例、段落数这些可解释特征。
4.2 训练脚本与参数区间
假设你已经有了X(DataFrame,列名与特征抽取函数返回一致)和y(结构分 1~5 的连续值),最小训练代码如下:
import pandas as pd from sklearn.model_selection import train_test_split import xgboost as xgb X_train, X_val, y_train, y_val = train_test_split( X, y, test_size=0.2, random_state=42 ) model = xgb.XGBRegressor( n_estimators=1000, learning_rate=0.03, max_depth=5, min_child_weight=2, subsample=0.8, colsample_bytree=0.8, reg_alpha=0.2, reg_lambda=1.0, eval_metric="mae", random_state=42, ) model.fit( X_train, y_train, eval_set=[(X_train, y_train), (X_val, y_val)], verbose=100, )评分任务里连续回归比分类更合理,因为 3.4 分和 3.6 分本质上是同一水平,强行分类会丢失排序信息。eval_metric="mae"在训练过程中看绝对误差,能反映预测值和真实分的平均差距;如果想要更直观地反映评分一致性,可以在早停回调里计算 QWK,而不是只看 MAE。
这里没有直接用stratify,因为结构分通常集中在 2~4 分,pd.qcut处理连续标签时容易因重复边界报错。如果你必须保证训练集和验证集分数分布一致,建议先把分数离散化成分箱,再按分箱列做分层划分。
参数区间是个经验值,不用照抄:
| 参数 | 推荐区间 | 说明 |
|---|---|---|
| max_depth | 4~6 | 特征只有几十维,深树只会记住噪声 |
| learning_rate | 0.02~0.05 | 配合 1000 棵树,防止早停前震荡 |
| min_child_weight | 2~4 | 抑制少数高分样本被过度放大 |
| subsample / colsample_bytree | 0.7~0.8 | 增加行与列的随机性,提升泛化 |
| reg_alpha / reg_lambda | 0.1~0.3 / 1.0 | L1/L2 正则,低分样本少时更稳 |
| n_estimators | 800~1200 | 配合早停,树越多不代表越好 |
4.3 QWK 量化评分一致性
自动作文评分领域通常不用准确率,而用二次加权 Kappa 衡量两个评分者的一致性。它和线性加权 Kappa 的区别在于:预测 3 分而真实 4 分,与预测 3 分而真实 1 分,前者惩罚远小于后者。这与作文评分的直觉一致。
import numpy as np def qwk(y_true, y_pred, low=1, high=6): y_true = np.clip(np.round(y_true), low, high).astype(int) y_pred = np.clip(np.round(y_pred), low, high).astype(int) n = high - low + 1 o = np.zeros((n, n)) for t, p in zip(y_true, y_pred): o[t - low, p - low] += 1 expected = np.outer(o.sum(axis=1), o.sum(axis=0)) / o.sum() w = np.fromfunction(lambda i, j: (i - j) ** 2 / (n - 1) ** 2, (n, n)) return 1 - (w * o).sum() / (w * expected).sum()使用前先对预测值做clip和round,把连续回归输出限制在评分区间内。o是真实分与预测分的混淆矩阵,expected是两个评分者在完全独立打分时最可能出现的联合分布,加权矩阵w对对角线之外的偏差做二次惩罚。QWK 约等于 0 说明和随机打分差不多,0.6~0.8 是作文评分任务里比较常见的可接受区间。
4.4 两个会影响论文结论的坑
第一个坑是整语料统计量泄漏。如果在拼接特征前用全量文本计算了 TF-IDF 或停用词表,训练和验证数据就已经交叉。检查方法是把特征抽取代码放在train_test_split之后重跑一遍,对比 QWK 是否明显下降。下降超过 0.05 就要反思预处理流程。
第二个坑是模型预测全挤在均值附近。低分和高分样本占比少,回归器容易输出 3.1、3.2 这种安全值。查看预测分布如果几乎看不到 4.5 分以上,先删除n_sents、n_paras这些与篇幅强相关的特征,再看 influence factor。如果仍然收敛,尝试把结构分离散成 5 档,用XGBClassifier配合排序损失做五分类,再映射回连续分。
5. 从能跑到高分:篇章结构评分的段落级解释与答辩演示
系统能跑通只算完成一半,另一半是让老师或工程师信任你的结果。篇章结构评分比一般文本分类更适合做段落级解释,因为过渡词、句长、局部连接度这些特征本身可以落到具体段落上。
对于展示型需求,我通常做一版“段落结构分”近似归因:把每个段落当成独立文本,套用同一个特征抽取函数得到该段的结构特征,再用训练好的模型预测一个段落级别的结构分。注意这是演示级近似,不是严格 Shapley 归因,但用于答辩和产品演示已经足够直观。
import pandas as pd def explain_by_paragraph(text: str, model, extractor) -> pd.DataFrame: paras = [p.strip() for p in text.split("\n") if len(p.strip()) > 0] rows = [] for idx, para in enumerate(paras, 1): feat = extractor(para) feat["paragraph_order"] = idx / len(paras) rows.append(feat) df = pd.DataFrame(rows) df["estimated_structure_score"] = model.predict(df) return df.groupby("paragraph_order", as_index=False)["estimated_structure_score"].mean()这段代码在输入范文时能立刻暴露问题段落。比如全文结构分 3.5,但第二段单独预测只有 2.1,说明该段缺少过渡词或中心句不明显。paragraph_order是段落位置归一化,用于补偿首段和末段天然比中段更具结构特征这一偏差。
如果想要更正规的单篇特征归因,用 SHAP 解释树模型输出:
import shap explainer = shap.TreeExplainer(model) shap_values = explainer.shap_values(X_val.iloc[[0]]) for name, value in zip(X_val.columns, shap_values[0]): print(f"{name}: {value:+.3f}")单篇解释能直接回答“为什么这篇作文结构分只有 2 分”这类问题,因为在样本里transition_start_ratio的贡献是 -0.4,std_sent_len的贡献是 -0.2。答辩或产品沟通时,这个输出比一个孤零零的分值可信得多。
验证结构特征是否起作用,最干净的实验是特征消融。先拿纯 TF-IDF 词汇特征训练一套评分器,再叠加篇章结构特征训练另一套,分别计算验证 QWK:
| 特征组 | 验证 QWK(示意值) |
|---|---|
| 纯 TF-IDF + 句长统计 | 约 0.70 |
| 纯 TF-IDF + 全部篇章结构特征 | 约 0.78 |
上面是示意数据,不代表特定语料一定会复现这个差,但差异方向在大多数作文评分任务里是一致的。如果你的实验里结构特征没有拉开差距,优先检查标注一致性而不是模型参数。
最后留一个我常用的验收技巧:从训练集里抽 10 篇结构分 5 分的正例和 10 篇结构分 1 分的负例,对每篇跑一遍explain_by_paragraph。如果正例的段落结构分表里出现了 2.5 分以下的段落,说明标注或特征定义里有矛盾,顺着这个段落反查原始作文,往往会发现标注员把议论文误标成了说明文,或者预处理时段落合并掉了。把这一步跑完,这份“高分毕业设计”才真正经得起追问。
本文还有配套的精品资源,点击获取