简介:这套由吉林多所名校联合命制的2017年七年级下学期第一次月考数学卷,适合刚进入下学期的学生用于阶段自测,也适合教师用来评估教学进度、家长辅助家庭辅导。试卷依据课标和命题要求设计,包含选择、填空、解答等多种题型,在数与代数方面覆盖整数、分数、小数的运算及方程化简,几何部分涉及线段、角度、三角形和四边形的性质与简单证明,统计与概率板块包含图表绘制、平均数、中位数、众数计算及基础事件概率,同时穿插图形平移、旋转、对称等变换问题,并通过实际情境锻炼数学思考与估算能力。资源包内为1个doc文档,大小2.29MB,除完整试题外还配有逐题答案解析,便于学生核对思路、教师分析典型错因;目前已有110人学习浏览。无需注册即可免费下载,既能作为考前模拟卷使用,也可为校本命题或备课提供参考样例。
1. 一份旧试卷比新题库更值得拆解的地方
一份标题叫《吉林名校调研(省命题)2017年七年级下第一次月考数学试卷及答案精选.doc》的旧文档,多数人只会拿它当过期教辅。但换个角度,它是一份完整带答案与解析的数学试题数据集:题目组织、难度排布、答案规范全在文件里。对做教育数据、题库清洗、学情分析甚至智能组卷的人来说,“省命题”卷的真实度远高于网络随机拼题,适合用来验证解析脚本和知识点标注规则。
文档覆盖七年级下学期第一次月考范围,也就是相交线与平行线、实数、平面直角坐标系这些内容。结构基本可预测:选择题、填空题、解答题,题号连续。熟悉这套结构后,从Word里把题目和答案拆出来,写一次脚本,后续同类试卷就能批量处理。学生刷题、老师做阶段评价、开发者拿它当高质量语料,各取所需。
2. 从Word文档逆推出试卷的知识点结构与题型骨架
分析试卷之前,先要看清它的文体层。省命题卷在排版上有很强惯性,这既是便利也是约束。便利在于规则稳定,约束在于个别学校会改动题号或插入附加题。先把这种结构拆出来,再谈识别。
2.1 为什么2017年“省命题”卷适合做结构分析
省命题卷与市统考、学校随堂卷不同,题目经过教研审核,每道题对应的课标条目更清晰。而且这类卷子通常以免费文档形式传播,没有加密层,适合做文本提取。比起扫描版PDF,Word文档天然带段落样式和表格,题号、选项、答案的换行规律相对稳定,给自动标注提供了可复现的基础。2017年的卷子已经足够老,知识点与当前课标接近,又不会太旧到出现大幅删改,做历史题库时是很合适的种子语料。
七年级下学期第一次月考的内容,按当时进度一般在相交线与平行线、实数、平面直角坐标系等章节内出题。这套“精选”文档中,答案和解析是齐全的。做自动分析时,第一步不是让机器学习读题,而是先观察文档的物理结构:题号以“1.”“2.”或“一、选择题”这样的标题开头,选项多夹在题干后或另起一段,答案与解析则集中在文档后半部分。识别这些结构,往往比直接做语义识别更可靠。
| 题型 | 常见文本位置 | 适合提取的知识点 |
|---|---|---|
| 选择题 | 文档前半部分,题干后接A/B/C/D | 相交线与平行线判定 |
| 填空题 | 选择题后,空格处填数值 | 实数与坐标系整数点 |
| 解答题 | 文档后半部分,含“证明”等要求 | 平行线性质、实数混合运算 |
这张表描述的是该阶段常见卷的布局。实际拿到文档后,我会用下面的方式验证布局假设:统计“选择题”这个关键词出现的位置,把文档前半段和后半段分开检索,看答案区与题目区的分界点在哪一段。
2.2 识别题型与答案的通用标注规则
所有题型识别都基于文本模式。经典逻辑是:先按换行拆分段落,再根据段落首部匹配题号,然后在段落内部按A. B. C. D.切出选项。对解答题,则以“解答题”或“三、”为进入标记。下面这个例子只依赖标准库的re,可以跑在离线环境,正好适合处理这类Word导出的文本。
import re def detect_question_block(text): # 将文本按照行切分,去掉空行 lines = [ln.strip() for ln in text.splitlines() if ln.strip()] questions = [] current = None for line in lines: # 匹配 1. 1. 1、 三种题号写法 m = re.match(r'^(\d+)[..、]\s*(.*)', line) if m: if current: questions.append(current) current = {'qid': m.group(1), 'body': m.group(2), 'options': [], 'answer': None} else: # 匹配 A. B. C. D.,也兼容 A. opt = re.match(r'^([A-Da-d])[..、]\s*(.*)', line) if opt and current is not None: current['options'].append(f"{opt.group(1)}: {opt.group(2)}") elif current is not None: current['body'] += " " + line if current: questions.append(current) return questions逻辑说明:函数把文本切成非空行后,逐行做两件事:匹配题号,或者匹配选项。匹配到新题号时,将前一道题推入结果列表。选项单独保存,没有匹配到的普通行拼接到题干后面,避免题干被换行截断。qid字段保留字符串类型,方便后续排序;options统一改成“A: 内容”的格式,省去原始分隔符差异。
参数说明:正则^(\d+)[..、]同时兼容英文句点、中文句号和顿号,因为教辅文档里经常混用。[A-Da-d]只匹配单字母选项,如果文档出现“(A)”这种带括号的,正则要改成^[\((]?([A-Da-d])[\))]?[..、]。处理真实文件时,我会先用chardet做字符集检测,再决定以什么编码打开文本,否则很容易被UnicodeDecodeError卡住。
提示:从Word里复制的文本段落经常带大量空白和制表符,建议在切行前先用
re.sub(r'[ \t]+', ' ', text)把连续空白收敛成单个空格,降低干扰。
这一层的本质不是自然语言理解,而是把版面位置转化为键值结构。后面无论做题库还是学情分析,都基于这一层结构化结果。另外需要注意,七年级下第一次月考的图形题较多,纯文本抽取只能看到“如图”这类占位词,真正的图形需要单独处理,这正好是第三章要解决的问题。
3. 用python-docx把月考试卷拆成可检索的题目清单
上一篇的结构化偏重“从文本到题号”,但实际拿到的文档是.doc二进制格式,直接用文本方式打开会看到乱码。所以要先解决格式兼容问题,再用docx接口拆解段落与表格。
3.1 先处理.doc的老格式兼容问题
原始文件名是.doc,python-docx只支持Office Open XML格式,直接打开会抛异常。常见做法是先用LibreOffice或Word把文件转成.docx,再从.docx抽取。如果机器上装了LibreOffice,一条命令可以批量转换:
soffice --headless --convert-to docx --outdir ./converted ./input/JL2017.doc命令说明:--headless要求不启动GUI,--convert-to docx指定目标格式,--outdir指定输出目录,最后给输入文件路径。转换产生的排版差异基本不影响文本内容。如果环境是Windows且没有LibreOffice,可以用COM调用Word的Document.SaveAs,但那会逐个启动进程,不适合批量跑,我一般优先用LibreOffice。
| 转换方式 | 依赖 | 优点 | 缺点 |
|---|---|---|---|
| LibreOffice headless | soffice | 跨平台,可批量 | 需要安装 |
| Word COM | MS Office | 排版保真度高 | 只能在Windows/macOS |
| 在线转换服务 | 网络 | 零安装 | 不能批量且可能有扰 |
如果转换后的docx里面找不到图片,先确认原.doc是否包含OLE嵌入对象,而不是普通图片。LibreOffice在转换时可能只是重新封装OLE,这种情况需要先转PDF再从PDF截图像,我会在3.3里给出参考做法。
3.2 按段落和表格抽取题目与答案
python-docx的接口很直接:Document.paragraphs返回所有段落,Document.tables返回所有表格。一套试卷的题干、选项、答案在文档里往往以多个连续段落出现,答案和解析可能被写成表格放在文档末尾。下面的代码演示如何把段落文本和表格都捞出来。
from docx import Document def extract_docx_structure(path): doc = Document(path) body = [] for p in doc.paragraphs: style = p.style.name if p.style else '' # 全角空格统一成半角,保留题干内换行 text = p.text.strip().replace('\u3000', ' ') if text: body.append({'style': style, 'text': text}) for i, table in enumerate(doc.tables): rows = [] for row in table.rows: cells = [c.text.strip().replace('\u3000', '') for c in row.cells] rows.append(cells) body.append({'style': f'TABLE_{i}', 'text': rows}) return body path = 'converted/JL2017.docx' struct = extract_docx_structure(path) for item in struct[:10]: print(item)逻辑说明:这里将段落和表格统一抽象成列表元素,用style和text区分。段落的style能帮助识别“一、选择题”这类标题,表格数据在答案匹配时很有用,因为很多Word版的解析表会把题号、答案、分值放在同一行的不同单元格里。replace('\u3000', ' ')是处理全角空格导致的切分问题,先替换成半角,后面统一清洗。
参数说明:doc.tables中的row.cells在遇到合并单元格时可能返回同一个对象多次,去重需要通过单元格的_tcid,或者干脆按文本值去重。答题区经常出现“B.”这种带中文句号的选项,清洗时要把全角句号转半角;如果你只需要题目文本,可以后面再做过滤,但保留全部结构更保守。
3.3 清洗脏数据:题号、空格、图片占位
Word转存后常见三种脏数据。第一种是题号后面跟制表符或全角空格,导致split出错;第二种是选择题选项被自动编号成数字而不是A、B、C、D;第三种是几何题的图片在文本层只剩一个空行或“图”字。下面这两个函数分别处理文本归一化和图片抽取:
import re import os import zipfile def clean_question_text(raw: str) -> str: # 全角空格转半角,制表符变空格 s = raw.replace('\u3000', ' ').replace('\t', ' ') # 连续空白压成一个空格,便于正则匹配 s = re.sub(r'[ \t]+', ' ', s) # 把题号后的中文句号统一成半角点 s = re.sub(r'^(\d+)[..、]', r'\1.', s) return s.strip() def extract_images_from_docx(path, out_dir='imgs'): os.makedirs(out_dir, exist_ok=True) with zipfile.ZipFile(path) as z: names = [n for n in z.namelist() if n.startswith('word/media/') and not n.endswith('/')] for i, name in enumerate(names): data = z.read(name) ext = name.rsplit('.', 1)[-1] if ext.lower() not in ('png', 'jpg', 'jpeg', 'gif'): ext = 'img' with open(os.path.join(out_dir, f'img_{i}.{ext}'), 'wb') as f: f.write(data) return len(names)逻辑说明:clean_question_text不改变题干,只把标点和空白归一化,例如全角空格转半角、题号后的全角句号转半角点。extract_images_from_docx利用docx就是zip包的特性,从word/media目录里把图片全部解出,按出现顺序编号。返回的图片数量可以用来和题目数量做对比,如果某道几何题没有对应图片,多半是原文档里用了文本框而不是嵌入图。
参数说明:zipfile.ZipFile可以直接处理.docx,因为它的本质是zip压缩包。names列表按文档打包顺序返回,通常对应插入先后,但顺序并非绝对可靠,严谨的做法是读取word/document.xml里的r:embed关联,这里不做展开。过滤掉文本扩展名是为了防止把字体文件当图片导出。
提示:如果在转换后的docx里找不到图片,优先检查原.doc是否为OLE嵌入格式。LibreOffice对OLE对象只是重新封装,不会变成可解出的JPG;这种情况建议改用
soffice --convert-to pdf,然后再用PDF渲染器把页面转成PNG,代价是图片位置需要用坐标来对齐。
最后在跑完整流程前,建议先做一次统计:段落总数、表格数、图片数,以及“选择题”标记出现的次数。如果题号最多到20,但选择题只有15道,说明中间混入了填空题,这时候要把detect_question_block和当前章节状态结合,而不是单纯依赖题号连续。
4. 把试题和答案解析改造成离线题库数据表
在第3章拿到文档的段落和表格后,接下来的任务是把这些松散结构变成统一的数据表。这样无论是做练习系统还是导成Excel,脚本都只需要读一遍数据接口。
4.1 字段设计:从文本片段到结构化记录
将每道题变成一个字典,字段包括 id、题型、题干、选项、答案、解析、知识点、来源文件。答案和解析从文档末尾答案表提取,而不是靠自然语言推理,这样才能保留原卷权威性。字段表如下:
| 字段 | 类型 | 示例 | 说明 |
|---|---|---|---|
| id | str | "2017JL_01" | 来源卷 + 题号 |
| section | str | "选择" | 题型分组 |
| stem | str | "下列图形中,∠1与∠2是对顶角的是" | 题干 |
| options | list | ["A: ①", "B: ②"] | 选项列表 |
| answer | str | "B" | 标准答案 |
| analysis | str | "对顶角的两边互为反向延长线" | 解析 |
| knowledge | str | "相交线与平行线" | 考点标签 |
| page | int | 2 | 原卷页码,可选 |
字段设计的核心是保证id可追溯。2017JL_前缀能避免后续合并多个学校试卷时冲突。答案区和题目区分离的试卷,必须先把题目读出来,再拿题号去答案表里回填答案,否则会出现题干和答案错位。
4.2 用Python生成JSON题库
下面这段代码把第3章的struct转成题库JSON,并演示如何从表格行回填答案。它假设答案表的结构是“题号 | 答案 | 解析”三列,这也是Word试卷最常见的组织方式。
import json import re def guess_section(style_name: str) -> str: if '选择' in style_name: return '选择' if '填空' in style_name: return '填空' return '解答' def build_question_bank(struct): bank = [] cur = None in_answer_section = False for item in struct: text = item.get('text', '') # 表格元素可能是答案表 if isinstance(text, list): for row in text: if row and len(row) >= 2 and str(row[0]).strip().isdigit(): bank = attach_answer(bank, row) continue if '答案' in text and '解析' in text and '选择题' in text: in_answer_section = True continue if in_answer_section: continue m = re.match(r'^(\d+)\.\s*(.*)', text) if m: if cur: bank.append(cur) cur = { 'id': '2017JL_' + m.group(1), 'stem': m.group(2).strip(), 'options': [], 'answer': '', 'analysis': '', 'knowledge': '', 'section': guess_section(item.get('style', '')) } else: opt = re.match(r'^([A-Da-d])[..、]\s*(.*)', text) if opt and cur is not None: cur['options'].append(f"{opt.group(1).upper()}: {opt.group(2).strip()}") elif cur is not None: cur['stem'] += " " + text.strip() if cur: bank.append(cur) return bank def attach_answer(bank, row): qid = '2017JL_' + str(row[0]).strip() for q in bank: if q['id'] == qid: q['answer'] = row[1].strip() if len(row) > 2: q['analysis'] = row[2].strip() break return bank # 这里的 struct 来自第3章 extract_docx_structure(path) bank = build_question_bank(struct) with open('jl2017_bank.json', 'w', encoding='utf-8') as f: json.dump({'source': '吉林名校调研(省命题)2017', 'questions': bank}, f, ensure_ascii=False, indent=2)逻辑说明:build_question_bank在遇到表格时先尝试当答案表解析,只有表头第一列是数字的行才回填答案。段落分支中,先判断是否进入答案区,再判断题号和选项,普通行拼接到题干。这段代码与第3章的detect_question_block不同,它直接操作包含style的struct,因此能保留题型分节信息。
参数说明:guess_section的输入是Word样式名,如果一个文档没有样式,这里全部返回“解答”,这时需要靠“一、选择题”这类文本标记来切分。json.dump中ensure_ascii=False让中文以明文写入,indent=2便于阅读;若生成结果较大,可以把indent去掉并用JSONL格式。检查输出时,重点看answer为空的记录数,如果超过总题数的5%,说明原始的答案表不是三列结构,需要查看表格原始数据再改attach_answer。
4.3 用关键词映射表回填知识点
试卷解析中如果出现“对顶角”“同位角”等词,可以直接用关键词映射到知识点标签。下面是一个适用于这份试卷的知识点映射表:
| 关键词 | 知识点 |
|---|---|
| 对顶角、邻补角、同位角、内错角 | 相交线与平行线 |
| 算术平方根、立方根、无理数 | 实数 |
| 坐标、象限、原点 | 平面直角坐标系 |
实现时遍历题干和解析,命中一个关键词就写入知识字段,多个都命中就合并成用逗号分隔的短语。这个做法比训练文本分类器快得多,尤其是对这种学科术语稳定的试卷。要注意“平行”这个词在七年级数学里几乎每道几何题都会出现,所以不能只匹配一个词,至少用双词组合,例如“平行线+证明”再触发标签。
提示:用关键词映射回填后,最好抽样10道题人工核验,因为同一道题可能同时涉及“实数”和“坐标系”,只保留单个标签会流失信息。
题库最终还需要落到可查询的存储。常见做法是用sqlite3导入,导入之前把options里的换行符替换成\n,避免SQL语句被截断。下面是一个最小插入示例:
import sqlite3 import json with open('jl2017_bank.json', encoding='utf-8') as f: data = json.load(f) conn = sqlite3.connect('questions.db') conn.execute('''CREATE TABLE IF NOT EXISTS questions ( id TEXT PRIMARY KEY, section TEXT, stem TEXT, options TEXT, answer TEXT, analysis TEXT, knowledge TEXT )''') for q in data['questions']: conn.execute('INSERT OR REPLACE INTO questions VALUES (?,?,?,?,?,?,?)', (q['id'], q['section'], q['stem'], json.dumps(q['options'], ensure_ascii=False), q['answer'], q['analysis'], q['knowledge'])) conn.commit() conn.close()逻辑说明:使用INSERT OR REPLACE让题库可反复导入,options转成JSON字符串保存,避免另建子表。查询时用json.loads还原列表。相比一次插入一行,这个写法也能看清楚每个字段的映射关系。
参数说明:SQLite的文本类型可以存JSON字符串,但排序和精确过滤不如直接用列。如果将来要做选项级统计,建议拆出question_options子表,用外键关联题目id。
5. 用题库数据反推七年级学生薄弱点的最小实现
当题库有了结构化记录,学情分析就能直接从JSON读取,不需要再回头翻Word。下面给出一个两层级的实现:先计算每道题的答题对错,再统计知识点级别的正确率。
5.1 把答题结果映射到知识点
from collections import Counter def knowledge_accuracy(bank, submissions): stats = {} for s in submissions: qid = '2017JL_' + str(s['question_id']) q = next((x for x in bank if x['id'] == qid), None) if q is None: continue correct = q['answer'].strip().upper() == s['result'].strip().upper() kp = q.get('knowledge') or '未标注' item = stats.setdefault(kp, {'correct': 0, 'total': 0}) item['total'] += 1 if correct: item['correct'] += 1 return {kp: v['correct'] / max(v['total'], 1) for kp, v in stats.items()} # 模拟两名学生的答案,question_id对应原卷题号 submissions = [ {'question_id': 1, 'result': 'B'}, {'question_id': 2, 'result': 'A'}, {'question_id': 1, 'result': 'A'}, {'question_id': 2, 'result': 'B'}, ] acc = knowledge_accuracy(bank, submissions) for k, v in sorted(acc.items(), key=lambda x: x[1]): print(k, v)逻辑说明:函数以题库中的answer字段为基准,比对submissions里的回答结果,再把正确率按知识点聚合。排序用sorted(acc.items(), key=lambda x: x[1])升序输出,正确率最低的知识点自然排在前面,这就是薄弱点的初筛结果。
参数说明:submissions里的question_id必须是原卷题号,代码会拼接成2017JL_1去匹配。如果存在学生缺考或漏答题,total会变小,严格做法是预先对同一个学生补齐缺失记录,否则统计出来的正确率会偏向已答题目。对于一次月考,题目数量有限,正确率波动很大,我一般会追加一个规则:total < 3的知识点不进入最终排序,避免样本太小。
5.2 验证正确率是否可信
单份试卷的统计结果很容易过拟合,因此要用一道“换卷验证”来检查:从题库中随机抽出一半题目,用另一半题目预测同一学生的薄弱知识点,比较两次排序的重合度。如果重合度低于阈值,说明原卷的题目太少,需要把多次月考试卷合并后再做分析。
这种验证不需要复杂框架,直接把题库按知识点分层抽样,分别调knowledge_accuracy即可。由于本题集只有一张卷,更实际的做法是只输出“错误集中度”:用Counter统计错题的知识点分布,哪个知识点出现次数最多,就把它列为优先复习项。输出到CSV时记得用utf-8-sig编码,避免Excel打开后中文乱码。
本文还有配套的精品资源,点击获取