☰
Python+Django主观题自动阅卷系统:关键词匹配与文本相似度混合评分实战
2026/10/2 1:11:56 网站建设 项目流程

简介:面向计算机相关专业毕业设计或项目实战场景的作品,以Python与Django为核心,实现主观题在线作答与自动阅卷流程。系统功能覆盖系统首页、在线考试、试题管理、试卷管理、成绩管理和用户管理等模块,贴近高校教务与考试业务需求。资源共316个文件,整体约8.92MB,其中包含28个Python源码文件、16个HTML页面、36个JavaScript脚本、20个CSS样式表及1个SQL数据库脚本,另有大量gif动图用于过程演示,结构上兼顾了程序、页面与说明材料。压缩包内还配有录像演示与说明文档,便于从零理解项目搭建和模块运行逻辑。系统核心是Django框架串联出题、作答、阅卷与成绩统计的完整流程,并集成Bootstrap、Layui等前端组件,页面效果较为完整。已有232人学习查看,适合正在做选题、需要参考完整可运行项目的学生快速对照实践。

1. 主观题自动阅卷系统:毕设里最像“AI”却最吃工程细节的项目

每年毕业设计都有一批学生选“主观题自动阅卷系统”,听上去是人工智能,实际拆开看,核心是一个“文本相似度 + 关键词匹配 + 规则评分”的 Web 项目。基于 Python+Django 做这个题目,最大的优势是框架自带 Admin 后台、表单处理和数据库迁移,你只需要把阅卷算法写对,就能在两周内拼出一个能演示、能答辩、能交差的完整系统。这篇笔记要解决的不是“怎么把代码跑起来”,而是“怎么让机器给的分数接近老师手改的分数”——那才是这个项目真正的分水岭。适合正在做毕设、或者想在公司内部做一套简易在线测验系统的开发者。

2. 先想清楚阅卷判什么:文本相似度与关键词的权衡

2.1 主观题阅卷的三条技术路线:词频向量、关键词匹配与编辑距离

主观题自动阅卷,最忌讳一上来就写代码。先要问一个问题:机器阅卷到底在看什么?人类老师改简答题,心里其实有两把尺子——第一,答案里有没有踩中得分点;第二,表述是否接近参考答案。机器的实现思路也逃不出这两把尺子。

常见做法有三条路线。第一条是余弦相似度:把参考答案和学生答案分别转成词频向量或 TF-IDF 向量,计算两个向量夹角的余弦值,越接近 1 说明越像。这条路线用sklearn的TfidfVectorizer就能实现,代码量极小,但纯靠它做主观题阅卷不靠谱,原因后面避坑章节会展开。

第二条是关键词匹配:从参考答案里人工或自动提取几个关键词,逐个去学生答案里找,按命中比例给分。比如“刑法的基本原则”这道题,参考答案里拆出“罪刑法定”“人人平等”“罪责刑相适应”三个得分点,学生答中两个就给 2/3 的分。这条路线的优点是解释性强,答辩时老师问你“为什么给这个分”,你可以直接打开评分明细说“他命中了两条关键词”。缺点是对同义词和语序变化很敏感,学生写“法律面前人人平等”而不是“适用刑法人人平等”,就漏判了。

第三条是编辑距离 / 序列匹配,用difflib.SequenceMatcher或Levenshtein距离算两个句子有多“像”。这条路适合短文本、且对错分明的情况,比如填空题的延伸题;一旦答案超过三句话,编辑距离就被各种插入、倒装、同义替换打得七零八落。下表是三条路线的直观对比:

路线实现成本可解释性抗口语化能力适合题型
余弦相似度低弱中长答案、论述题
关键词匹配低强中简答题、名词解释
编辑距离最低中弱短句、固定表述

2.2 选型结论:关键词权重 + 余弦相似度混合打分,理由有三

我一般会给学生推荐“关键词命中率 + 余弦相似度”的混合评分方案,而不是单选其中任何一条。理由有三个。

第一,可解释性决定你能不能过答辩。系统内部可以是一个黑匣子,但答辩演示时必须让评委看到评分依据。混合方案把总分拆成“关键词分 + 相似度分”,每一分都有出处。第二,中文主观题的答案普遍是 50 到 200 字的短文本,分词之后特征稀疏,纯靠 TF-IDF 余弦相似度,两句意思相同但用词完全不同的答案可能算出 0.2 的相似度,学生直接得零分,这在教学场景里是不可接受的。第三,关键词匹配单独使用的误伤率也很高——学生答案里口语化地换了个说法,关键词就“消失”了,但整句话读起来其实是对的。混合打分相当于给两条路子互相兜底。

这里给出一个最常用的评分公式:

总分 = 100 * (关键词命中率 * 0.6 + 余弦相似度 * 0.4)

其中关键词命中率 = 命中的关键词数 / 参考答案关键词总数。0.6 和 0.4 是权重,后面章节会讲怎么调。这个方案处理的是文本型主观题,也就是简答题、名词解释、论述题;数学公式推导、代码题、画图题不在此列,别往这个方案里套。

2.3 相似度计算的两个前置细节:分词与停用词表

不管走哪条路线,中文文本都要先分词。最常用的是jieba,它的精确模式适合阅卷这种“不能乱切词”的场景。分词之后要过滤停用词,也就是“的、了、是、在”这类没有实际语义的词,不滤掉它们,相似度会被严重稀释。另外要注意,答案里常出现“首先、其次、我认为、综上所述”这类套话,可以按业务场景把它们加进自定义停用词表,让特征集中到真正的知识点上。

3. 用 Django 把阅卷逻辑接进 Web:四个 Model 与一条完整请求链路

3.1 先搭骨架:项目结构、依赖与第一个 App

我习惯把阅卷引擎拆成独立的模块,不让算法代码和 Django 视图混在一起。实际动手前先确定技术栈:Python 3.10 以上、Django 4.x LTS,外加jieba和scikit-learn。环境配置这是基本功,把 Python 装好、在 VSCode 里配好解释器再继续。创建项目结构的完整命令如下:

# 创建虚拟环境并激活 python -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate # 安装依赖 pip install django jieba scikit-learn # 创建项目与应用 django-admin startproject exam_system . python manage.py startapp grading

执行完这条命令之后,grading这个 app 里会生成models.py、views.py、migrations等骨架文件。阅卷引擎建议放在grading/services/目录下,新建一个grading_engine.py,视图层只负责调用它、传参和返回结果。

3.2 数据建模:试卷、题目、答卷与评分的四个 Model

models.py的设计直接影响后续代码复杂度。最基本的字段要覆盖四个维度:一套试卷有多道题,每道题有参考答案,每个学生交一份答卷,每道题产生一个评分结果。参考实现如下:

from django.db import models class Exam(models.Model): title = models.CharField('试卷名称', max_length=100) created_at = models.DateTimeField(auto_now_add=True) class Question(models.Model): exam = models.ForeignKey(Exam, related_name='questions', on_delete=models.CASCADE) content = models.TextField('题干') reference_answer = models.TextField('参考答案') keywords = models.CharField( '关键词(逗号分隔)', max_length=500, blank=True, help_text='例如:罪刑法定,人人平等,罪责刑相适应' ) full_score = models.PositiveIntegerField('满分', default=10) class Answer(models.Model): exam = models.ForeignKey(Exam, on_delete=models.CASCADE) student_name = models.CharField('学生姓名', max_length=50) submitted_at = models.DateTimeField(auto_now_add=True) class AnswerItem(models.Model): answer = models.ForeignKey(Answer, related_name='items', on_delete=models.CASCADE) question = models.ForeignKey(Question, on_delete=models.CASCADE) content = models.TextField('学生答案') score = models.FloatField('得分', default=0) detail = models.JSONField('评分明细', default=dict)

这里有两个容易被忽略的设计点。一是Question.keywords字段单独存一份人工标注的关键词,而不是每次阅卷时去reference_answer里现算。原因是自动提取关键词经常把“原则”“制度”这类泛词提出来,打分时会误伤,人工标注虽然费一点时间,但准确性高得多。二是AnswerItem.detail用 JSONField 存评分明细,这是答辩时的“后悔药”,后面避坑章节会细说。

3.3 阅卷引擎:文本清洗、关键词命中与余弦相似度

阅卷引擎是核心中的核心,这段代码负责把前面讲的混合评分方案变成可执行逻辑。分三步走:清洗文本、算关键词命中率、算余弦相似度,最后加权输出。

import jieba import re import unicodedata from sklearn.feature_extraction.text import TfidfVectorizer STOP_WORDS = set('的 了 是 在 和 就 都 而 及 与 着 或 一个 没有 我们 你们 她们'.split()) def clean_text(text: str) -> str: # 1. 全角转半角,避免中文标点干扰分词 text = unicodedata.normalize('NFKC', text) # 2. 去除所有标点符号和数字,只保留中文和字母 text = re.sub(r'[^\u4e00-\u9fa5a-zA-Z]', '', text) return text def extract_keywords(text: str, top_k: int = 5) -> set: words = [w for w in jieba.lcut(clean_text(text)) if w not in STOP_WORDS and len(w.strip()) >= 2] # 简单按词频取前 top_k 个,也可换成 jieba.analyse.extract_tags freq = {} for w in words: freq[w] = freq.get(w, 0) + 1 return set(sorted(freq, key=freq.get, reverse=True)[:top_k]) def keyword_hit_rate(reference_kw: list, student_text: str) -> float: cleaned = clean_text(student_text) hit = sum(1 for kw in reference_kw if kw and kw in cleaned) return hit / len(reference_kw) if reference_kw else 0.0 def cosine_similarity(reference: str, student: str) -> float: vectorizer = TfidfVectorizer(token_pattern=r'\w+', tokenizer=jieba.lcut) tfidf = vectorizer.fit_transform([clean_text(reference), clean_text(student)]) from sklearn.metrics.pairwise import cosine_similarity as cos_sim return float(cos_sim(tfidf[0:1], tfidf[1:2])[0][0]) def grade(reference: str, student: str, keywords: list, full_score: int = 10): kw_score = keyword_hit_rate(keywords, student) sim_score = cosine_similarity(reference, student) # 关键词权重 0.6,相似度权重 0.4 total = full_score * (kw_score * 0.6 + sim_score * 0.4) return { 'score': round(total, 1), 'keyword_hit_rate': round(kw_score, 3), 'similarity': round(sim_score, 3), 'hit_keywords': [kw for kw in keywords if kw in clean_text(student)] }

这段代码有三个关键点。第一,clean_text里用unicodedata.normalize('NFKC')做全角转半角,这一步不做,学生会因为输入法切到全角模式,答案里带全角逗号和空格,分词结果完全变形。第二,keyword_hit_rate用的是子串包含判断,也就是学生在答案里写出“罪责刑相适应”的任意部分,只要连续字符能匹配就算命中,这比要求整词完全匹配更宽容,也更贴近人工判卷。第三,cosine_similarity每次调用都会重新 fit 一个 TF-IDF 模型,数据量小没问题,200 份答卷同时提交时会明显变慢,性能优化在避坑章节里给方案。

3.4 提交答卷的视图:把引擎挂到 Django 请求链路上

有了模型和引擎,下一步就是通过视图把它们串起来。以下代码实现两个接口:一个是学生提交整张答卷,另一个是后台查看某道题的评分明细。

import json from django.views.decorators.csrf import csrf_exempt from django.http import JsonResponse from .models import Exam, Question, Answer, AnswerItem from .services.grading_engine import grade @csrf_exempt def submit_exam(request): if request.method != 'POST': return JsonResponse({'error': '仅支持 POST'}, status=405) # 预期格式:{ "exam_id": 1, "student": "张三", # "answers": [{ "question_id": 1, "content": "..." }] } data = json.loads(request.body) exam = Exam.objects.get(id=data['exam_id']) answer = Answer.objects.create( exam=exam, student_name=data['student'] ) results = [] for item in data['answers']: question = Question.objects.get(id=item['question_id']) # 从题库取该题的关键词列表 keywords = [kw.strip() for kw in question.keywords.split(',') if kw.strip()] # 调用阅卷引擎 result = grade( reference=question.reference_answer, student=item['content'], keywords=keywords, full_score=question.full_score ) answer_item = AnswerItem.objects.create( answer=answer, question=question, content=item['content'], score=result['score'], detail=result ) results.append({ 'question_id': question.id, 'score': result['score'] }) return JsonResponse({'answer_id': answer.id, 'results': results})

这个视图的请求链路是:前端 POST 答卷 JSON → Django 反序列化 → 逐题调用grade()→ 评分结果和明细一起落库 → 返回总分列表。特别提醒一点,detail字段别省,它存了关键词命中率和相似度两个原始分,一旦某项得分不合理,你可以打开后台看是哪部分拉低了分数。

4. 评分参数怎么拧:三个必调参数与参考答案库的写法

4.1 三个必调参数:关键词权重、相似度权重与最低分词长度

混合评分不是搭好框架就能直接用,参数不调,分数会明显偏离人工判断。最值得调的参数有三个,都在上一章的grade()函数里。

第一个是关键词覆盖率权重和相似度权重的配比。我见过很多人默认写 0.5 和 0.5,实际跑下来会发现一个尴尬情况:学生把得分点都答全了,只是表述啰嗦,相似度分数反而把总分拉低了。对简答题,关键词权重要往上调,0.6 对 0.4 是相对安全的起点;对论述题,表述质量更重要,可以把相似度权重拉到 0.5。第二个是相似度阈值:如果某道题的两个关键词都没命中,而且相似度低于 0.3,基本可以判定学生答的是无关内容,直接判零分比硬算一个 2.5 分更合理。第三个是分词保留长度,extract_keywords里len(w.strip()) >= 2这个条件过滤了所有单字词,单字词在中文里大多是“法、权、罪”这种泛化概念,留着只会制造噪声。

参数推荐初始值调高时的效果调低时的效果
关键词权重0.6更看重踩点更看重表述
相似度阈值0.3更容易判跑题更容易给同情分
最小分词长度2关键词更精准关键词更全但更噪

4.2 参考答案库怎么写:一句话题目拆出 3 到 5 个得分点

很多项目做到一半翻车,不是代码问题,而是参考答案库写得太随意。比如题目“简述刑法的基本原则”,参考答案写“刑法有三个基本原则,分别是罪刑法定、适用刑法人人平等、罪责刑相适应”,关键词直接自动化从这句话里提取,取出来大概率是“刑法”“基本原则”“适用”“分别”这类词,真正的得分点一个都没进关键词库。

正确答案库的写法是人工拆得分点。以这道题为例,关键词应该写成罪刑法定,适用刑法人人平等,罪责刑相适应,三个关键词对应三个得分点,每个得分点占三分之一的分。原则很简单:一道题的关键词数量控制在 3 到 5 个,太少则区分度不够,太多则学生很难全部命中,分数会普遍偏低。另外要注意不要把整个短语塞进一个关键词,比如“适用刑法人人平等”这种八个字的短语,学生写了“法律面前人人平等”就匹配不上,正确的做法是拆成“适用刑法”“人人平等”两个短词,命中率会高很多。

4.3 把参数放进 Django Settings 而不是写死在代码里

参数写死在grading_engine.py里是毕设项目最常见的坏味道。答辩时老师让你把关键词权重从 0.6 改成 0.7,你要改代码重启服务,现场体验非常糟糕。正确做法是把参数收进 settings。在exam_system/settings.py里加一段:

AUTO_GRADING_CONFIG = { 'keyword_weight': 0.6, # 关键词命中率权重 'similarity_weight': 0.4, # 相似度权重 'zero_threshold': 0.3, # 低于此相似度且未命中关键词则判零分 'min_keyword_len': 2, # 关键词最小长度 'max_answer_len': 2000, # 学生答案最大接受长度,防止超大文本拖垮服务 }

然后在grade()函数里通过配置读取权重,不要直接写数字。这样调参全程不用动算法代码,python manage.py runserver跑着就能演示效果。参数收敛的方法也简单:拿 20 份人工批改过的历史答卷做回归,先固定其他参数,只调关键词权重,记录总分和人工分的平均绝对误差,取误差最小的那组配置。

5. 主观题阅卷避坑:文本编码、乱分词与评分翻车

5.1 全角和半角混用导致相似度崩到 0.1

这是最隐蔽也最常见的问题。学生从 Word 里复制答案到网页时,标点、括号、空格会被自动转成全角,中文和英文混排时还会夹杂半角符号,比如“罪刑法定, 人人平等”,中间那个逗号后面跟着半角空格。分词按空格切,于是“法定”和“人人平等”被硬生生分开,关键词匹配全部落空,余弦相似度直接掉到 0.1。

解决方法是清洗阶段的unicodedata.normalize('NFKC'),它能把全角字符统一转成半角。注意这里是把全角逗号变成半角逗号,然后正则把所有标点清干净,所以顺序必须是:先 normalize,再做re.sub(r'[^\u4e00-\u9fa5a-zA-Z]', '', text)。顺序反了的话,全角符号会被漏掉一部分。这个坑我踩过一次之后,凡是做中文文本处理,清洗函数永远放在所有计算之前。

5.2 学生用口语化表达,关键词一个都没命中

学生写“法律面前所有人都一样平等”,参考答案关键词是“人人平等”,kw in cleaned判断的是连续子串,“所有人都一样平等”里面没有“人人平等”这个连续字符,于是命中率是 0,但这句话意思完全正确。

这里不能只靠机器硬匹配,要做两件事。第一,关键词库提前做同义词扩展,比如“人人平等”旁边加一个扩展词“一律平等”,用字段存扩展词,匹配时任一命中即算得分。第二,把关键词匹配降级为“子串拆分匹配”,把“人人平等”拆成“人人”和“平等”,两个子词都命中才算这个得分点命中。后者误判率更高,但对付口语化答案效果更明显,我一般建议优先做同义词扩展,比较可控。

5.3 答案超过一千字,请求卡死

一道论述题要求 500 字左右,但学生粘贴了一篇 3000 字的论文片段进来。TfidfVectorizer对这种长文本的 fit 耗时急剧上升,加上每次请求都要重新加载jieba词典,整张卷子所有的题串行计算,一个请求能卡十几秒。

我的处理方案有三层。第一层,在 settings 里限制max_answer_len,超出部分直接截断或拒绝提交,这是保底策略。第二层,把vectorizer和jieba初始化提到模块加载时完成,不要让每个请求都重新加载词典,改成模块级单例对象。第三层,用functools.lru_cache缓存同一道题、同一份参考答案的 TF-IDF 向量矩阵,因为同一道题的参考答案向量是固定的,没必要每次重新计算。

from functools import lru_cache @lru_cache(maxsize=128) def get_reference_vector(question_id: int, reference: str): vectorizer = TfidfVectorizer(token_pattern=r'\w+', tokenizer=jieba.lcut) return vectorizer.fit_transform([clean_text(reference)])

把题目 ID 作为缓存 key,同一道题无论多少学生作答,参考答案向量只算一次。这是性价比最高的性能优化。

5.4 评分过程没有留下痕迹,复查全靠重跑

答辩的时候评委问“这道题为什么给八分不给你分”,如果你只能回答“系统算出来的”,那基本等于送命。评分必须留痕。AnswerItem.detail字段就是为了这个场景。它至少存三样东西:关键词命中列表、关键词命中率、相似度得分。再把原始答案和参考答案都落库,任何时候复查都能还原当时的评分依据。

我建议你在此基础上加一个“人工复核”字段:is_reviewed和reviewed_score。当机器分数与预期偏差超过 3 分时,老师可以在后台手动改分并标记。这个功能让系统从“全自动”变成“自动初评 + 人工终审”,在实际教学场景里更可信,答辩时也是一个不错的加分亮点。

5.5 参考答案本身就是错的,怎么调参数都没用

最后一个坑不在代码里,在数据里。有些题目参考答案写得很烂,可以是一段话里只有一句话是得分点,其他全是过度展开的废话。这种题无论怎么调关键词权重,机器给分都会偏高——因为相似度部分把废话也算进去了。

排查方法是把每道题的参考关键词覆盖率分布拉出来看。如果某道题所有学生都只命中一两个关键词,其他关键词永远没人命中,先别急着调权重,回去改参考答案和关键词库。参数只能微调,数据错了,参数救不回来。这是一个很反直觉的结论:自动阅卷系统上线前,花最多时间的往往是整理题库而不是调算法。

6. 从“能跑”到“敢用”:用 50 份人工批改样本校准评分

6.1 建一个回归集,用平均绝对误差判断评分是否可靠

系统能跑只是起点,敢用才是目的。我会建议你从真实作业里抽 50 份答案,每份都让老师人工打分,然后把同样 50 份跑一遍系统,计算机器分与人工分的平均绝对误差(MAE)。举个例子,如果人工分是 8、7、9,机器分是 7.5、6.5、8.5,MAE 就是 0.5 分,这个误差在 ±1 分以内就算及格。超出 2 分就要回头调权重和关键词库。另一种更直观的口径是“误差不超过 ±2 分的答案占比”,占比达到 90% 以上,一般就敢对外演示了。

6.2 一个低成本提分技巧:按“得分块”给分,避免整段一票否决

最后分享一个我经常建议的改进方向。大多数学生答案不是一口气写下来的,而是分点作答。整段算关键词命中率时,学生先写了一句废话,再写得分点,废话稀释了整体关键词密度,得分可能反而偏低。把评分单位从“整段答案”改成“按句切块”:用句号、分号、换行符把学生答案切成若干小句,对每个小句独立算关键词命中率和相似度,最后取所有小句里分数最高的那一个或两个作为本题得分。

这样做的道理在于,主观题阅卷不应该惩罚“多答了无关内容”,只要学生把得分点写出来,就应该给分。切块后,一句废话对应一个低分块,真正的得分点单独成块拿到高分,总分自然更接近人工判卷。这一步改动只有十几行代码,但评分合理性提升非常明显,也是“从能跑到敢用”的最后一块砖。我自己的教训是:一定要把评分明细做成可视化,让老师能看到“哪些关键词命中、哪些句子贡献了分数”。一开始我觉得太麻烦没做,结果校级验收时评委一问评分依据,现场打开后台逐个数字解释,才勉强过关。后来我把明细做成表格,任何分数都能追查到底,这个系统才真正从演示品变成可用的工具。希望上面的实现细节能帮你少踩几个坑,让这套主观题自动阅卷系统真正落地。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询