ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

自动作文评分算法:从特征工程到大模型与QWK优化

自动作文评分算法:从特征工程到大模型与QWK优化 我最早接触自动作文评分是在一个英语写作批改类项目里。当时团队的想法很简单既然人工老师批一篇作文要五六分钟那能不能让算法先把明显的问题筛出来老师只做最后确认。真正做起来才发现这件事远比算个分复杂——同样一篇作文两个老师给出的分数差 1 分甚至 2 分是常事而算法要做的是去逼近这个本身就带有噪声的人工答案。自动作文评分算法Automated Essay Scoring常简称 AES本质上是一套把阅卷经验翻译成可计算信号的系统它解决的问题不是写得对不对而是在给定的评分标准下这篇作文大概处在哪个水平段。这篇文章会从整体架构、特征体系、模型选型、评价指标、代码实现、踩坑排查几个层面把这条链路完整拆一遍。不管你是刚进教育科技团队、需要一周内搭出个能演示的版本还是已经有一版模型、被长文高分套模板拿分折腾得头疼下面的内容应该都能对上你的场景。1. 自动作文评分算法的整体设计与选型思路1.1 先把评分这件事拆开看我在项目启动会上问过评委老师一个问题您给这篇作文 4 分是因为它哪儿好老师想了半天说感觉就是中等偏上还行但不惊艳。这个回答很有代表性——人类阅卷是整体感知加局部核对先有一个模糊的印象分再去找证据支持或修正它。算法没法复刻感觉所以我们必须把整体感知拆成一组可观测的维度写得够不够长、词用得丰不丰富、句子结构有没有变化、段落之间有没有衔接、有没有语法和拼写错误、内容跑没跑题。这六个维度基本能覆盖大多数作文评分标准的前四档要求。拆维度只是第一步更关键的是想清楚每一维用什么信号去代理。比如内容丰富度是个抽象概念我们能用词种比不同词数/总词数、平均词长、低频词占比来间接刻画句子结构多样可以用句长标准差、从句密度、依存树深度分布来代理。这里的核心心法是任何一个抽象维度都要找到至少两个独立的、可从文本直接算出的代理指标避免单点失真。我见过一个版本的模型结构这一维只用了句长标准差结果学生只要写一长一短交替的句子这一项就满分完全偏离了本意。还有一个容易被忽略的前置问题评分标准本身是不是可计算的。有些评分细则写着思想深刻这种维度再强的模型也做不稳因为它连人工之间的一致性都保证不了。遇到这类维度务实的做法是把它降权甚至剔除只保留可复核的维度宁可模型覆盖窄一点也别让不可靠的维度污染总分。1.2 三条技术路线的取舍目前能落地的路线大致三条我在不同项目里都用过各有明确的适用边界。第一条是纯特征工程加浅层模型。做法是把作文变成几十到几百维的数值向量喂给岭回归、SVR 或梯度提升树。它的最大优势是可解释、可干预、算得快CPU 上单篇推理几毫秒到几十毫秒一个中等配置的服务器能扛住很高的并发。可解释性在 to B 场景里几乎是刚需——学校或者机构会问为什么这篇只有 3 分你得能把特征贡献拉出来给他看。缺点也明显特征是人设计的天花板就在那儿遇上语言表达上的微妙差异它就无能为力了。第二条是预训练语言模型微调。把作文送进编码器在 [CLS] 位置上接一个回归头或者分类头。它不需要你手工设计特征模型自己会从大规模语料里学到语言规律在同一题目内部的评分一致性上通常明显好于特征工程路线。代价是训练成本、推理成本都上去了而且对数据量的要求更高小样本下很容易过拟合到题目的表面特征比如某个题目的学生习惯用某些固定表达换个题目分数就崩了。第三条是大模型提示评分。不训练直接给模型一份评分细则和几个示例让它输出分数和理由。冷启动成本几乎为零改评分标准只要改提示词特别适合题目频繁更换、标注数据稀少的场景。它的问题是稳定性——同一篇文章跑两次可能给出不同结果成本和延迟也远高于前两条路线。我的建议是分层组合用大模型做冷启动和细则迭代期的兜底用微调模型做主力打分用特征模型做实时预筛和可解释性补充。这三者不是互斥关系而是同一条流水线上不同工位的人。1.3 一个务实的混合架构把上面三条路线拼起来我常用的架构是这样分层的。第一层是质量门禁用规则和轻量模型做快速筛查字数低于阈值、大段复制题目原文、通篇乱码或明显机器生成痕迹、有效句不足若干句这些直接拦掉并返回无法评分的提示不进入后面的昂贵流程。这一层能挡掉相当一部分无效请求对成本控制帮助很大。第二层是特征与轻量模型输出一个粗分数和六个维度的分项分同时把特征值缓存下来。这一层的结果既可以直接给到即时反馈场景也可以作为后续模型的输入特征。第三层是深度模型或大模型评分做精细打分并且要求它给出分数对应的证据句——比如第三段缺少过渡第二段的论据与上一段重复。证据句这个要求很关键它不只是给用户看的也是我们自己排查模型是否真的读懂了文章的重要手段。如果模型给出的证据句跟分数完全不搭那基本可以判断它是在靠长度或者别的表面特征做判断。第四层是一致性校准把模型输出的原始分数映射到评分细则规定的档位上同时做多路评分的一致性投票。这一层是很多人会漏掉的但恰恰是决定最终 QWK 指标的关键一环后面第 2.4 节会详细讲。2. 核心特征与打分模型把阅卷经验翻译成数字2.1 六大类可计算特征特征设计这里我把常用的一百来个特征归成六类实际项目里挑三四十个就够了太多了反而增加共线性风险。下面这张表是我自己整理的特征清单里面标注了每一类最实用的两三个特征和它们的坑。特征类别代表特征计算方式常见坑产出量总词数、有效句数、段落数分词计数、标点切分权重过高会诱导学生凑字数词汇丰富度词种比 TTR、MTLD、低频词占比去停用词后统计TTR 受长度影响大短文本会虚高句法复杂度平均句长、句长标准差、从句密度、依存树深度依存句法分析句长适中最好不是越长越加分衔接连贯连接词密度、相邻句余弦相似度、指代链长度词表匹配 句向量连接词堆砌会被误判成连贯语言准确性拼写错误率、语法错误密度拼写检查 规则引擎错误检测的误报会直接扣冤枉分内容切题与题目语义相似度、要点覆盖率、主题漂移度句向量/嵌入模型相似度整体偏高需要做相对校准具体说几个细节。词种比是我踩过坑最多的特征一篇 80 词的小学生作文 TTR 可能到 0.85一篇 800 词的高中作文反而只有 0.42如果直接进模型它学到的规律是词种比越低分数越高逻辑完全反了。解决办法是换用对长度不敏感的 MTLD移动平均类符形符比或者在对数空间里做长度归一化再或者干脆按长度分桶、桶内做标准化。我一般选第二种实现简单效果也够。衔接连贯这一项也值得单独说。很多开源实现用连接词密度来代理连贯性比如统计howeverthereforein addition出现的次数。这在议论文里勉强能用但学生会迅速学会堆连接词——我见过一篇作文每句开头都是Furthermore机器给了很高的连贯分人看着非常别扭。更稳的做法是算相邻句的语义相似度连贯的文章相邻句子在语义空间里距离适中既不重复相似度 0.9 以上说明在啰嗦也不跳跃0.2 以下说明断了。我一般取相邻句相似度的均值和标准差两个统计量均值反映整体流畅度标准差反映段落内是否有断裂。这两个量加起来比连接词密度靠谱得多。内容切题这一项需要重点提防。所有句向量模型给出的相似度都集中在 0.6 到 0.9 这个窄区间里直接当特征用区分度极低。我的做法是做相对校准把同一批提交的作文放在一起算相似度取分位数而不是原始值。这样即使模型本身有偏移相对排名也是稳的。2.2 从特征到分数回归、分类还是排序特征算出来之后怎么变成分数这里有三种做法选哪个取决于你的评分细则和下游需求。回归最直观直接拟合连续分数或者平均分输出一个实数再四舍五入到整数档。它的优点是信息保留完整缺点是当评分档位很少比如只有 1 到 4 档时回归会把大量样本压在中间档附近两端的极端分数预测不准。分类把每一档当成一个类别输出各档概率。它的好处是天然输出置信度便于做低置信度转人工的路由坏处是丢掉了档位之间的序关系——模型可能把 5 分预测成 2 分还觉得错得不算离谱实际上这种错误在评分场景里是致命的。序数回归是我目前最推荐的做法。它承认档位有顺序做法是在共享的特征表示上学习一组递增的阈值输出落在每个档位区间的概率。实现上可以用累积链接模型cumulative link model或者在神经网络上接多个有序二分类头。它同时保留了序关系和概率输出做低置信度转人工和档位概率展示都很方便。如果你手上有大量同一篇作文被两个老师打了两分的数据还可以考虑排序学习的思路不直接学绝对分数而是学哪篇更好再用 Bradley-Terry 或类似模型把两两比较的结果聚合成全局分数。这条路在标注成本有限、但比较判断容易做的时候特别划算——让老师判断A 和 B 哪个好比让他精确给分容易得多一致性也高得多。2.3 评价指标为什么 QWK 是绕不开的坎训练模型时用均方误差当损失没问题但评估汇报时如果只说 MSE 或者准确率很容易被质疑。原因在于评分是有序的把 4 分预测成 5 分和把 4 分预测成 1 分错误性质完全不同。二次加权 KappaQuadratic Weighted KappaQWK恰好解决了这一点它按错误距离的平方加权让相邻档错误的影响远小于跨档错误。公式是这样的$$\kappa 1 - \frac{\sum_{i,j} w_{ij} O_{ij}}{\sum_{i,j} w_{ij} E_{ij}}, \quad w_{ij} \frac{(i-j)^2}{(N-1)^2}$$其中 $O_{ij}$ 是混淆矩阵的实际计数$E_{ij}$ 是假设两者独立时按边际分布算出的期望计数$N$ 是档位总数。分母是随机猜测时的加权错误量分子是实际模型的加权错误量所以 0 表示和随机猜一样1 表示完全一致。手写一版不是为了炫技而是因为不同库对档位范围的处理不一样容易算出对不上的结果。import numpy as np def quadratic_weighted_kappa(y_true, y_pred, min_rating1, max_rating6): y_true np.asarray(y_true, dtypeint) - min_rating y_pred np.asarray(y_pred, dtypeint) - min_rating n max_rating - min_rating 1 if y_true.max() n or y_pred.max() n: raise ValueError(预测或真实分数超出给定档位范围) O np.zeros((n, n), dtypefloat) for t, p in zip(y_true, y_pred): O[t, p] 1 w np.zeros((n, n), dtypefloat) for i in range(n): for j in range(n): w[i, j] (i - j) ** 2 / (n - 1) ** 2 hist_t O.sum(axis1) hist_p O.sum(axis0) E np.outer(hist_t, hist_p) / O.sum() return 1 - (w * O).sum() / (w * E).sum()实测下来QWK 到 0.7 上下算是能进生产、但要配人工抽检的水平0.75 以上就相对稳了。参考系很重要公开语料里两位人工评阅者之间的一致性很多题目也就落在 0.7 到 0.8 之间。所以如果有人拿一个 0.9 的 QWK 来汇报大概率是数据划分有问题比如同一篇作文的多个评分版本被切到了训练集和测试集两边这是最经典的泄漏方式。注意划分数据集时必须按作文 ID 分组划分绝不按评分记录划分。同一篇文章的两条评分记录分别进训练和测试模型等于开卷考试指标会虚高得非常离谱。2.4 分数映射的阈值优化深度模型输出的是连续值要变成档位分中间需要一个映射。大多数人的做法是直接四舍五入这是白白丢分的地方。举个例子模型的连续输出分布如果集中在 3.2 到 3.8 之间四舍五入就全变成 3档位4永远预测不出来如果把阈值从 3.5 调整到 3.15能捞回不少正确预测。正确做法是基于 QWK 搜索最优阈值。具体来说先把模型输出排序然后找 $N-1$ 个切分点把样本分成 $N$ 组使得分组标签和真实标签的 QWK 最大。暴力搜索是 $O(n^{N-1})$太慢实际可以用动态规划在排序后的样本上做复杂度降到 $O(nN)$。import numpy as np def optimize_thresholds(y_true, y_cont, n_classes, sample_step1): order np.argsort(y_cont) y_sorted np.asarray(y_true)[order] s_sorted np.asarray(y_cont)[order] n len(y_sorted) # dp[k][i]: 前 i 个样本分成 k 组时累计的最优加权一致样本数 # 用简单的加权得分越接近越得分来近似 QWK 的优化目标 cost np.zeros((n_classes 1, n 1)) for k in range(1, n_classes 1): cost[k, n] -np.inf cost[1, 0] 0.0 for i in range(1, n 1): cost[1, i] 0.0 for k in range(2, n_classes 1): for i in range(k, n 1): best -np.inf for j in range(k - 1, i): if cost[k - 1, j] -np.inf: continue seg_true y_sorted[j:i] # 该区间被预测为 k 档用平方距离定义区间收益 gain -np.sum((seg_true - k) ** 2) if cost[k - 1, j] gain best: best cost[k - 1, j] gain cost[k, i] best thresholds [] i n for k in range(n_classes, 1, -1): best_j, best_val None, -np.inf for j in range(k - 1, i): if cost[k - 1, j] -np.inf: continue seg_true y_sorted[j:i] val cost[k - 1, j] - np.sum((seg_true - k) ** 2) if val best_val: best_val, best_j val, j thresholds.append((s_sorted[best_j] s_sorted[best_j - 1]) / 2 if best_j 0 else s_sorted[0]) i best_j return sorted(thresholds)这个实现的优化目标用的是平方距离的负值严格来说和 QWK 并不完全等价但在实践中给出的阈值已经比四舍五入好很多。如果你追求更严格可以在粗搜之后用scipy.optimize.minimize做一轮局部精调把 QWK 本身当目标函数——不过要注意 QWK 对阈值的梯度是分段为 0 的得用无导数的优化方法比如 Nelder-Mead 或者 Powell。我在一个四档评分的项目上做过对比四舍五入的 QWK 是 0.68阈值优化之后提到 0.74模型架构一点没动。这块免费的性能不拿白不拿。3. 一条可跑通的最小实现从数据到上线3.1 数据准备与标注一致性清洗数据这块我先说一个血泪教训标注数据拿到手第一件事不是训模型是做标注一致性检查。我接过一个项目两千多篇作文每篇两个老师打分表面看数据很充足。一算 QWK两位老师之间只有 0.52。这意味着什么意味着即使模型完全复刻了老师 A 的判断跟老师 B 的吻合度也上不去我们无论如何都做不出一个 QWK 0.7 的模型——因为数据本身的上限就在那儿。处理办法有几个层次。差距小于等于 1 分的直接取平均四舍五入到最近档位这是最省事的做法。差距大于等于 2 分的一定要拉出来做仲裁由第三位资深老师定分同时把争议点记录下来——这些争议样本后续是最宝贵的分析素材往往能暴露出评分细则中含糊的地方。如果争议样本占比超过 15%那就不是模型的问题了得回头先把评分细则和教师培训做实。还有一个必须做的清洗是查重与去模板。公开语料里有一批高度模板化的作文开头几段几乎一模一样如果这批数据占比高模型很容易学到只要出现某些句式就给某个分数。我的做法是对训练集做 n-gram 级别的重复检测把重复度超过阈值的样本标出来训练时降权或者直接剔除。检测可以用简单的 shingling 加 Jaccard 相似度也可以用句向量聚类看每个簇的规模和内部文本相似度。最后提醒一个数据组织上的细节如果你的模型是跨题目使用的验证集一定要按题目划分训练集里出现的题目和验证集的题目不能有重叠。同题目内部的指标只能说明模型记住了这道题说明不了泛化能力。我见过太多团队在随机划分上拿到 0.8一换题目掉到 0.5上线后直接被真实数据打脸。3.2 特征与模型的代码骨架下面这段是我常用的最小骨架特征部分挑了六个最有代表性的模型用岭回归加梯度提升做简单集成。它跑不出 SOTA但能在一两天内给你一个可用的基线后续所有优化都从这个基线往上加。import re import numpy as np from collections import Counter from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import Ridge from sklearn.ensemble import GradientBoostingRegressor from sklearn.model_selection import GroupKFold, cross_val_predict from sklearn.preprocessing import StandardScaler from sklearn.pipeline import make_pipeline def tokenize(text): return re.findall(r[a-zA-Z], text.lower()) def mtld(tokens, threshold0.72): # 移动平均类符形符比对文本长度不敏感 def one_pass(seq): types, factors, count set(), 0, 0 for w in seq: types.add(w) count 1 ttr len(types) / count if ttr threshold: factors 1 types, count set(), 0 if count 0: leftover len(types) / count factors (1 - leftover) / (1 - threshold) if leftover 1 else 0 return len(seq) / factors if factors 0 else len(seq) if len(tokens) 8: return float(len(set(tokens))) return (one_pass(tokens) one_pass(tokens[::-1])) / 2 def handcrafted_features(text): sents [s for s in re.split(r[.!?], text) if s.strip()] tokens tokenize(text) n_words len(tokens) n_sents max(len(sents), 1) sent_lens [len(tokenize(s)) for s in sents] or [0] ttr len(set(tokens)) / n_words if n_words else 0.0 connectives {however, therefore, moreover, furthermore, in addition, for example, as a result, in conclusion} conn_count sum(text.lower().count(c) for c in connectives) long_word_ratio sum(1 for w in tokens if len(w) 7) / n_words if n_words else 0.0 return np.array([ n_words, n_words / n_sents, float(np.std(sent_lens)), ttr, mtld(tokens) / 100.0, conn_count / n_sents, long_word_ratio, ]) def build_matrix(texts): hand np.vstack([handcrafted_features(t) for t in texts]) tfidf TfidfVectorizer(ngram_range(1, 2), min_df3, max_features20000, sublinear_tfTrue) lex tfidf.fit_transform(texts).toarray() return np.hstack([hand, lex]), tfidf, hand # 训练与交叉验证groups 为作文题目 ID def train_model(texts, labels, groups): X, tfidf, hand build_matrix(texts) y np.asarray(labels, dtypefloat) ridge make_pipeline(StandardScaler(), Ridge(alpha3.0)) gbdt GradientBoostingRegressor(n_estimators400, learning_rate0.05, max_depth4, subsample0.8, random_state42) gkf GroupKFold(n_splits5) oof_ridge cross_val_predict(ridge, X, y, cvgkf, groupsgroups) oof_gbdt cross_val_predict(gbdt, X, y, cvgkf, groupsgroups) # 简单的加权融合权重用验证集上的误差反比来确定 w 1.0 / (np.mean((oof_ridge - y) ** 2) 1e-6) v 1.0 / (np.mean((oof_gbdt - y) ** 2) 1e-6) oof (w * oof_ridge v * oof_gbdt) / (w v) ridge.fit(X, y) gbdt.fit(X, y) return {ridge: ridge, gbdt: gbdt, w: w, v: v, tfidf: tfidf, oof: oof}这段代码有几处细节值得说明。GroupKFold保证同一道题的作文全部落在同一折里避免前面说的泄漏问题。TF-IDF 用了sublinear_tfTrue是为了抑制高频词在长文里的累积效应不加这个参数长文的向量模长会显著大于短文。手工特征里之所以把 MTLD 除以 100是为了让它和词种比量级接近不这么做的话标准化之后它的权重会被压得很低。还有一个实操要点别把手工特征和 TF-IDF 直接丢给同一个线性模型而不做分块标准化。手工特征的量纲差异大词数可能到几百词种比在 0 到 1 之间TF-IDF 的值域又在 0 到 1 附近但分布极偏。我一般先单独标准化手工特征再和 TF-IDF 拼接或者干脆用两个模型分别建模再融合后者更省心也更容易解释每部分是靠什么在起作用。3.3 大模型评分路线提示词 校准没有标注数据、或者题目每周都在换的场景直接用大模型打分是唯一可行的冷启动方案。但直接用一句请给这篇作文打分是不行的输出会非常飘。我的提示词模板大概长这样你是资深写作评阅者。请依据下面的评分细则给作文打分。 评分细则 - 5 分论点清晰且有深度论据充分具体结构完整有过渡语言准确且有变化 - 4 分论点清晰论据基本充分结构完整语言基本准确 - 3 分论点基本清楚论据较笼统结构尚可语言有少量错误 - 2 分论点模糊或部分偏题论据不足结构松散语言错误较多 - 1 分严重偏题或篇幅严重不足难以理解 题目{prompt} 作文 {essay} 要求 1. 先在 200 字以内逐条核对细则引用原文作为证据 2. 再给出维度分内容、结构、语言各 1-5 分 3. 最后输出 JSON{score: 整数, dims: {...}, evidence: [..., ...]} 不要输出 JSON 以外的多余内容。这个模板里有三个设计意图。第一先证据后结论。让模型先引用原文再给分能显著降低它靠长度或印象给分的倾向如果它给出的证据和分数明显不匹配这本身就是一条可以进人工复核的信号。第二要求维度分。单一总分信息量太少维度分能让我们在之后做校准的时候有更多自由度也方便给用户提供分项反馈。第三强制结构化输出。自由文本解析起来太脆JSON 虽然偶尔会缺字段但至少能写健壮的解析容错。大模型输出之后一定要做校准。最朴素的做法是用一批人工标注的作文拟合一个单调映射等距回归或者简单的分位映射把模型分数拉到人工分数的分布上。因为大模型有明显的高分倾向——它天然倾向于给中等偏上的评价直接使用会导致分数整体偏高分布严重右偏。我做过一次统计未校准的模型在五档评分里4 分和 5 分加起来占了将近六成而人工标注里这个比例只有三成出头。还有一个提高稳定性的技巧同一篇作文跑三次取中位数。三次的成本对于非实时场景是可以接受的而中位数能有效过滤掉偶发的极端输出。如果你的场景对一致性要求极高可以把温度设为 0 并且关掉所有采样随机性不过即便这样也不保证完全确定所以多重采样加投票仍然有必要的。3.4 集成与降级策略线上系统不能只有一条路径。我的标准配置是三级降级主路径是深度模型或大模型评分目标是 3 秒内返回一级降级是特征模型目标 200 毫秒内返回准确度差一些但完全可用二级降级是规则打分只做字数、段落数、错误率的粗略判断输出一个区间而不是具体分数并明确告知用户当前为快速评估。这三级的切换由超时和错误率触发切换过程对用户透明只是页面上标注一下评估模式的差异。多模型融合这里有个容易犯的错直接把不同模型输出的分数取平均。不同模型的输出尺度和偏差都不一样直接平均往往不如单模型。正确做法是先各自校准到同一分布再加权融合权重用验证集上的 QWK 或 MAE 来定。如果懒得校准至少做一次线性回归拟合把每个模型的输出当特征、人工分数当目标这样自动就学到了合适的权重和截距。4. 常见问题与排查技巧实录4.1 长文必胜与句式趋同最典型的两类偏差先说长度偏差。这是 AES 里最顽固的问题而且它有统计上的合理性——写得长的学生平均写作水平确实更高。这就导致模型会正大光明地依赖长度特征。我实测过在某些数据集上只用总词数一个特征做线性回归相关系数就能到 0.5 以上。问题是一旦上线学生很快就会学会凑字数长篇废话能拿高分评价体系立刻失效。处理办法是分层的。特征层面把绝对长度换成长度相对同批次的分位数削弱绝对量的影响训练层面可以对长度做残差化——先用长度拟合分数再用模型去拟合残差这样模型的注意力被强制从长度上移开评估层面必须单独做长度分桶的分组评估看每个长度段内的 QWK 是否均衡。如果短文本段的 QWK 明显低于长文本段说明长度偏差还在。再说句式趋同。模型在训练集里见过大量First... Second... Finally...结构的作文会形成一种对固定句式的偏好导致真正有创意但结构不典型的文章被低估。检测方式是看模型分数和句法模板利用率之间的相关性如果正相关很强说明它在奖励套路。缓解手段是在训练数据里刻意保留句式多样但质量高的样本或者引入基于句法树结构的多样性特征让多样性变成一个显式的加分维度而不是让模型自己去猜。这两个偏差我都会在模型上线前做一次专门的诊断把测试集按长度分成三组、按模板相似度分成两组交叉看六格里的指标分布。只要有一格明显掉队就先解决它再谈上线。4.2 作弊与对抗样本的处理真实场景里学生和老师的应对策略会不断演化对抗样本是必然会遇到的。我遇到过的几类典型情况以及对应的处理思路。模板作文网上流传的万能模板换个题目就能套。检测方式是对训练语料和线上提交做文档级相似度比对用 shingling 或者 SimHash 快速找出高相似度的历史文档。相似度超过阈值的不直接判零分而是走人工复核通道因为确实存在学生自己练习时反复使用自己固定开头的合理情况。同义词替换用工具把作文里的词批量换成近义词想绕过查重同时保住分数。这类文本有个明显特征——用词不自然比如把important换成crucial、把many换成a multitude of整体语域忽高忽低。检测上可以算词汇的语域一致性或者用语言模型的困惑度——机器替换过的句子困惑度往往异常比同水平的人工写作高出一截。无关长文写一大段和题目毫无关系但语言流畅的内容骗长度分和语言分。这类必须靠题目相关性门禁拦掉用一个独立的语义相关性模型算作文和题目的匹配度低于阈值直接不给分并且给出疑似偏题的提示。这里要注意阈值别设太高正常的作文也会有跑题段落宁可漏判也别错杀。重复句填充把同一句话改几个字反复写既凑了长度又让词种比看起来还行。检测方法是算句子级别去重后的有效长度如果去重前后长度差超过某个比例就按有效长度计分。注意所有反作弊的判定都不要直接输出作弊这样的结论。系统的定位是提示风险、转人工而不是判定对错。把判定权交给人的环节保留住既规避了误判带来的争议也让模型可以保持相对保守的阈值。4.3 问题速查表这张表是我这几年攒下来的基本覆盖了日常排查的八成场景。现象可能原因排查动作处理方案线下 QWK 很高线上骤降数据泄漏或题目分布不一致检查划分方式是否按作文 ID/题目分组改成分组划分重训并看跨题指标分数分布严重右偏模型有高分倾向或训练数据分布偏画预测分数与真实分数的分布对比图做等距回归校准或调整分类阈值长文得分明显偏高长度特征主导看特征重要性、做长度分桶评估长度分位数化、残差化训练同一篇两次结果不同推理有随机性或多模型投票不稳固定随机种子、重复跑十次看方差温度归零、多次采样取中位数短文本分数波动大特征在短文本上方差大按长度分段看指标设置最小有效长度门禁短文本走区间输出人工复核通过率低阈值过松或模型偏差统计转人工样本的分数分布重新搜索阈值重估人工一致性语法批评误报多规则引擎对特定句式不友好抽样看被误报的句子加白名单规则或降低该维度权重换题目后指标崩塌模型过拟合到题目表面特征做跨题目测试集评估加题目无关特征或用大模型路线兜底用这张表的时候有个诀窍先看分布再看指标。很多团队一上来就盯着 QWK 调参其实分数分布图上一眼就能看出问题。如果预测分布是个尖峰真实分布是均匀的那无论怎么调参都救不回来必须从校准或采样层面解决。4.4 上线之后的监控与迭代模型上线不是终点。我习惯配四个监控指标缺一个都会在出问题时抓瞎。第一个是输入分布漂移。监控每天提交作文的长度分布、词汇丰富度分布、题目相关性分布和训练集基线做对比。一旦某个维度的分布明显偏移说明用户群体或题目类型变了模型可能已经不适应。这个指标最好做成一维的统计量比如均值和中位数方便画趋势图看长期变化。第二个是分数分布漂移。监控每天预测分数的直方图。正常情况下它应该稳定在一个形状上如果某天突然出现一个 5 分尖峰那大概率是模型或者上游特征出了问题而不是学生集体水平突飞猛进。第三个是人工复核通过率。所有被标记为低置信度、转人工的样本统计人工最终维持原判的比例。这个比例如果持续下降说明模型的置信度估计不准了需要重新校准如果持续上升说明阈值设得过保守白白增加了人工成本。第四个是对抗样本命中率。统计每天被反作弊规则拦截的请求占比和类型分布。这个数据能让你提前看到攻击手段的演化趋势比如某段时间同义词替换的占比突然上升就该考虑加强这一块的检测了。迭代节奏上我一般按季度做一次全量重训中间按需做增量微调。重训时要特别注意把新积累的人工标注数据加进去并且重新检查标注一致性——如果新数据是新老师标的风格可能和老数据不一致直接混在一起训会拉低模型。我的做法是先在新数据上算一下它和旧数据分布的重叠度差异大的话先做一轮对齐再合并。这个习惯是从一次事故里养成的当时连续三个月没检查模型分数整体往某个方向漂了 0.3 分用户投诉了一批之后才发现是新增标注的风格问题。最后分享一个我在实际项目里反复验证过的经验QWK 提升最明显的动作往往不在模型上而在数据清洗和阈值优化上。我经手的几个项目从换模型架构上拿到的提升平均不到 0.02而从标注一致性清洗和后处理阈值搜索上平均能拿到 0.05 到 0.08。所以如果你手里的资源有限先把数据洗干净、把后处理做扎实再去折腾模型结构性价比会高得多。
返回列表