《信号与系统》期末卷PDF解析:结构化题库与FTS5检索
2026/9/17 13:56:08 网站建设 项目流程

简介:这份资源是西南科技大学《信号与系统》课程5套历年期末考试试卷及参考答案的PDF合集,面向本校修读该课程的本科生,以及考研复习、期末冲刺或需要刷题巩固的理工科学生。试卷按年份与A/B卷编排,覆盖填空题、判断题、证明题、绘图题、计算题、分析题与综合应用题等完整题型,涉及卷积与冲激响应、Z变换与LT变换、因果稳定性判定、采样定理、傅里叶变换与频谱分析、滤波器频率响应等核心考点;答案部分附有评分细则,便于对照自查与规范答题步骤。资源包共1个PDF文件,约1.76MB,篇幅紧凑、便于打印与移动端查阅,目录按考试场次顺序组织,可快速定位薄弱章节。目前已有1526人学习下载,适合用作期末前的系统自测与考点梳理材料。

1. 五套《信号与系统》期末卷摆在面前,第一步不是打开看而是当成数据集

每到期末,群里总会传一份「西南科技大学《信号与系统》5套历年期末考试(含答案).pdf」。多数人打开翻两页就放下了:手机上公式糊成一片,想只刷卷积那一章的题做不到,答案区写着「1.B 2.C」还得来回翻页对题号。做过数据清洗的人一眼能看出这不是复习问题,而是一份排版混杂、半结构化、夹带数学符号的 PDF 语料工程问题。把文本层、题号层级、公式片段、答案索引拆成四层数据之后,按知识点抽题、导进 Anki、做错题本才有地基。下面这套流程适合自己搭题库的开发者、做教育工具的后端,以及需要长期在 LaTeX 与 PDF 之间来回倒腾的人。

2. 先探文本层再谈解析:期末卷 PDF 的提取选型

2.1 用 PyMuPDF 判断这份卷子是文本层还是扫描件

拿到 PDF 别急着写解析器,先花三分钟探一下它是「真文本」还是「扫描图」。这两种情况后面的路子完全不同:文本层可以直接抽字符,扫描件必须先走 OCR,否则你抽出来的是一堆空行加图片对象。我一般用 PyMuPDF 做一次概率性抽样,只看前几页就够判断。

import fitz # PyMuPDF,pip install pymupdf from pathlib import Path def probe(path: str, sample_pages: int = 5) -> list[dict]: """抽样探测每页的文本层密度,判断是文本型还是扫描型 PDF""" doc = fitz.open(path) rows = [] for i, page in enumerate(doc): if i >= sample_pages: break # "text" 模式只取文本层,不含图片里的字 text = page.get_text("text").strip() imgs = page.get_images(full=True) rows.append({ "page": i + 1, "chars": len(text), # 纯文本字符数 "imgs": len(imgs), # 图片对象数量 # 密度:字符数 / 图片数,扫描件这一项会塌到个位数 "density": round(len(text) / max(len(imgs), 1), 1), }) doc.close() return rows for r in probe("swust-signals-final.pdf"): print(r)

逻辑说明:get_text("text")读的是 PDF 内部的字形流,扫描件里这一层基本是空的;get_images(full=True)返回页面上被引用的图片对象,扫描页通常一张整页大图。经验阈值是——正文页普遍低于 200 字符、同时每页都带整页图,就按扫描件处理。参数上sample_pages不用调大,5 页足够,因为历年卷的排版风格基本一致。

提示:有些卷子是「文字覆盖在扫描图之上」,肉眼看着一样,但文本层是有的。这种情况密度会中等偏高,往下走正常解析即可,别白白上一遍 OCR。

2.2 提取工具怎么选:pdfplumber、PyMuPDF、pdftotext 的边界

《信号与系统》试卷的难点不在字数,而在三样东西:题号层级(「一、选择题」下面挂「1.」「(1)」)、上下标和积分号这类数学排版、以及选择题的 A/B/C/D 分行对齐。不同工具在这三件事上的表现差别很大。

工具依赖公式/上下标保留版面坐标典型用途
PyMuPDF (fitz)自带二进制 wheel好,保留 glyph 顺序可拿到批量探测、全量取文
pdfplumberpdfminer.six一般,行内空隙偏多强,给到 word 级 bbox按坐标切选项、抽答案表
pdftotext -layoutpoppler一般靠空格凑命令行先看个大概
pdfminer.six纯 Python一般深度定制解析逻辑

我的常见组合是:PyMuPDF 负责把每页正文整段拉出来做初筛和索引,pdfplumber 只在需要精确切「A. xxx B. xxx」这种同行多列选项时上场,因为它给得出每个 word 的坐标。pdftotext 留给 shell 里快速的 grep,比如先确认这五套卷子里有没有出现「z 变换」章节的题。

# 先把五套卷子统一命名,方便后面按年份/学期建索引 mkdir -p papers/raw for f in *.pdf; do # 统一成 lower + 下划线,避免中文空格和全角括号带来路径问题 n=$(echo "$f" | tr '()' '()' | tr ' ' '_') mv "$f" "papers/raw/$n" done ls papers/raw

这一步看着琐碎,但后面所有脚本都靠文件名区分年份和 A/B 卷,命名乱了就得手工补元数据。

2.3 最小可用的分页落盘脚本

先把每页文本按页存成 JSONL,解析失败能定位到具体页,而不是重跑整本。

import fitz, json from pathlib import Path def dump_pages(pdf: str, out: str) -> int: doc = fitz.open(pdf) n = 0 with open(out, "w", encoding="utf-8") as fp: for i, page in enumerate(doc): rec = { "paper": Path(pdf).stem, # 卷子标识,来自文件名 "page": i + 1, "text": page.get_text("text"), "w": round(page.rect.width, 1), "h": round(page.rect.height, 1), } fp.write(json.dumps(rec, ensure_ascii=False) + "\n") n += 1 doc.close() return n Path("papers/jsonl").mkdir(parents=True, exist_ok=True) for p in Path("papers/raw").glob("*.pdf"): c = dump_pages(str(p), f"papers/jsonl/{p.stem}.jsonl") print(p.name, c, "pages")

page.rect顺手存下来是有用的:A4 与 B5 的宽度不同,后面按坐标切双栏排版时要用页面宽度做归一化。ensure_ascii=False必须加,不然中文题面会被写成\uXXXX,肉眼没法排查。

3. 题目切分与公式还原:把 5 套期末卷拆成结构化 JSON

3.1 题号层级识别:从「一、选择题」到「(1)」的正则

《信号与系统》试卷的骨架相当稳定:多数是「一、填空题 / 二、选择题 / 三、计算题 / 四、综合题」,大题下面是 1、2、3,小题是 (1)(2) 或 ①②。切分的关键是先定大题边界,再在区间内切小题,千万别一把梭全用行首数字正则——「1/2π」「0.5」这种内容行会被误伤。

import re # 大题:一、二、… 或 第一部分,后接 2~30 字的题型描述 SEC_RE = re.compile(r"^\s*([一二三四五六七八九十]+)\s*[、..]\s*(\S{2,30}?题)") # 小题:支持 (1) (1) 1. 1、 1. 四种写法 ITEM_RE = re.compile( r"^\s*(?:[((](\d{1,2})[))]|(\d{1,2})\s*[、..])\s*(.+)$" ) def split_paper(text: str) -> list[dict]: sections, cur_sec, cur_item = [], None, None for line in text.splitlines(): if m := SEC_RE.match(line): cur_item = None cur_sec = {"title": line.strip(), "items": []} sections.append(cur_sec) continue if (m := ITEM_RE.match(line)) and cur_sec is not None: # group(3) 是题面开头,前面两组分别对应两种题号写法 cur_item = {"no": m.group(1) or m.group(2), "stem": m.group(3).strip()} cur_sec["items"].append(cur_item) continue if cur_item is not None: # 续行:题干换行、公式独占一行都落在这里 cur_item["stem"] += "\n" + line.strip() return sections

逻辑上先把「大题」当成状态机,只有当cur_sec已存在时才认小题,能挡掉答案区里独立的行首编号。SEC_RE里的\S{2,30}?题用非贪婪是必要的,否则「二、选择题(每小题 3 分,共 30 分)」会把整行都吞成标题。续行拼接那一段不要漏,信号题的积分区间经常单独占一行,丢了这行题干就不完整。

3.2 公式怎么办:四种还原路线的取舍

公式是这份语料最麻烦的部分。傅里叶变换、拉普拉斯、Z 变换的题干里全是分式、上下标和积分号,纯文本抽取后会变成X(jw) = ∫ x(t)e^{-jwt} dt或者更糟的字符乱序。要不要上 OCR 取决于你的下游用途。

路线成本离线适合场景
纯文本抽取 + 手工替换规则最低只要能搜关键词,不要求排版还原
整页渲染成图后走数学 OCR视模型要生成可读题面、导出 PDF
只对含公式的行做局部裁剪识别题量不大、追求性价比
全部人工校对极高不适用要正式出版或对外发布

我一般走「先规则后 OCR」:先用替换表把高频写法归一化,剩下识别不了的行再裁图送数学 OCR。规则表不长,写十几条就能覆盖大部分卷子。

import re NORMALIZE = [ (r"\s*∫\s*", " ∫ "), # 积分号周围留空格,便于后续切词 (r"\bw\b", "ω"), # 卷子里常把 ω 打成 w,统一成希腊字母 (r"\be\s*\^\s*\{?(-?\s*j\s*[ωw]t)\}?", r"e^{jωt}"), (r"[((]\s*\)]", "()"), # 空括号,多为公式渲染失败 (r"\s+", " "), # 行内多空格压成一个 ] def normalize_formula(s: str) -> str: for pat, rep in NORMALIZE: s = re.sub(pat, rep, s) return s.strip()

参数说明:替换表顺序有讲究,积分号的空格要放在最前面,否则后面的\s+压缩会把刚加的空格吃掉。\bw\b一定要带词边界,不然会把width里的 w 也换掉。替换完之后如果还有大量「()」空括号,说明公式是以图形对象绘制的,纯文本路线到此为止,走裁剪识别。

注意:不要为了追求公式好看,把整本卷子按页整图送去 OCR。五套卷子里真正含复杂公式的页面通常不到三成,全量识别既慢又会在汉字上引入新错误。

3.3 落成一份能校验的 JSON Schema

结构化输出最好定死字段,后面写校验脚本才有的放矢。

{ "paper_id": "swust_signals_2019_a", "year": 2019, "term": "A", "sections": [ { "title": "三、计算题", "items": [ { "no": "1", "stem": "已知 x(t) = e^{-2t}u(t),求其拉普拉斯变换 X(s) 及收敛域。", "answer": "X(s) = 1/(s+2), Re(s) > -2", "knowledge": ["laplace", "roc"], "page": 4, "review": false } ] } ] }

page字段一定要留,人工校对时直接翻到原页对比,比在 JSON 里盲猜快得多。review是布尔位,标记那些公式没还原干净、需要人眼过一遍的条目,后面抽题时可以按需过滤。knowledge数组允许一题多标签,计算题经常同时考变换和收敛域。

4. 答案对齐与知识点检索:让「含答案」真正可用

4.1 把答案区的「1. B 2. C」回填到题目上

带答案的卷子通常把答案集中放在末尾几页,格式是选择题一长串字母,计算题则是按题号给几行过程。回填的难点是题号在不同大题里会重复(选择题的第 1 题和计算题的第 1 题),所以键必须是「大题序号 + 小题序号」。

import re ANS_LINE_RE = re.compile(r"(\d{1,2})\s*[、..::]\s*([A-D]|\S.{0,80})") def parse_answer_block(text: str) -> dict[str, str]: """把答案区解析成 {题号: 答案} 的扁平字典""" out = {} for line in text.splitlines(): for no, ans in ANS_LINE_RE.findall(line): # 后者优先:同一题号后出现的通常是更完整的解答 out[no] = ans.strip() return out def attach(sections: list[dict], ans: dict[str, str]) -> int: hit = 0 for sec in sections: for it in sec["items"]: a = ans.get(it["no"]) if a: it["answer"] = a hit += 1 else: it["review"] = True # 没配到答案,标记待人工处理 return hit

ANS_LINE_RE里那个\S.{0,80}分支是给计算题用的,它对选择题的单个字母同样能匹配。回填后统计hit数量,如果命中率明显低于题目总数,八成是大题序号没对上,需要回到切分阶段检查SEC_RE有没有漏掉某一节。

4.2 用 SQLite FTS5 建一个按知识点检索的题库

不用上向量库,SQLite 的 FTS5 处理几千道题绰绰有余,还能随手拷给别人。

-- 建表:题干、答案、知识点三个字段都参与检索 CREATE VIRTUAL TABLE q_fts USING fts5( paper_id UNINDEXED, no UNINDEXED, stem_text, answer_text, knowledge, tokenize = 'unicode61' );

写入时把题干和答案拆开放;knowledge存空格分隔的标签串。这里有个坑:FTS5 内置的unicode61对中文是按字切分的,搜「拉普拉斯」能命中,但搜「拉氏变换」这种同义说法就悬。要按词召回,就在写入前用分词器预切一遍。

import sqlite3, jieba def to_index_text(s: str) -> str: # cut_for_search 会额外切出细粒度词,提升短词召回 return " ".join(jieba.cut_for_search(s)) conn = sqlite3.connect("signals_bank.db") conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS q_fts USING fts5(" "paper_id UNINDEXED, no UNINDEXED, stem_text, answer_text, " "knowledge, tokenize='unicode61')") for rec in load_all_items("papers/jsonl"): # 上一章产出的结构化数据 conn.execute( "INSERT INTO q_fts VALUES (?,?,?,?,?)", (rec["paper_id"], rec["no"], to_index_text(rec["stem"]), to_index_text(rec.get("answer", "")), " ".join(rec.get("knowledge", []))) ) conn.commit() # 查询:找所有涉及卷积的题,按卷子排序 rows = conn.execute( "SELECT paper_id, no, stem_text FROM q_fts " "WHERE q_fts MATCH ? ORDER BY paper_id, no", ("卷积",) ).fetchall()

to_index_text只作用于入库文本,查询词也要走同一个函数,否则两侧切分粒度不一致会导致漏召回。ORDER BY里用paper_id, no而不是rank,是因为复习时更想按年份顺序看知识点演化,而不是看相关度。

4.3 知识点打标:把题目映射到课程主线

自动打标先做关键词命中,再对未命中的题做人工补标。下面是覆盖《信号与系统》主线的映射表,可以直接喂给标注脚本。

知识点标签命中关键词典型题型
convolution卷积、h(t)、冲激响应求零状态响应
fourier_series傅里叶级数、三角形式、指数形式周期信号展开
fourier_transform傅里叶变换、频谱、幅度谱求频谱并画图
sampling抽样、采样定理、奈奎斯特判断能否无失真恢复
laplace拉普拉斯、s 域、收敛域求 X(s) 与 ROC
z_transformZ 变换、z 域、单位圆求 X(z) 与收敛域
stability稳定、极点、右半平面系统稳定性判定
state_space状态方程、状态变量求状态转移矩阵

打标脚本先在题干里做关键词扫描,命中多个就写多个标签,一个都没命中的丢进review。这一步别追求全自动,五套卷子总共也就一两百道题,人工过一遍的时间和调参的时间差不多,但准确率高得多。

5. 进阶:抽题组卷、去重与质量校验

5.1 按知识点配比组卷

有了标签表,组卷就是一次带配额的抽样查询——每组知识点抽固定数量,优先抽没做过或做错过次数多的题。

-- 每个知识点抽 3 道,优先抽历史错误次数高的题 SELECT k.knowledge, q.paper_id, q.no, q.stem_text FROM q_fts q JOIN item_stat k ON k.paper_id = q.paper_id AND k.no = q.no WHERE q.knowledge MATCH ? ORDER BY k.wrong_count DESC, RANDOM() LIMIT 3;

ORDER BY k.wrong_count DESC, RANDOM()的顺序不能反:先按错题优先,同权重再随机,保证每次组的卷子既针对薄弱点又不完全重复。item_stat是一张普通表,记录做题次数、错误次数和最近练习时间,和 FTS 表用paper_id + no关联。

5.2 三个必查的质量指标

题库跑起来之后,最怕的是静默错误:题目缺了后半段、答案配错了题号、同一道题被重复入库。加一段校验,三项都能量化。

指标计算方式预警阈值
题干完整度以「。」「?」结尾的题干占比低于 85% 要回查切分
答案配平率有 answer 字段的题 / 总题数低于 90% 查答案区解析
重复率题干 MinHash 相似度 > 0.9 的题对数大于 0 逐对人工确认

题干完整度这项最能暴露问题。信号题的题干常以公式结尾,不以句号收尾,所以阈值不能设太高,85% 是个经验值。重复检测用 MinHash 不用精确匹配,是因为同一道题在不同年份的卷子上往往只改了一两个数字,纯文本比对发现不了。

5.3 一个具体技巧:把校验固化成一条命令

最后落一个make check目标,把上面三步串起来,五套卷子重跑一次不到十秒。加卷子的时候只把新 PDF 丢进papers/raw,其余全自动。

check: python scripts/split.py --in papers/jsonl --out data/items.json python scripts/validate.py data/items.json \ --min-complete 0.85 --min-answer 0.90 --dedup-threshold 0.9 python scripts/index.py data/items.json --db signals_bank.db @echo "OK: 题库校验与索引完成"

关键在于validate.py用非零退出码报错,这样接进 CI 或者在本地随手跑都能拦住坏数据。新卷子进库的边际成本降到一条命令,题库才敢持续加。

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

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

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

立即咨询