☰
1480道竞赛题库结构化:Python解析、SQLite存储与错题驱动复习
2026/10/28 10:36:19 网站建设 项目流程

简介:《江苏省第36届金钥匙题库(合计1480道题)》面向参加江苏省金钥匙科技竞赛及希望拓展科学素养的中小学生与辅导教师,围绕自然、物理、化学、生物等跨学科常识提供系统练习。题库以真实生活现象设问,如电动车充电安全、深海鱼养殖与液体压强、探空气球体积变化、超声波加湿器原理、水循环能量来源等,并配有答案与提示,便于理解现象背后的科学原理。整套资料为1个docx文档,压缩包约339KB,题量完整、检索方便,可用于赛前刷题、课堂拓展与查漏补缺。目前已有276人学习,适合需要巩固科学常识、训练逻辑思维和积累跨学科素材的读者按需使用。

1. 1480 道题不是拿来背的:题库真正的瓶颈在结构化

一份省级青少年科技素养竞赛题库,合计 1480 道题,拿到手的时候通常是两种形态:排满整页的 Word,或者手机上缩放起来很别扭的 PDF。多数人的第一反应是打印、从头背到尾。真过一遍就会发现,按知识点筛做不到,只看错题得自己抄本子,做错的题下次还会错,因为你根本不知道自己错在哪个知识块上。1480 道题、每题平均 40 秒,一轮就是 16 个多小时,靠翻页翻完,投入产出比很低。

题库的价值从来不在题目本身,而在它被拆成字段之后,能不能被查询、被统计、被反复调度。这篇文章讲的就是这条链路:把一份原始题库文档,变成一张结构化的题表,再做答案校核、知识点打标、错题驱动复习。适合正在整理竞赛题库、培训题库、认证题库的人照着做,也适合想看清楚「一份非结构化文档怎么变成可查询数据」的工程师。

2. 题库的字段设计与存储选型:一道题该被拆成几个字段

2.1 从「题干+选项+答案」到 12 个字段的最小模型

很多人整理题库,第一版就是一张 Excel:题目、A、B、C、D、答案,六列。能看,但用不起来。原因很直白——缺了「这题考什么」和「这题我掌握得怎么样」两个维度,剩下的数据只能当电子书翻。

我在 1480 道题这个量级上用过的最小字段集是 12 个,够用且不冗余:

字段类型说明
qidTEXT稳定题号,形如K036-0147,届次+序号,跨版本不变
stemTEXT题干正文,已去除题号前缀
opt_a~opt_dTEXT四个选项正文,无选项的判断题置空
answerTEXT统一大写单字母,判断题归一为A/B
analysisTEXT解析文本,原文没有就留空,不臆造
topicTEXT知识点标签,如「力学-浮力」
qtypeTEXT单选/多选/判断,用于选择题型调度
difficultyINTEGER1~3,人工或按正确率回填
source_pageINTEGER原文档页码,用于回溯校对
has_imageINTEGER0/1,标记依赖图片的题
confidenceREAL答案置信度,0~1,第 4 章细讲
created_atTEXTISO 时间戳,便于增量合并

qid是整张表的锚点。不要用行号当主键——文档重新排版一次,行号全乱,你之前记的错题本、笔记、标注全对不上。用「届次+题序」拼出来的 ID,只要题目顺序不变就永远稳定,这是后续做增量更新的前提。

has_image单列出来,是因为图示题在纯文本管线里必然出问题。先把它们标记出来单独处理,比混在主流程里被静默丢掉要好得多,后面第 5 章会讲这个坑有多疼。

2.2 为什么选 JSONL + SQLite 双份,而不是直接上数据库

选型这件事上,血泪经验是:不要为 1480 条数据上服务端数据库。

1480 条题目,加上解析、标签,整体数据量在 2 MB 上下。这个量级下,MySQL、PostgreSQL 带来的部署成本、连接管理、备份复杂度,全都是负资产。真正需要的只有两件事:一是人能看懂、能 diff、能提交到版本库的源文件;二是能快速按条件过滤的查询层。

我的做法是双份存储:

  • JSONL 作为源文件。一行一道题,UTF-8 编码,用 Git 管理。改动一道题就是一行 diff,谁改的、改了什么,一目了然。相比单个大 JSON 数组,JSONL 追加和合并都不会产生整文件冲突。
  • SQLite 作为查询层。单文件、零部署、Python 标准库直接sqlite3就能读,拷给任何人都能跑。1480 条数据全表扫描也就几毫秒,加索引后按知识点过滤稳定在 30 ms 以内。

有人会问,直接读 JSONL 到内存里过滤不行吗?行,但每次启动都要解析,而且写错题记录、更新 confidence、回填 difficulty 这些操作,用 SQL 的UPDATE ... WHERE比在 Python 里遍历列表清爽得多。SQLite 在这里扮演的是「可变状态层」,JSONL 是「不可变源」。

提示:JSONL 里保持字段顺序固定,用json.dumps(obj, ensure_ascii=False, sort_keys=False)配合OrderedDict构造,能让 diff 只出现在真正改动的字段上。

2.3 建表与索引:让 1480 条题在 30ms 内查完

建表语句我一般这么写,字段名和 JSONL 保持一致,导入导出不用做映射:

CREATE TABLE IF NOT EXISTS question ( qid TEXT PRIMARY KEY, -- 稳定题号,直接做主键 stem TEXT NOT NULL, opt_a TEXT, opt_b TEXT, opt_c TEXT, opt_d TEXT, answer TEXT NOT NULL, -- 统一大写单字母 analysis TEXT, topic TEXT, qtype TEXT NOT NULL DEFAULT 'single', difficulty INTEGER NOT NULL DEFAULT 2, source_page INTEGER, has_image INTEGER NOT NULL DEFAULT 0, confidence REAL NOT NULL DEFAULT 0.0, created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_topic ON question(topic); CREATE INDEX IF NOT EXISTS idx_answer ON question(answer); CREATE INDEX IF NOT EXISTS idx_conf ON question(confidence);

三个索引对应三类高频查询:按知识点筛题、查答案分布是否异常、筛低置信度题待人工复核。qid是主键,SQLite 会自动建唯一索引,不用重复建。

如果要做题干全文搜索(比如「输入关键词找相似题」),1480 条其实LIKE '%关键词%'也就 10 ms 级别,不必上 FTS5。但如果你打算把题库扩到几万条,可以加一张虚拟表:

CREATE VIRTUAL TABLE question_fts USING fts5( qid UNINDEXED, stem, analysis, tokenize='unicode61' );

tokenize='unicode61'对中文的分词效果一般,它按字符类别切,中文会变成单字索引,召回高但精度低。中文场景下更靠谱的做法是先用 jieba 之类切好词,把空格分隔的词串写进 FTS 列,再检索。这一步属于可选优化,1480 条不做也不影响。

导入时用executemany批量写,比逐条INSERT快一个数量级:

import json, sqlite3 def load_jsonl(path): with open(path, encoding='utf-8') as f: for line in f: line = line.strip() if line: yield json.loads(line) def build_db(jsonl_path, db_path): conn = sqlite3.connect(db_path) conn.executescript(open('schema.sql', encoding='utf-8').read()) rows = [ (q['qid'], q['stem'], q.get('opt_a'), q.get('opt_b'), q.get('opt_c'), q.get('opt_d'), q['answer'], q.get('analysis'), q.get('topic'), q.get('qtype', 'single'), q.get('difficulty', 2), q.get('source_page'), int(q.get('has_image', 0)), q.get('confidence', 0.0), q['created_at']) for q in load_jsonl(jsonl_path) ] conn.executemany( 'INSERT OR REPLACE INTO question VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)', rows) conn.commit() conn.close() print(f'imported {len(rows)} rows')

INSERT OR REPLACE是关键参数选择。题库整理是反复迭代的过程,每次重跑解析脚本都会产生新版本,用 REPLACE 能保证同一qid覆盖而不是报主键冲突。代价是会丢掉该行上手工改过的内容——所以手工修订要落在 JSONL 里,而不是直接改数据库。把 JSONL 当唯一真相源,数据库随时可以从零重建,这条纪律能省掉后面无数麻烦。

3. 用 Python 把文档拆成结构化题目:切分、清洗、去重的完整链路

3.1 题干切分:正则与三种版式容错

原始文档的题号格式五花八门。我在这类题库里见过至少四种:1.(全角句点)、1.(半角点)、1、(顿号)、(1)(括号包裹),甚至同一份文档前后不一致——前面几十题用全角,后面换成半角,大概率是多人分段录入后拼起来的。

切分的第一原则是先归一化再匹配。用unicodedata.normalize('NFKC', ...)把全角字母数字转半角,但标点要小心:NFKC 会把全角句点.转成半角.,这对题号识别有利,但会把中文语境里的全角括号也转掉,所以归一化只对「行首的题号片段」做,不要整行无差别转换。

import re import unicodedata # 题号:行首 1~4 位数字 + 任意常见分隔符,允许前后空白 STEM_RE = re.compile(r'^\s*(\d{1,4})\s*[..、,,::))]\s*(.*)$') # 选项:行首单个 A-D(含全角)+ 分隔符 OPT_RE = re.compile(r'^\s*([A-Da-dA-D])\s*[..、,,::]\s*(.*)$') # 括号答案,兼容全角半角括号与对错符号 ANS_RE = re.compile(r'[((]\s*([A-Da-dA-D√×✓✗对错]{1,4})\s*[))]') def norm_letter(ch): """全角字母转半角,对错符号归一""" s = unicodedata.normalize('NFKC', ch).upper() return {'√': 'A', '✓': 'A', '对': 'A', '×': 'B', '✗': 'B', '错': 'B'}.get(s, s)

STEM_RE里的分隔符字符类把常见的六种都收了。注意)和)也放进去,是为了兼容1) xxx这种写法。数字限制在 1~4 位,是因为题库总共 1480 道,四位足够;如果你的题库上万,改成\d{1,5}。不要用\d+——正文里出现的「2019年」这类数字会被误判成题号,这是切分环节最常见的翻车点。

逐行状态机推进,比一次性re.split稳得多:

def parse_lines(lines): cur = None buf = [] for raw in lines: line = raw.rstrip('\n') m_stem = STEM_RE.match(line) # 只有当前没有题目,或上一题已经有答案/选项,才认新题号 if m_stem and (cur is None or cur.get('answer') or cur['opts']): if cur: buf.append(cur) cur = {'stem': m_stem.group(2).strip(), 'opts': {}, 'answer': None} continue if cur is None: continue m_opt = OPT_RE.match(line) if m_opt: cur['opts'][norm_letter(m_opt.group(1))] = m_opt.group(2).strip() continue m_ans = ANS_RE.search(line) if m_ans and not cur['answer']: cur['answer'] = norm_letter(m_ans.group(1)[0]) continue # 其余行并入题干(多行题干、解析、页眉页脚) cur['stem'] += ' ' + line.strip() if cur: buf.append(cur) return buf

核心判断在m_stem and (cur is None or cur.get('answer') or cur['opts'])这一行:只有当「上一题已经收集到选项或答案」时,才把新出现的数字行当作新题号。这条规则专治一种情况——题干本身带编号,比如「3. 下列哪项…」出现在一道大题的正文里。没有这个守卫,一道题会被切成两道。宁可漏切(后面人工补),也不要错切,因为漏切能通过「题目总数对不上 1480」立刻发现,错切则静默污染数据。

3.2 选项与答案对齐:括号内答案的四种写法

答案的藏身之处比选项多得多。同一份 1480 题的文档里,我见过这几种:

  1. 紧跟在题干末尾的括号里:……下列正确的是( B )
  2. 单独一行:答案:B
  3. 跟在解析后面:……因此选B。
  4. 完全没有,答案在文档末尾的答案表里,题干里只有一个空括号

第 1 种用ANS_RE就能抓;第 2 种要额外加一条正则^\s*答\s*案\s*[::]?\s*([A-D√×对错]);第 3 种最麻烦,只能靠「因此选X」「故选X」「正确答案是X」这类句式,命中率大概七成,剩下三成必须人工;第 4 种要单独解析答案表,然后按题号 join 回去。

我的处理顺序是:先用第 1 种和第 2 种吃掉大部分,剩下answer is None的题,再用第 3 种句式兜底扫一遍,最后剩下的全部标记confidence = 0进人工队列。不要在代码里给「猜」的答案——一个猜错的答案比没有答案危害大得多,因为你在刷题时不会再去怀疑它。

对齐完还要做一次结构校验,四条硬规则:

  • 单选题必须恰好 4 个选项,键集合等于{A,B,C,D}
  • 判断题允许 0 选项,answer 归一为 A/B
  • answer 必须落在选项键集合内(判断题除外)
  • stem 长度大于 8 个字符(过滤掉纯标题行误入)

不满足的直接打confidence = 0并写入reject.jsonl,附上失败原因。这个 reject 文件在排错阶段比正经输出还重要,第 5 章的坑基本都是从这里捞出来的。

3.3 去重与近似重复:SimHash 和编辑距离的取舍

1480 道题里出现重复很正常——不同届次的题会互相抄,同一道题换个问法又出一遍。全重复好办,stem精确匹配就行。麻烦的是近似重复:改了两个字的、调换选项顺序的、把「下列正确的是」改成「以下说法正确的是」的。

先说为什么不能直接上编辑距离。1480 条两两比较是 109 万对,题干平均 30~60 字,Levenshtein 复杂度 O(nm),算下来是十亿级基本操作,纯 Python 要跑几分钟到十几分钟。跑一次能接受,但你调一次阈值就要重跑一次,迭代体验极差。

实用方案是两层:SimHash 粗筛 + 编辑距离精判。

import hashlib from collections import Counter def shingles(text, n=2): """中文按字符 n-gram 切,去掉标点空白""" t = re.sub(r'[^\u4e00-\u9fa5A-Za-z0-9]', '', text) return [t[i:i+n] for i in range(max(len(t) - n + 1, 1))] def simhash(text, bits=64): v = [0] * bits for tok, w in Counter(shingles(text)).items(): h = int(hashlib.md5(tok.encode('utf-8')).hexdigest(), 16) & ((1 << bits) - 1) for i in range(bits): v[i] += w if (h >> i) & 1 else -w out = 0 for i in range(bits): if v[i] > 0: out |= (1 << i) return out def band_keys(h, bands=4, bits=64): """把 64 位切成 4 段 16 位,任一段相同即进入候选集""" seg = bits // bands mask = (1 << seg) - 1 return [(i, (h >> (i * seg)) & mask) for i in range(bands)]

shingles用 2-gram,是因为中文单字信息量太低、3-gram 又太稀疏,2-gram 在这个长度区间是最稳的。band_keys是经典的 LSH 分桶:64 位切 4 段,只要有一段完全相同就当作候选对。这样候选对数量从 109 万降到几千,桶内再算 Hamming 距离就很快了。

粗筛之后,Hamming 距离阈值我一般设3(64 位下),对应相似度大约 95%。低于 3 的进候选,再用编辑距离精判:归一化编辑距离小于 0.15 判为重复。这两个数字不是拍脑袋来的——阈值设到 5 以上,会把「同一知识点的两道不同题」误合并;设到 1,则漏掉大量换词改写。

def hamming(a, b): return bin(a ^ b).count('1') def norm_edit(a, b): import difflib return 1 - difflib.SequenceMatcher(None, a, b).ratio() def find_dups(rows, ham_th=3, edit_th=0.15): buckets = {} sigs = {} for r in rows: h = simhash(r['stem']) sigs[r['qid']] = h for k in band_keys(h): buckets.setdefault(k, []).append(r['qid']) pairs = set() for ids in buckets.values(): for i in range(len(ids)): for j in range(i + 1, len(ids)): a, b = ids[i], ids[j] if hamming(sigs[a], sigs[b]) <= ham_th: pairs.add((a, b)) return pairs

这里用difflib.SequenceMatcher而不是自己写 Levenshtein,是因为标准库实现是 C 层优化过的,在候选对只剩几千对时完全够快,省掉一个第三方依赖。ratio()返回 0~1 的相似度,取反得距离。

去重的处置策略也要想清楚:不是删掉,是打标。重复题保留,加一个dup_of字段指向保留的那条,刷题时按dup_of IS NULL过滤。这样万一合并错了,随时能还原——后悔药要在设计阶段就备好。

3.4 图片与公式题的单列处理

题库里的图示题占比通常在一成上下。这类题在纯文本管线下只有一个结局:题干被解析成半截,选项丢失,答案错位。更糟的是它不会报错,只是静默地少几道题——你看到总数是 1350 而不是 1480,会以为是切分规则的问题,查半天查不到。

我的处理是提前分流。文档转文本时(如果你有原始 Word,用python-docx遍历段落,遇到内联图片就记录段落索引),把含图的题单独抽出来,题干保留文字部分,has_image = 1,answer和选项留空,直接推入人工队列。

公式同理。竞赛题里物理、化学公式不少,纯文本提取会把上下标拍平,v²变成v2,H₂O变成H2O,多数情况下还能读懂,但如果公式本身是解题关键(比如带根号的表达式),拍平后就废了。这类题也走人工通道。

分流之后主流程处理的是 1300 多道纯文本题,成功率能到 95% 以上。剩下那 100 多道人工过一遍,一两个小时的事。这个比例账要算清楚:花两小时人工,比花两天调一个永远调不完美的图片 OCR 管线划算得多。

4. 答案校核怎么做:没有标准答案时的三层兜底

4.1 交叉校验:同一知识点在不同题里的答案一致性

题库整理最怕的不是格式乱,是答案错。格式乱你能看出来,答案错你看不出来——你按错的答案背,背得越熟错得越狠。

一个能自动跑的信号是答案分布异常。给每道题打上知识点标签后,按 topic 分组统计答案分布。正常情况下,一个知识点的十几道题,A/B/C/D 应该大致均衡。如果某个 topic 下 12 道题有 9 道答案是 C,那大概率不是命题偏好,而是答案提取串位了——比如解析文本里「选C」被误抓成了答案,而真正的答案是括号里的 B。

from collections import defaultdict, Counter def topic_answer_anomaly(rows, min_n=6, thresh=0.7): """返回疑似答案异常的 (qid, topic, 主导答案, 占比)""" groups = defaultdict(list) for r in rows: if r.get('topic') and r.get('qtype') == 'single': groups[r['topic']].append(r) out = [] for topic, items in groups.items(): if len(items) < min_n: continue cnt = Counter(x['answer'] for x in items) top, n = cnt.most_common(1)[0] ratio = n / len(items) if ratio >= thresh: for x in items: if x['answer'] == top: out.append((x['qid'], topic, top, round(ratio, 2))) return out

min_n=6是个经验下限:样本少于 6 道时,随机波动就能造成 70% 的集中,没有统计意义。thresh=0.7同理,低于这个值属于正常偏斜。这两个参数可以按你的题库规模调,但不要设得比 0.6 更低,否则每天都有一堆假阳性要处理,用不了几天你就会把这个检查关掉。

注意这个检查的输出是疑似清单,不是判决。命中的题全部降confidence,进人工复核队列,不是直接改答案。

4.2 置信度打分表与人工抽检比例

单一信号不可靠,所以我把几个弱信号加权成一个confidence,用分数决定人工投入的优先级。这是我目前在用的打分表:

信号分值判定依据
结构完整+0.30四个选项齐全且 answer 在选项集合内
括号答案命中+0.25答案来自题干末尾括号,这是最可信的来源
知识点分布正常+0.20所属 topic 答案分布未触发异常
解析关键词一致+0.15解析文本中出现「选X」「答案是X」且与 answer 相同
选项长度启发+0.10answer 对应选项是四个中最长的(命题常见偏差)
人工确认直接置 1.0人工标注优先级最高,覆盖一切

最后那个「选项长度启发」听起来像玄学,但在这类竞赛题里确实有统计倾向:命题人为了让正确答案表述严谨,往往写得比干扰项长。它只值 0.1 分,单独用毫无意义,作为加权项能帮你在边界样本上做区分。

按总分切三档:

  • ≥ 0.70:自动通过,不再看
  • 0.40 ~ 0.70:按 30% 比例抽检
  • < 0.40:全量人工

1480 道题按这个分布跑下来,通常落在「900 道自动通过 / 350 道抽检 / 230 道全检」这个量级。全检那 230 道里,图示题和公式题占大头——这恰好印证了 3.4 的分流策略是对的。

抽检那 350 道不要随机抽,按 topic 分层抽。每个知识点至少抽 2 道,这样能顺带验证知识点打标准确性;纯随机抽的话,冷门知识点可能一道都抽不到。

4.3 错题回流的闭环

校核不是一次性动作。真正让confidence变得有用的,是刷题阶段的反馈回流。

设计上很简单:用户答错一道题时,如果这道题的confidence < 0.7,就把这次错误计入「可疑计数」。同一道题被不同人答错 3 次以上,自动升级为人工复核。理由是——低置信度题被反复答错,很可能不是答题者的问题,是答案本身有问题。

这个机制的价值在于它把「校核」从离线的一次性工作,变成了在线持续收敛的过程。第一轮刷题之后,人工队列会自动排出优先级,你不用再从 1480 道里盲找。

def bump_suspicion(conn, qid, threshold=3): conn.execute( 'UPDATE question SET confidence = MAX(confidence - 0.05, 0.0) ' 'WHERE qid = ? AND confidence < 0.7', (qid,)) conn.commit() row = conn.execute( 'SELECT confidence FROM question WHERE qid = ?', (qid,)).fetchone() if row and row[0] < 0.3: conn.execute( "UPDATE question SET qtype = qtype WHERE qid = ?", (qid,)) # 实际落库到一个 review_queue 表,这里以打印代替 print(f'[review] {qid} 进入人工复核')

MAX(confidence - 0.05, 0.0)里的 0.05 是衰减步长,别设太大。设成 0.2 的话,错两次就直接掉到零,噪声太大;0.05 的话要错十几次才见底,收敛太慢。0.05 是个折中值,实际用下来手感还行。

5. 避坑与排查:题库处理里最容易翻车的 5 个地方

5.1 全角半角混排:解析完每道题只剩一个选项

现象:跑完脚本统计,1480 道题里 1100 多道的选项数少于 4,打开看,每道题只有 A 选项,B/C/D 全丢。总数又是对的,说明题干切分没问题,问题出在选项识别。

原因:原始文档里选项标号用的是全角字母AB,而OPT_RE里虽然写了[A-Da-dA-D],但norm_letter里unicodedata.normalize('NFKC', ch)对单个全角字母转出来的是半角A,这一步没问题;真正的问题是分隔符——全角字母后面跟的是全角空格 (U+3000),而正则里的\s在某些 Python 版本和编码组合下并不匹配它。

解决:在匹配前对整行做一次针对性的空白归一,把 U+3000、U+00A0 全部替换成普通空格,再进正则。别图省事对整行做 NFKC,那会把中文标点一起改掉,题干就变形了。

def clean_ws(line): for ch in ('\u3000', '\u00a0', '\t'): line = line.replace(ch, ' ') return line

5.2 题号断层导致跨题合并:一道题的题干长到离谱

现象:stem长度统计里出现几条 500 字以上的「题」,点开发现里面塞了两三道题,第二道的题号没被识别成新题。

原因:3.1 那个状态机的守卫条件写得过严。守卫是「上一题已经有选项或答案才认新题号」,但如果上一题正好是道无选项的判断题,且答案在文档末尾的答案表里(题干里没有),那么cur['opts']是空、cur['answer']是 None,守卫就不成立,下一道题的题号被当成正文吞进stem。

解决:守卫条件补一条——如果当前题的stem已经超过 200 字,无条件认新题号。200 字这个阈值来自题库里最长单题题干的实际分布,正常题干极少超过 150 字。同时加一个后置检查:凡stem长度超过 300 的,全部打进 warn 日志人工看一眼。

5.3 图片题被静默丢弃:总数从 1480 掉到 1340

现象:导入数据库后SELECT COUNT(*)是 1340,比预期少 140。日志里没有任何报错。

原因:图示题在文本提取阶段,图片段落被转成空行,题干后半截(通常在图片下方)还在,但括号答案在图片里,所以answer为空。结构校验规则里有一条「answer 为空则 reject」,于是这 140 道题被送进了reject.jsonl,主表里就没有了。整个过程没有异常抛出,因为它是「按规则拒绝」,不是「出错」。

解决:reject.jsonl必须每次跑完都扫一眼统计,按拒绝原因分组打印。图示题的拒绝原因应该单独一类,而不是和「选项缺失」混在一起。分流逻辑要在结构校验之前生效,别让它们走到 reject 分支。

5.4 答案符号混用:「√」被判成无效答案

现象:判断题的正确率统计里,某个 topic 的题全部答错,打开看,答案字段存的是√,而答题端只认A/B。

原因:norm_letter里对错符号的映射写在了函数末尾的字典里,但ANS_RE的正则字符类[A-Da-dA-D√×✓✗对错]里没有包含✔(U+2714)和✘(U+2718)这两个变体。文档里用的是后者,正则根本没匹配上,于是走了「解析文本兜底」分支,抓到了别处的字符。

解决:把归一化表做成外置的映射字典,正则字符类由字典的键动态拼出来,避免两处维护不同步。这类问题的根本原因是「同一份知识写在两个地方」,修法不是补正则,是消除重复。

SYMBOL_MAP = {'√': 'A', '✓': 'A', '✔': 'A', '对': 'A', '×': 'B', '✗': 'B', '✘': 'B', '错': 'B'} _ans_chars = 'A-Da-dA-D' + ''.join(SYMBOL_MAP) ANS_RE = re.compile(rf'[((]\s*([{_ans_chars}]{{1,4}})\s*[))]')

5.5 去重过度:知识点覆盖被合并掉一块

现象:去重跑完,题目从 1480 降到 1290,看起来正常。但知识点覆盖统计里,「光学」这个 topic 从 18 道变成 6 道,明显不对。

原因:编辑距离阈值设成了 0.25,太宽。同一知识点下的题目共享大量术语,「下列关于凸透镜成像的说法正确的是」这类题干骨架高度相似,加上四个选项里有两个是通用的干扰项,整体相似度很容易超过 0.75,被判成重复。

解决:编辑距离阈值收到 0.15,同时加一条硬约束——只有答案相同的题才允许判重。答案不同的两道题,哪怕题干 99% 像,也大概率是「同一情境不同问法」的两道独立题,合并了就丢题。加了这条约束之后,误合并基本消失。

另外,去重结果一定要输出一份「合并对」清单,人工扫一遍。1480 条量级下,合并对通常只有几十对,五分钟能看完,这五分钟值得花。

6. 从 1480 道题到一套自适应刷题:覆盖度统计与错题驱动

题库整理到这一步,已经能查、能筛、能统计了。但真正让这 1480 道题产生差异化的,是把它从「题库」变成「训练计划」——不是从头刷到尾,而是按掌握度动态调度。

具体做法是给每个 topic 算一个掌握度,用最近 N 次作答的正确率加权,再乘一个覆盖系数(该 topic 下已作答题数 / 总题数)。掌握度低于 0.6 且覆盖系数低于 0.3 的,就是盲区,优先推题。

def mastery(records, topic_total, n=20, prior=0.5): """records: [(topic, is_correct), ...];返回 topic -> 掌握度""" from collections import defaultdict hit = defaultdict(list) for t, ok in records: hit[t].append(1 if ok else 0) out = {} for t, arr in hit.items(): recent = arr[-n:] rate = (sum(recent) + prior * 2) / (len(recent) + 2) # 拉普拉斯平滑 cover = len(arr) / max(topic_total.get(t, 1), 1) out[t] = round(rate * (0.4 + 0.6 * cover), 3) return out

prior=0.5是拉普拉斯平滑的先验,作用是不让「只做过 1 道题且做对」的 topic 直接拿到 1.0 的掌握度。n=20取最近 20 次作答,是因为更早的记录反映的是旧水平,权重应该衰减。0.4 + 0.6 * cover这个系数保证:即使正确率很高,只要覆盖率低,掌握度也上不去——做过 5 道题全对,不代表这个知识块你拿下了。

排复习队列用简化的 SM-2,只保留间隔递增这一个核心逻辑:

def next_interval(reps, ease, correct): """reps: 连续答对次数; ease: 难度因子, 初始 2.5""" ease = max(1.3, ease + (0.1 if correct else -0.2)) if not correct: return 1, 1, ease # 答错:明天再来 reps += 1 interval = 1 if reps == 1 else (6 if reps == 2 else round(6 * ease ** (reps - 2))) return interval, reps, ease

ease下限 1.3 是原始算法里的设定,低于这个值间隔会塌缩,复习频率高到没法用。答错时-0.2、答对时+0.1,这个不对称是有意的:一次错误说明这道题你确实没掌握,惩罚要重于奖励。

这套东西我用下来的最大体会是:别在数据没洗干净之前就急着上调度算法。前两轮我跳过了第 4 章的答案校核直接开刷,结果错题本里一半是题库本身的错答案,复习计划全被带偏,返工比重做还累。先把 confidence 打到 0.7 以上,再谈自适应,这个顺序不能反。希望帮到你。

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

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

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

立即咨询