構(gòu)的K12作文自動評分系統(tǒng):從count文件到可解釋評分報告)
簡介這份資源是面向K-12教育場景的Python自動作文評分系統(tǒng)實現(xiàn)包適合NLP入門學(xué)習(xí)者、教育技術(shù)開發(fā)者及需要批量評分的教師參考。系統(tǒng)圍繞篇章結(jié)構(gòu)展開涵蓋詞法分析、句法分析、語義理解、段落連貫性與主題發(fā)展識別并引入SVM、決策樹、隨機森林及神經(jīng)網(wǎng)絡(luò)等模型學(xué)習(xí)人工評分模式同時包含特征工程、文本預(yù)處理與后處理、交叉驗證評估及用戶界面設(shè)計等完整流程。壓縮包共112個文件約2.04MB以py腳本、txt文本、count統(tǒng)計文件、png圖表、xml配置和xlsx數(shù)據(jù)為主另有訓(xùn)練與結(jié)果類文件便于復(fù)現(xiàn)實驗與查看中間產(chǎn)物。目前已有265人學(xué)習(xí)下載。通過該資源讀者可獲取從文本清洗、特征提取到模型訓(xùn)練與評分輸出的完整代碼鏈路理解篇章結(jié)構(gòu)特征如何影響作文得分并借助統(tǒng)計文件與圖表分析模型表現(xiàn)為二次開發(fā)或教學(xué)演示提供可運行的基礎(chǔ)方案。1. 從一份“模型結(jié)果清單”說起這套 K12 作文評分系統(tǒng)到底交付了什么先看一組文件名ADMResult、ADMTrainLM、BGResult、BGTrainLM、EGTrain.txt.count、REXPTrain.txt.count、BGTrain.txt.count、RTTTrain.txt.count、EEXPTrain.txt.count、RSTrain.txt.count。第一次拿到這個包的人大概率會愣一下——沒有main.py沒有requirements.txt只有一堆以Result、TrainLM、.count結(jié)尾的文件。這恰恰是它的真實形態(tài)這不是一個開箱即用的 Web 應(yīng)用而是一套基于篇章結(jié)構(gòu)的自動作文評分系統(tǒng)的訓(xùn)練產(chǎn)物與中間統(tǒng)計用 Python 把 K12 作文的篇章特征抽出來、訓(xùn)成語言模型、再落成分數(shù)預(yù)測結(jié)果。它能解決的問題很具體K12 階段一個語文老師帶兩個班一次作文上百份人工批改一篇要 3 到 5 分鐘全批完就是大半天。這套系統(tǒng)把「篇章結(jié)構(gòu)」當作評分主線——不是只看詞藻和錯別字而是看段落之間怎么銜接、主題句怎么展開、過渡是否自然——然后用統(tǒng)計語言模型和回歸模型去擬合人類評分員的打分習(xí)慣。適合誰適合已經(jīng)會 Python 基礎(chǔ)、想拿一套真實教育 NLP 數(shù)據(jù)練手的中小學(xué)信息化老師、教育科技方向的在校生以及需要給作文批改做輔助工具的教研人員。它不承諾替代人工但能把「初篩 客觀維度打分」這一步自動化把老師的精力留給真正需要主觀判斷的部分。2. 篇章結(jié)構(gòu)特征怎么抽從原始作文到.count統(tǒng)計文件這套系統(tǒng)的核心資產(chǎn)是那些.count文件。它們不是隨便生成的日志而是把每篇作文按篇章結(jié)構(gòu)切分后統(tǒng)計出的 n-gram 頻次。理解這一步后面所有TrainLM和Result才有意義。2.1 為什么評分主線選“篇章結(jié)構(gòu)”而不是“詞匯豐富度”純詞匯維度的評分很容易被“堆砌華麗詞藻但邏輯混亂”的作文騙過去。K12 作文評分標準里內(nèi)容、結(jié)構(gòu)、語言三項中結(jié)構(gòu)往往是最能區(qū)分檔次的一項。常見做法是先把作文按段落切開識別每段的主題句通常是段首句再計算相鄰段落之間的詞匯重疊度、指代銜接詞密度、過渡詞命中率。這些指標合起來構(gòu)成“篇章連貫性”特征向量。我一般會這樣組織特征特征類別具體指標作用段落級段落數(shù)、平均段長、段長方差反映結(jié)構(gòu)是否勻稱銜接級相鄰段詞匯重疊率、代詞密度反映段落是否連貫過渡級過渡詞命中數(shù)因此/然而/總之反映邏輯顯性程度主題級段首句與標題的相似度反映是否跑題這些特征抽完之后才會進入語言模型訓(xùn)練。.count文件就是這些統(tǒng)計的落盤結(jié)果命名里的BG、EG、REXP、RTT、EEXP、RS大概率對應(yīng)不同的特征子集或不同的作文分組比如按文體或按年級分桶。2.2 用 Python 把作文切成篇章單元并生成 count 文件下面這段腳本是我按這套系統(tǒng)的數(shù)據(jù)形態(tài)反推的生成邏輯跑之前把raw_essays/換成你的作文目錄每篇一個.txt。import os import re from collections import Counter # 中文過渡詞表可按學(xué)段增刪 TRANSITIONS [因此, 然而, 總之, 首先, 其次, 最后, 而且, 但是, 所以] def split_paragraphs(text): # 按換行和中文全角空格切段過濾空段 paras re.split(r\n, text.strip()) return [p.strip() for p in paras if len(p.strip()) 5] def char_ngrams(text, n2): # 字符級 n-gram對中文比詞級更穩(wěn)不依賴分詞器 text re.sub(r\s, , text) return [text[i:in] for i in range(len(text)-n1)] def extract_features(text): paras split_paragraphs(text) feats {} feats[para_num] len(paras) feats[avg_para_len] sum(len(p) for p in paras) / max(len(paras), 1) # 相鄰段詞匯重疊率 overlaps [] for i in range(len(paras)-1): a, b set(char_ngrams(paras[i])), set(char_ngrams(paras[i1])) if a and b: overlaps.append(len(a b) / len(a | b)) feats[adj_overlap] sum(overlaps) / max(len(overlaps), 1) # 過渡詞命中 feats[transition_hit] sum(text.count(t) for t in TRANSITIONS) return feats, paras def dump_count(paras, out_path): # 把所有段落拼起來做 n-gram 統(tǒng)計寫入 .count counter Counter() for p in paras: counter.update(char_ngrams(p, n2)) with open(out_path, w, encodingutf-8) as f: for gram, cnt in counter.most_common(): f.write(f{gram}\t{cnt}\n) if __name__ __main__: src_dir raw_essays out_dir counts os.makedirs(out_dir, exist_okTrue) for name in os.listdir(src_dir): if not name.endswith(.txt): continue with open(os.path.join(src_dir, name), encodingutf-8) as f: text f.read() feats, paras extract_features(text) stem name.replace(.txt, ) dump_count(paras, os.path.join(out_dir, f{stem}.txt.count)) print(stem, feats)邏輯說明split_paragraphs負責(zé)把整篇作文切成篇章單元這是“篇章結(jié)構(gòu)”四個字的落地起點char_ngrams用字符級二元組而不是詞級是因為 K12 作文里分詞錯誤會直接污染特征字符級更抗噪。extract_features里的adj_overlap是篇章連貫性的核心指標值越低說明段落之間越“跳”。dump_count把每篇作文的 n-gram 頻次寫成制表符 計數(shù)的格式這正是.count文件該有的樣子。參數(shù)說明n2可以改成 3 提升區(qū)分度但文件會變大TRANSITIONS表按學(xué)段調(diào)整小學(xué)低年級可以只留“首先/然后/最后”。跑完你會得到一批XXX.txt.count和資源里的命名規(guī)律對得上。2.3TrainLM與Result的關(guān)系訓(xùn)練產(chǎn)物和預(yù)測產(chǎn)物別搞混ADMTrainLM、BGTrainLM這類文件是訓(xùn)練階段的語言模型產(chǎn)物通常是 n-gram 概率表或模型參數(shù)序列化結(jié)果ADMResult、BGResult是預(yù)測階段對測試作文打分后的輸出。很多人第一次用會直接打開Result想找分數(shù)結(jié)果看到一堆浮點數(shù)就懵了——那些浮點數(shù)就是每篇作文的預(yù)測分或各維度得分。判斷方法很簡單文件名帶Train的是訓(xùn)練中間件帶Result的是最終輸出。用之前先確認你的測試集和訓(xùn)練集命名前綴一致否則Result里的順序?qū)Σ簧献魑摹?. 把 count 喂給模型訓(xùn)練、打分與結(jié)果落盤有了.count和特征下一步是訓(xùn)練一個能預(yù)測分數(shù)的模型。這套系統(tǒng)沒有限定必須用哪個算法但從它保留TrainLM的形態(tài)看走的是“統(tǒng)計語言模型 回歸”的經(jīng)典路線而不是端到端深度網(wǎng)絡(luò)。原因也現(xiàn)實K12 作文標注數(shù)據(jù)量通常只有幾百到幾千篇上 BERT 容易過擬合SVM 或嶺回歸反而穩(wěn)。3.1 從 count 文件還原特征矩陣import os import numpy as np from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import Ridge def load_counts(count_dir): docs, names [], [] for fn in sorted(os.listdir(count_dir)): if not fn.endswith(.count): continue grams [] with open(os.path.join(count_dir, fn), encodingutf-8) as f: for line in f: parts line.strip().split(\t) if len(parts) 2: gram, cnt parts[0], int(parts[1]) grams.extend([gram] * min(cnt, 5)) # 截斷高頻防長尾主導(dǎo) docs.append( .join(grams)) names.append(fn.replace(.txt.count, )) return docs, names def train_and_predict(count_dir, score_csv): docs, names load_counts(count_dir) vec TfidfVectorizer(max_features5000, ngram_range(1, 2)) X vec.fit_transform(docs) # score_csv: 兩列作文名,人工分 import csv y_map {} with open(score_csv, encodingutf-8) as f: for row in csv.reader(f): y_map[row[0]] float(row[1]) y np.array([y_map[n] for n in names]) model Ridge(alpha1.0) model.fit(X, y) preds model.predict(X) for n, p in zip(names, preds): print(f{n}\t{p:.2f}) return model, vec if __name__ __main__: train_and_predict(counts, scores.csv)邏輯說明load_counts把.count里的gram 頻次還原成可被TfidfVectorizer吃的文本min(cnt, 5)是關(guān)鍵——n-gram 頻次長尾極重不截斷的話高頻虛詞會主導(dǎo)整個向量。Ridge用 L2 正則適合特征維度高、樣本少的場景alpha越大正則越強。輸出直接打印每篇作文的預(yù)測分這就是Result文件該有的內(nèi)容。參數(shù)說明max_features5000在千篇級數(shù)據(jù)上夠用數(shù)據(jù)上萬可以提到 20000ngram_range(1,2)同時保留單字和雙字兼顧詞匯和搭配。如果你的scores.csv里作文名和 count 文件名對不上y_map[n]會直接 KeyError這是最常見的翻車點。3.2 交叉驗證與評分穩(wěn)定性檢查單次訓(xùn)練集打分沒有意義必須做 K 折交叉驗證看泛化。常見做法是KFold(n_splits5, shuffleTrue, random_state42)每折算一次 MAE 和 Pearson 相關(guān)系數(shù)。K12 作文評分里MAE 能壓到 3 分以內(nèi)滿分 50 分制就算可用Pearson 上 0.7 說明和人工評分趨勢一致。如果某一折 MAE 突然飆高先查那折里是不是混進了字數(shù)極短或極長的異常作文而不是急著換模型。3.3 結(jié)果后處理分數(shù)平滑與異常值截斷原始預(yù)測分經(jīng)常出現(xiàn) 47.3、47.8 這種帶小數(shù)的值直接展示給老師很怪。后處理兩步一是按滿分區(qū)間截斷二是做一次簡單平滑。我一般會把預(yù)測分映射到整數(shù)檔位比如 5 分一檔同時保留原始分用于排序。異常值處理上如果某篇作文的預(yù)測分和同題其他作文偏離超過 2 個標準差標記為“待人工復(fù)核”而不是直接采信。這一步在Result落盤前做能省掉大量事后返工。4. 避坑與排查這套資源最容易翻車的五個地方4.1 現(xiàn)象.count文件打開是亂碼或空行原因原始作文編碼不是 UTF-8或者切段后段落長度都小于閾值被過濾光了。解決讀文件時顯式指定encodingutf-8失敗就試gbk把split_paragraphs里的len(p.strip()) 5臨時調(diào)到 1看是不是過濾太狠。4.2 現(xiàn)象訓(xùn)練時KeyError或標簽全為 0原因scores.csv里的作文名和.count文件名前綴不一致比如一個是ADM01一個是ADM01.txt。解決統(tǒng)一在load_counts里做一次name.replace(.txt.count,).replace(.count,)歸一化再和標簽表對齊。4.3 現(xiàn)象模型在訓(xùn)練集上 MAE 很低換一批作文就崩原因特征里混入了和分數(shù)強相關(guān)的“作弊特征”比如作文長度。長作文天然容易得高分但模型學(xué)到的是“長高分”而不是“結(jié)構(gòu)好高分”。解決把para_num、總字數(shù)這類特征做標準化或直接剔除只保留結(jié)構(gòu)比例類特征。4.4 現(xiàn)象TrainLM文件加載報序列化錯誤原因訓(xùn)練和加載用的 Python 或依賴庫版本不一致pickle 協(xié)議不兼容。解決確認訓(xùn)練環(huán)境版本或者改用 JSON 存模型參數(shù)。這類文件不要跨大版本直接搬。4.5 現(xiàn)象預(yù)測分全部擠在中間檔區(qū)分度差原因回歸模型在樣本不均衡時傾向于預(yù)測均值。解決對分數(shù)做分箱后改用分類模型或者給 Ridge 加樣本權(quán)重讓高分和低分樣本權(quán)重更大。5. 進階把篇章特征做成可解釋的評分報告到這一步系統(tǒng)能打分了但老師真正想要的不是一個小數(shù)而是“為什么是這個分”。我的做法是在Ridge之上再掛一層特征貢獻分析對每篇作文取model.coef_和該作文的 TF-IDF 向量做逐維乘積排序后取絕對值最大的前 10 個 n-gram再映射回它們所屬的篇章特征類別銜接、過渡、主題。這樣輸出的報告長這樣維度貢獻方向代表 n-gram段落銜接正向“因此”“綜上”過渡顯性正向“首先”“其次”主題偏離負向與標題低重疊的段首詞def explain(model, vec, doc, topk10): x vec.transform([doc]) contrib x.toarray()[0] * model.coef_ idx np.argsort(np.abs(contrib))[::-1][:topk] inv {v: k for k, v in vec.vocabulary_.items()} return [(inv[i], contrib[i]) for i in idx] # 用法explain(model, vec, docs[0])邏輯說明contrib是每個 n-gram 對最終分數(shù)的線性貢獻正負號代表拉高還是拉低。inv把索引還原成 n-gram 本身。參數(shù)topk控制報告長度給老師看 10 個足夠給教研分析可以放到 30。驗證方法上我習(xí)慣抽 20 篇人工已評的作文把系統(tǒng)報告和人工評語并排看如果系統(tǒng)標出的“負向貢獻”詞恰好是老師批注里圈出的問題說明解釋層可信如果系統(tǒng)把老師認為寫得好的過渡詞標成負向那多半是訓(xùn)練集里這類詞出現(xiàn)太少需要補樣本。從那以后我每次上線新模型前都強制走一遍這 20 篇的對照不看過一遍不敢把報告發(fā)給老師。希望這套拆解能幫到你拿到資源后先跑通.count生成再談模型調(diào)參順序反了會多走很多彎路。本文還有配套的精品資源點擊獲取