Python解析ACSM RCEP题库:SQLite与SM-2间隔重复练习系统
2026/9/18 19:00:37 网站建设 项目流程

简介:面向备考ACSM注册临床运动生理学家(Registered Clinical Exercise Physiologist)认证的考生,这份文档围绕考试代码040-444整理了一套英文题库,适合运动生理学、临床运动指导、康复治疗及相关专业学生用于自测与查漏补缺。资源包内仅含1个docx文件,压缩后约116KB,内容以单项选择题目与正确答案标注为主,覆盖解剖学、生理学、病理学以及运动指导和康复等模块。目前已有106人学习下载。题库涉及气管C形软骨功能、骨骼功能、骨骼肌收缩蛋白、下背痛柔韧性训练选择、长骨结构特征、手臂关节运动、膝关节伸展主要肌群、肩关节外旋肌力评估、软骨组织分类及心脏瓣膜血流路径等具体考点,每题均给出正确选项,便于读者定位薄弱环节并针对性复习,适合作为RCEP认证冲刺阶段的练习材料。

1. 从一份 ACSM RCEP(040-444) 题库 docx 到可查询的练习系统

准备 ACSM 注册临床运动生理师(Registered Clinical Exercise Physiologist,考试代码 040-444)的人,手上多半已经躺着一份《ACSM(040-444)认证考试题库.docx》。翻几百页做三十道题,很快说不清自己到底弱在运动生理、运动测试还是运动处方上。问题不在题量,而在于题干、选项、答案、解析全被塞进自然段落,程序读不进去,也就没法去重、打标、按考点抽题。

真正要解决的是把 docx 变成结构化题库:一道题一条记录,选项分列,正确答案单独字段,题干按临床运动生理的考点域打上标签。这一步做完,间隔重复调度、错题归因、分域正确率统计才有数据可依,而不是靠 Word 查找功能一页页翻。

接下来走的是解析、清洗去重、SQLite 落库、考点打标、每日到期队列这条线,代码用 Python 3 加 python-docx,全本地跑。适合要考 RCEP 的临床运动生理、康复、心内方向从业者,也适合手上有同类 Word 题库、想把它变成可查询系统的工程师。

2. 用 python-docx 解析 RCEP 题库 docx 的段落流与表格

2.1 docx 的真实结构决定了先探测再写正则

docx 本质是个 zip 包,正文在word/document.xml。python-docx 把它抽象成Document.paragraphsDocument.tables两个序列:段落顺序在.paragraphs里保留,表格却是单独收集的。一份用表格排版的题库,段落流里几乎读不到题干,直接上正则会得到一堆空结果。

我一般先做一次排版探测,不追求写通用解析器。常见三种排版:

排版类型特征解析入口主要坑
纯段落式题干一行、选项四行、答案一行doc.paragraphs跨页断行把题干拆成两段
表格式一行一题,单元格里塞选项doc.tables单元格内换行被合并成一段
混排式题干是段落,选项是表格两者按顺序交叉段落与表格的相对位置丢失

先 dump 前 40 个段落和所有表格的首行,肉眼确认属于哪一类,比写一套万能解析器省一半时间。

2.2 最小探测脚本:把段落和表格结构打印出来

from docx import Document doc = Document("ACSM(040-444)认证考试题库.docx") # 只看前 40 个非空段落,确认题干 / 选项 / 答案各占什么位置 for i, p in enumerate(doc.paragraphs[:40]): text = p.text.strip() if text: print(f"[P{i:04d}] {text[:120]}") print("-" * 60) # 表格式排版:打印每张表的行列数与首行内容 for ti, tb in enumerate(doc.tables): head = " | ".join(c.text.strip()[:30] for c in tb.rows[0].cells) print(f"[T{ti}] rows={len(tb.rows)} cols={len(tb.columns)} :: {head}")

doc.paragraphs返回的是全部段落对象,索引i保留原始位置信息,后面做「段落号 → 题目」溯源时会用到。[:40]只是控制输出量,探测阶段不需要读全文。tb.rows[0].cells取首行单元格,len(tb.rows)给出的行数可以判断这张表是一题一行还是一题多行。如果打印结果里段落几乎都是目录和版权页、表格却有一张几百行的,那就是表格式题库,走doc.tables分支。

2.3 把段落流切成题目块:三条正则定骨架

纯段落式题库的骨架很好抓:题干以序号开头,选项以 A–D 开头,答案以「答案」「参考答案」开头。

import re Q_START = re.compile(r'^\s*(?:第\s*)?(\d{1,4})\s*[\.、\))题]\s*(.*)$') OPT = re.compile(r'^\s*([A-Da-d])\s*[\.、\))]\s*(.+)$') ANS = re.compile(r'^\s*(?:答案|参考答案|正确答案|Answer)\s*[::]?\s*([A-Da-d][A-Da-d\s,,、]*)$') EXP = re.compile(r'^\s*(?:解析|答案解析|Explanation)\s*[::]?\s*(.+)$') def split_questions(lines): blocks, cur = [], None for ln in lines: ln = ln.strip() if not ln: continue m = Q_START.match(ln) if m: # 命中题号即开新题,上一题收尾 if cur: blocks.append(cur) cur = {"qid": m.group(1), "lines": [m.group(2).strip()]} elif cur: cur["lines"].append(ln) # 选项、答案、解析都先归到当前题 if cur: blocks.append(cur) return blocks

Q_START里的(?:第\s*)?是可选前缀,兼容「第 12 题」和「12.」两种写法;[\.、\))题]覆盖点号、顿号、右括号和「题」字四种分隔符。split_questions只做粗切分,不区分选项和答案,因为答案行必须留在同一题里才能回填。切完之后再遍历lines,用OPT抽选项、用ANS抽正确答案字母、用EXP抽解析。答案正则结尾锚定$,是为了避免把解析里出现的「A」误当答案。

2.4 表格式题库按列对齐解析

表格式的处理更简单:一题一行,列固定。

def parse_table(tb): items = [] for row in tb.rows[1:]: # 跳过表头 cells = [c.text.strip() for c in row.cells] if len(cells) < 3 or not cells[0]: continue # 选项常见为「A. xxx\nB. yyy\nC. zzz\nD. www」挤在一个单元格里 opts = {} for line in re.split(r'[\n\r]+', cells[-2] if len(cells) >= 4 else cells[1]): m = OPT.match(line) if m: opts[m.group(1).upper()] = m.group(2).strip() items.append({ "qid": re.sub(r'\D', '', cells[0]) or cells[0], "stem": cells[1], "choices": opts, "key": cells[-1].strip().upper()[:1], }) return items

tb.rows[1:]跳过表头,实际列数要按探测结果改;cells[-2] if len(cells) >= 4是防御性写法,列数不足时退回第二列取选项。re.split用换行拆单元格内的多行文本,这一步不能省,否则四个选项会被当成一个字符串。cells[-1]取最后一列当答案,[:1]只保留首字母,防止「A. 运动强度」这类写法污染答案字段。

2.5 解析失败的三种典型情况

一是 Word 自动编号。题干序号由列表样式渲染,p.text里根本没有「12.」,此时要改读p._p.pPr里的编号定义,或者干脆用「上一题答案之后的第一段」当题干起点。二是答案和解析挤在同一行,ANS的正则锚定会失配,去掉$锚定后改用re.search取第一个字母组即可。三是跨页断行,题干被拆成两个短段落。稳妥做法是切分后做一遍修补:如果某个题块的首段长度短于 15 个字且不以标点结尾,就和下一段合并再入下一阶段。

3. RCEP 题库落 SQLite:字段设计、去重与考点打标

3.1 三张表分开存题目、选项和作答记录

把题目、选项、作答分开存,好处是统计口径清晰:算正确率查作答表,查题目内容查题目表,不用在一张大宽表里判空。

PRAGMA foreign_keys = ON; CREATE TABLE question ( qid TEXT PRIMARY KEY, -- 原文档题号,去重与溯源锚点 stem TEXT NOT NULL, domain TEXT, -- 考点域标签 fingerprint TEXT NOT NULL, -- 题干归一化后的哈希 src_para INTEGER, -- 来自第几个段落,方便回原文核对 created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE choice ( qid TEXT NOT NULL, label TEXT NOT NULL, -- A/B/C/D content TEXT NOT NULL, is_key INTEGER DEFAULT 0, -- 1 表示正确项 PRIMARY KEY (qid, label), FOREIGN KEY (qid) REFERENCES question(qid) ); CREATE TABLE attempt ( id INTEGER PRIMARY KEY, qid TEXT NOT NULL, picked_label TEXT, -- NULL 表示没作答直接看答案 grade INTEGER NOT NULL, -- 0-5,供 SM-2 使用 created_at TEXT DEFAULT (datetime('now')) ); CREATE INDEX idx_attempt_qid ON attempt(qid); CREATE INDEX idx_question_domain ON question(domain);

choice(qid, label)做联合主键,天然防重复插入。is_key用整数而不是布尔,是为了兼容多选题后续扩展。attempt.picked_label允许为空,因为很多人是「看完题直接翻答案」,这条例外记录对后面的诱饵分析仍有价值——没作答的题不该算进答错统计。

3.2 题干归一化与近似去重

同一份题库里,同一道题换个说法出现两遍很常见,纯哈希查不出来,得先归一化再算相似度。

import hashlib, re from difflib import SequenceMatcher def normalize(stem: str) -> str: s = re.sub(r'\s+', '', stem) # 去所有空白 s = re.sub(r'[((][^))]*[))]', '', s) # 去括号里的补充说明 s = re.sub(r'[,。;、,.;:\'"“”‘’]', '', s) # 去标点 return s def fingerprint(stem: str) -> str: return hashlib.md5(normalize(stem).encode('utf-8')).hexdigest()[:16] def dedup(rows, threshold=0.9): kept, seen = [], {} for r in rows: fp = fingerprint(r["stem"]) if fp in seen: # 完全同题,直接丢 continue dup = False for k in kept: # 近似题,逐个比对 if SequenceMatcher(None, normalize(r["stem"]), normalize(k["stem"])).ratio() >= threshold: dup = True break if not dup: seen[fp] = r["qid"] kept.append(r) return kept

normalize里去掉括号内容,是因为很多题库在题干后补了「(单选)」这类说明,不同版本写法不一。threshold=0.9是实测比较稳的线:低于 0.85 会把「运动强度」和「运动时间」这种只差两个字的题误合并,高于 0.95 又漏掉改写过的重复题。真正被合并的题不要静默删除,我习惯把qid记到一张dup_map表里,回头人工抽查十几条确认阈值合理。

3.3 按临床运动生理考点域给题干打标

题目标签不用模型,关键词映射表足够,而且结果可解释、可手工修正。

考点域典型关键词建议抽题占比
运动生理基础摄氧量、心输出量、乳酸阈、通气阈、肌纤维20%
病理生理与临床心力衰竭、冠心病、糖尿病、慢阻肺、β 受体阻滞剂25%
健康评估与运动测试运动试验、心电图、终止指征、危险分层、最大心率20%
运动处方与 FITT强度、频率、时长、靶心率、RPE、抗阻训练20%
行为改变与安全依从性、自我效能、急救、除颤、应急预案15%

占比那一列不要照抄别人的,把你手上考纲给出的各域权重填进去,没有就先用一组自定配比,跑两轮模拟后再调。

DOMAIN_RULES = { "运动生理基础": ["摄氧量", "心输出量", "乳酸阈", "通气阈", "肌纤维"], "病理生理与临床": ["心力衰竭", "冠心病", "糖尿病", "慢阻肺", "受体阻滞"], "健康评估与运动测试": ["运动试验", "心电图", "终止指征", "危险分层", "最大心率"], "运动处方": ["强度", "频率", "靶心率", "RPE", "抗阻"], "行为与安全": ["依从性", "自我效能", "急救", "除颤", "应急预案"], } def tag_domain(stem: str) -> str: hits = {d: sum(1 for w in ws if w in stem) for d, ws in DOMAIN_RULES.items()} best = max(hits, key=hits.get) return best if hits[best] > 0 else "未分类"

按命中词数取最大值,命中为零的归到「未分类」。实践中「未分类」通常占 5% 到 15%,基本都是纯情景题——题干只描述一个病人状态,考点藏在选项里。这部分不要硬塞,单独留一张表,人工过一遍效率比调规则高。

3.4 用考纲配比校验题库覆盖

打标完成后跑一条聚合查询,看题库在各域上的分布和你定的配比差多少。

SELECT domain, COUNT(*) AS n, ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM question), 1) AS pct FROM question GROUP BY domain ORDER BY n DESC;

pct和你表里的目标配比并排列出来,差 5 个百分点以上的域就是要补题的地方。这一步的价值在于避免一种错觉:刷了几百道题感觉什么都练了,实际上某个域只出过十几道,考场上遇到陌生题型就崩。

4. 用 SM-2 间隔重复驱动题库:参数、到期队列与错题闭环

4.1 为什么题库要接调度而不是随机抽题

随机抽题的缺陷是短期记忆被反复强化,长期记忆没被触碰。间隔重复的核心是「在快忘记的时候再问一次」,答得越顺,下次间隔越长;答错就退回短间隔。对 RCEP 这种同时考概念辨识和临床情景判断的考试,把有限时间压在高遗忘率题目上,比平均刷题更划算。

4.2 SM-2 的最小实现

def sm2(grade: int, ease: float = 2.5, interval: int = 0, reps: int = 0): """grade: 0-5,3 分及以上算通过;ease 是难度系数""" if grade < 3: reps, interval = 0, 1 # 答错重置,明天再见 else: if reps == 0: interval = 1 elif reps == 1: interval = 6 # 第二次答对,间隔直接跳到 6 天 else: interval = round(interval * ease) reps += 1 # 难度系数随作答质量浮动,答得差就下降 ease = ease + (0.1 - (5 - grade) * (0.08 + (5 - grade) * 0.02)) ease = max(1.3, ease) # 下限,防止间隔塌缩 interval = min(interval, 180) # 上限,防止某题半年不出现 return ease, interval, reps

grade < 3的分支做重置,这是错题闭环的入口。ease的更新公式里,grade=5时增量为0.1 - 0 = 0.1grade=3时为0.1 - 2*(0.08+2*0.02) = 0.1 - 0.24 = -0.14,也就是「勉强答对」会持续压低难度系数,让这道题更频繁地出现。max(1.3, ease)是硬下限,低于 1.3 间隔几乎不再增长,题目会变成每天必刷。min(interval, 180)是上限,考前两个月设置成 60 更合适。

4.3 四个必须调的参数

参数默认值什么时候改改错的后果
初始 ease2.5基础差调到 2.2太高则前期进度慢
ease 下限1.3不要低于 1.3题目天天出现,队列爆掉
间隔上限180 天考试前 60 天改成 60熟题一个月不出现,临考遗忘
通过线 grade≥3冲刺期改成 ≥4只是「有点印象」也被判通过

grade 到作答行为的映射也统一下:看到题 3 秒内选出且正确记 5,正常选出记 4,犹豫后正确记 3,选错但有明确排除思路记 2,完全没方向记 1,直接看答案记 0。这个映射写进练习界面,别让人凭感觉打分。

4.4 每日到期队列用一条 SQL 决定

WITH latest AS ( SELECT qid, MAX(created_at) AS last_at FROM attempt GROUP BY qid ) SELECT q.qid, q.stem, q.domain, a.interval_days, julianday('now') - julianday(a.due_at) AS overdue_days FROM question q JOIN attempt a ON a.qid = q.qid AND a.created_at = latest.last_at JOIN latest ON latest.qid = q.qid WHERE a.due_at <= date('now') ORDER BY overdue_days DESC, q.domain LIMIT 60;

如果不想每道题都存due_at,把easeinterval_daysreps三个字段直接放在 question 表上,用date(created_at, '+' || interval_days || ' day')现算到期时间也可以,省一张表的连接。ORDER BY overdue_days DESC让拖得最久的题先出,LIMIT 60是每日上限,一般按可支配时间除以单题平均耗时估:30 分钟、每题 40 秒,就是 45 道左右。

4.5 错题按考点域回灌

答错之后不要只把这道题推回来,要把同域的低分题一起拉进当天队列。

SELECT q.qid, q.domain, AVG(a.grade) AS avg_grade FROM attempt a JOIN question q ON q.qid = a.qid WHERE q.domain = :wrong_domain GROUP BY q.qid HAVING avg_grade < 3.0 ORDER BY avg_grade ASC LIMIT 15;

:wrong_domain传刚答错题的域标签,HAVING avg_grade < 3.0筛出该域里历史上也没做明白的题,一次回灌十几道。这样做的好处是暴露的是「域」的薄弱而不是「题」的薄弱——RCEP 情景题换个人物背景、换个合并症就是新题,只记答案没有意义。

5. 从作答记录里挖出高频诱饵选项

做到几百道之后,attempt表本身就成了资料。统计每个选项被选中的次数和选中后的正确率,能定位出那些「看着最像答案」的诱饵。

SELECT q.domain, c.label, COUNT(*) AS picked_times, ROUND(AVG(c.is_key), 3) AS correct_when_picked, ROUND(AVG(a.grade), 2) AS avg_grade FROM attempt a JOIN question q ON q.qid = a.qid JOIN choice c ON c.qid = a.qid AND c.label = a.picked_label GROUP BY q.domain, c.label HAVING picked_times >= 20 ORDER BY correct_when_picked ASC;

correct_when_picked接近 0 而picked_times很高的标签,就是高吸引诱饵。实际排查中,这类选项往往集中在两类说法上:把绝对强度写成相对强度、把「相对禁忌」写成「绝对禁忌」。把它们拎出来,回头在选项表里加一个distractor_note字段记录为什么错,比多刷一百道新题有用。

另一个值得加的字段是作答耗时。人在犹豫时切换选项的次数和停留时长,比最终对错更能反映掌握程度,在练习界面里把picked_labelfinal_labelelapsed_ms三个值一起写进attempt,之后按elapsed_ms > 60000 AND grade >= 4筛出的题,就是「做对了但没吃透」的那一批——这些题应该被强制下调 ease,而不是按答对处理直接推到 6 天之后。

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

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

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

立即咨询