
简介这份资源是面向K-12教育场景的Python自动作文评分系统实现包适合NLP入门学习者、教育技术开发者及需要批量评分的教师参考。系统围绕篇章结构展开涵盖词法分析、语法与语义分析、段落连贯性识别、特征工程以及SVM、随机森林、神经网络等评分模型训练并配有预处理、后处理与评估优化流程可帮助读者理解从文本解析到分数预测的完整链路。压缩包共112个文件约2.04MB以py脚本、text与txt语料、count统计文件为主辅以png图表、xml配置、xlsx数据表及多种训练与结果文件便于对照实验数据复现评分流程。目前已有265人学习下载适合作为课程设计或毕业项目的参考方案从中可获取特征提取思路、模型训练脚本与评分结果样例快速搭建可运行的作文评分原型。1. 篇章结构自动作文评分从一篇作文的骨架说起改过几百份作文的老师都有一个共识一篇作文能不能拿高分往往看前三段就八九不离十了。开头有没有立论、中间有没有分层展开、结尾有没有收束这些骨架层面的东西比某个句子写得漂不漂亮更能决定分数档位。python基于篇章结构自动作文评分系统要解决的正是把这种看骨架的经验翻译成程序能算的指标。它适合三类人一是做教育类工具、想给作文批改加自动打分能力的开发者二是手里有一批学生作文、想做批量分析的研究者三是想拿一个完整 NLP 项目练手的 python 学习者。核心思路不复杂——把作文切成段落和句子提取结构特征段落数、段长分布、主题句位置、段落间语义连贯度等再喂给一个回归或分类模型去预测分数。相比只堆词频、句长的表面特征方案篇章结构特征更贴近人工评分的真实依据也更抗堆砌辞藻的作弊写法。这一章先把这件事讲清楚后面几章带你从零跑通。2. 篇章结构特征到底怎么算从切分到向量化2.1 为什么结构特征比词袋特征更靠谱很多人做自动作文评分第一反应是上 TF-IDF 加线性回归把整篇作文当成一袋词。这条路能跑通但有两个硬伤一是它完全丢掉了顺序和层次一篇把论点全堆在结尾的作文和一篇结构匀称的作文词袋表示几乎一样二是它对字数多就分高有强偏好模型学到的其实是长度而非质量。篇章结构特征换了个视角。人工评分时老师脑子里其实在跑一套隐性的结构检查有没有明确的开头段主体是不是分了几个层次每段有没有一个中心句段落之间是并列、递进还是转折结尾有没有回应开头把这些拆成可计算的量就是结构特征。常见做法是先把作文切成段落再把段落切成句子然后在段落和句子两个粒度上算统计量和语义量。我一般会算这几类段落层面的段落数、各段句子数、段长方差衡量结构是否匀称句子层面的平均句长、句长方差语义层面的相邻段落相似度衡量连贯性、首段与末段相似度衡量首尾呼应、每段首句与段内其余句子的相似度衡量中心句是否突出。这些量加起来二三十维已经能覆盖大部分结构信息。2.2 用 python 把一篇作文切成段落和句子中文作文的切分比英文麻烦标点和换行都不规范。下面这段代码是我常用的最小切分逻辑先按换行切段再按中文句末标点切句同时做基本清洗。import re def split_paragraphs(text): # 先统一换行符再按空行或单换行切段 text text.replace(\r\n, \n).replace(\r, \n) # 连续空白行视为段落分隔 paras re.split(r\n\s*\n|\n, text) # 去掉空段和纯空白段 paras [p.strip() for p in paras if p.strip()] return paras def split_sentences(para): # 中文句末标点。以及英文对应符号 # 用零宽断言保留标点避免切完丢符号 sents re.split(r(?[。!?;]), para) sents [s.strip() for s in sents if s.strip()] return sents if __name__ __main__: sample 春天来了。\n\n小草绿了花儿开了\n这是我最喜欢的季节。 for i, p in enumerate(split_paragraphs(sample)): print(f段落{i}: {p}) for j, s in enumerate(split_sentences(p)): print(f 句子{j}: {s})逻辑说明split_paragraphs先把不同系统的换行符统一再用正则把连续空行或单个换行都当作段落边界。这里有个取舍——有些作文段落之间只有一个换行如果严格按空行切会漏段所以我用\n\s*\n|\n把两种情况都覆盖。split_sentences用后向零宽断言(?...)在句末标点之后切这样标点会留在前一句末尾不会丢。参数说明句末标点集合里我加了分号因为中文作文里分号常用来分隔并列分句算作句子边界更合理如果你的语料里分号多用于句内停顿可以把它去掉。切分粒度直接影响后面所有特征建议先拿十几篇真实作文跑一遍人工看看切得对不对再定标点集合。2.3 把结构特征拼成一个特征向量切分完成后就可以算特征了。下面这段代码把段落、句子统计和语义相似度拼成一个字典语义部分用 sentence-transformers 的中文模型算向量。import numpy as np from sentence_transformers import SentenceTransformer # 首次运行会自动下载模型之后走本地缓存 model SentenceTransformer(paraphrase-multilingual-MiniLM-L12-v2) def cosine(a, b): return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) 1e-8)) def extract_features(text): paras split_paragraphs(text) para_sents [split_sentences(p) for p in paras] feat {} # 段落层面 feat[para_count] len(paras) para_lens [len(p) for p in paras] feat[para_len_mean] float(np.mean(para_lens)) if para_lens else 0 feat[para_len_std] float(np.std(para_lens)) if para_lens else 0 # 句子层面 sent_counts [len(s) for s in para_sents] feat[sent_per_para_mean] float(np.mean(sent_counts)) if sent_counts else 0 all_sents [s for ss in para_sents for s in ss] sent_lens [len(s) for s in all_sents] feat[sent_len_mean] float(np.mean(sent_lens)) if sent_lens else 0 feat[sent_len_std] float(np.std(sent_lens)) if sent_lens else 0 # 语义层面段落向量 if len(paras) 2: para_vecs model.encode(paras, normalize_embeddingsTrue) # 相邻段落相似度均值衡量连贯性 adj [cosine(para_vecs[i], para_vecs[i1]) for i in range(len(para_vecs)-1)] feat[adj_para_sim] float(np.mean(adj)) # 首尾段相似度衡量首尾呼应 feat[head_tail_sim] cosine(para_vecs[0], para_vecs[-1]) else: feat[adj_para_sim] 0.0 feat[head_tail_sim] 0.0 # 中心句突出度每段首句与段内其余句的相似度均值 topic_scores [] for ss in para_sents: if len(ss) 2: vecs model.encode(ss, normalize_embeddingsTrue) sims [cosine(vecs[0], vecs[k]) for k in range(1, len(vecs))] topic_scores.append(float(np.mean(sims))) feat[topic_sentence_score] float(np.mean(topic_scores)) if topic_scores else 0.0 return feat逻辑说明段落和句子统计量直接用 numpy 算均值和标准差标准差是重点——它衡量结构是否匀称一篇段落长短悬殊的作文para_len_std会明显偏高。语义部分用多语言 MiniLM 模型它对中文短文本效果够用且体积小适合本地跑。相邻段落相似度取均值值太低说明段落之间跳跃、不连贯首尾相似度衡量呼应但要注意这个值不是越高越好太高可能是首尾重复。参数说明normalize_embeddingsTrue让向量归一化后面余弦相似度就退化成点积省一次除法。topic_sentence_score只在段内句子数大于等于 2 时才算单句段没有中心句概念直接跳过。模型名换成更大的中文模型如text2vec-base-chinese通常能提升语义特征质量但推理会慢建议先用小模型跑通流程。3. 从特征到分数模型训练与数据准备3.1 数据从哪来、怎么标注自动作文评分是典型的监督学习没有标注分数就无从训练。现实里数据来源有三条路一是公开数据集部分教育研究机构会放出带人工评分的作文语料二是自己组织标注找两到三位老师对同一批作文独立打分取平均同时算一下评分者一致性三是用现有考试的分数作为弱标签。我一般会先确认三件事分数是连续分还是档位分决定用回归还是分类、评分标准是否统一决定标签噪声大小、作文题目是否单一决定模型能不能跨题泛化。如果只有几十篇作文别急着上深度学习先用结构特征加梯度提升树小样本下它比神经网络稳得多。标注环节有个血泪经验一定要留一部分作文做双评算一下评分者之间的相关系数。如果两位老师对同一篇作文的分差经常超过总分的一档说明评分标准本身模糊这时候模型学到的就是噪声再调参也没用。3.2 用 sklearn 训练一个结构特征评分模型假设你已经把每篇作文的分数和特征整理成了 CSV下面是训练和评估的完整流程。import pandas as pd import numpy as np from sklearn.model_selection import train_test_split, cross_val_score from sklearn.ensemble import GradientBoostingRegressor from sklearn.metrics import mean_absolute_error, cohen_kappa_score # df 列每篇作文的结构特征 score人工分 df pd.read_csv(essay_features.csv) feature_cols [c for c in df.columns if c ! score] X df[feature_cols].values y df[score].values # 小样本优先用交叉验证看稳定性 X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42) model GradientBoostingRegressor( n_estimators300, # 树的数量小样本别设太大 learning_rate0.05, # 学习率配合树数量控制拟合 max_depth3, # 树深结构特征维度低3 层够用 subsample0.8, # 行采样抗过拟合 random_state42 ) model.fit(X_train, y_train) pred model.predict(X_test) print(MAE:, mean_absolute_error(y_test, pred)) # 如果分数是整数档位可以四舍五入后算二次加权 kappa pred_round np.round(pred).astype(int) print(QWK:, cohen_kappa_score(y_test, pred_round, weightsquadratic)) # 看特征重要性验证结构特征是否真的有用 imp sorted(zip(feature_cols, model.feature_importances_), keylambda x: -x[1]) for name, v in imp: print(f{name}: {v:.4f})逻辑说明GradientBoostingRegressor对小样本、低维特征很友好比随机森林更不容易过拟合。评估用两个指标——MAE 看平均偏差多少分QWK二次加权 kappa看档位一致性后者更贴近人工评分的实际需求因为评分误差在一档内通常可接受跨档才是大问题。参数说明n_estimators和learning_rate是一对学习率小就要树多但小样本下树太多会记住训练集我一般从 200 到 300 起步。max_depth3是因为结构特征只有二三十维深树没必要还容易过拟合。subsample0.8每次只用 80% 样本建树是廉价的抗过拟合手段。跑完一定要看特征重要性如果排前面的全是长度类特征说明模型还是在以长取分得回头检查特征设计或做长度归一化。3.3 特征标准化与长度归一化结构特征里段落数、句长这些量纲差很多树模型对量纲不敏感可以跳过标准化但如果你要换线性模型或神经网络标准化是必须的。更重要的是长度归一化——很多结构特征和总字数强相关直接喂进去模型会偷懒。from sklearn.preprocessing import StandardScaler # 对线性/神经网络模型先标准化 scaler StandardScaler() X_scaled scaler.fit_transform(X) # 长度归一化把绝对量换成相对量 df[para_len_cv] df[para_len_std] / (df[para_len_mean] 1e-8) df[sent_len_cv] df[sent_len_std] / (df[sent_len_mean] 1e-8) # 段落数按总字数归一避免长文天然段多 df[para_density] df[para_count] / (df[total_chars] / 100.0 1e-8)逻辑说明变异系数CV 标准差 / 均值把段长是否匀称从绝对量变成了相对量一篇 500 字和一篇 1000 字的作文只要结构匀称程度一样CV 就接近。para_density用每百字段落数衡量分段密度比原始段落数更公平。参数说明1e-8是防止除零的极小量别省。归一化后记得把原始绝对特征也保留一份树模型有时能同时利用绝对量和相对量让模型自己选。4. 避坑与排查结构评分系统最容易翻车的五个地方4.1 切分错误导致特征全盘失真现象模型在训练集上表现很好换一批作文 MAE 突然翻倍。原因新语料的段落分隔习惯和训练集不同比如训练集用空行分段新语料用单换行切分逻辑没覆盖段落数全错。解决切分函数要兼容多种分隔符并且在上线前对每批新数据抽样人工核对切分结果把切分质量当成数据预处理的第一道验收。4.2 语义模型对短段落给出噪声相似度现象相邻段落相似度特征和人工判断对不上短段落之间相似度忽高忽低。原因句向量模型对极短文本比如只有四五个字的过渡段编码不稳定向量方向随机性大。解决对少于一定字数的段落要么跳过不参与相似度计算要么用前后段拼接后再编码。我一般设 10 字为阈值低于它的段落不单独算向量。4.3 分数分布不均导致模型偏向多数档现象模型几乎把所有作文都预测成中间档高分段和低分段全错。原因训练集里中间档作文占绝大多数回归模型为了降低整体误差倾向于输出均值附近的值。解决对样本做分层采样或在损失函数里给少数档更高权重也可以先做档位分类再在档内做回归两步走比一步回归更稳。4.4 用测试集调参造成虚高指标现象反复在测试集上调模型参数指标很好看上线就崩。原因测试集被当成了验证集用模型间接记住了测试集。解决严格三分——训练集、验证集、测试集调参只看验证集测试集只在最后跑一次。小样本下用交叉验证替代固定验证集但交叉验证的分数也不能拿来反复挑模型。4.5 忽略题目差异导致跨题泛化差现象模型在写人记事类作文上准换议论文就失准。原因不同文体的篇章结构差异大议论文段落更规整、中心句更明显模型学到的结构规律不通用。解决要么按文体分别建模要么在特征里加入文体标识要么用更多文体混合数据训练。跨题泛化是这类系统最难的一关别指望一个模型打天下。5. 让结构评分更稳的两个进阶技巧第一个技巧是把结构特征和内容特征做互补。纯结构特征有个天花板——它能判断骨架好不好但判断不了血肉是否切题。我一般会在结构特征之外加一维主题相关性用作文题目和全文的语义相似度或者用题目关键词在文中的覆盖度。这样模型既看结构又看内容对跑题作文的识别会明显变好。做法很简单把题目也编码成向量和全文向量算余弦相似度拼进特征表即可。第二个技巧是用分档一致性而不是绝对误差来验收。自动评分系统落地时用户真正在意的是分档对不对而不是差 0.3 分。我习惯把预测分和人工分都映射到档位比如每 5 分一档然后算二次加权 kappaQWK 到 0.7 以上才算可用0.6 到 0.7 之间只能做辅助参考。下面这个小函数可以直接拿去用import numpy as np from sklearn.metrics import cohen_kappa_score def to_band(scores, band5): # 把连续分映射到档位band 为每档分值跨度 return (np.asarray(scores) // band).astype(int) def band_agreement(y_true, y_pred, band5): return cohen_kappa_score( to_band(y_true, band), to_band(y_pred, band), weightsquadratic)逻辑说明to_band用整除把分数压到档位band5表示每 5 分一档你可以按实际评分标准调整。band_agreement算二次加权 kappa它对跨档误差惩罚重、对档内误差惩罚轻正好匹配评分场景。参数说明weightsquadratic是固定选择线性加权对跨档惩罚不够二次加权更符合差一档可接受、差两档严重的直觉。我自己的习惯是任何一版模型上线前先拿 50 篇没参与训练的真实作文跑一遍人工看预测档位和实际档位的偏差分布偏差集中在哪一档就回头查那一档的特征。这套流程跑顺之后结构评分系统能稳定承担初筛工作把老师的精力省下来处理真正需要人工判断的边界作文。希望帮到你。本文还有配套的精品资源点击获取