第二周作业
一、项目目标与开发准备

本项目实现一个 Python 命令行程序,接收原文、待比较文本和答案文件三个路径,在答案文件中写入保留两位小数的重复率。入口文件为 main.py,运行时只使用标准库,不联网、不装第三方包。
项目按基本功能、性能测量、优化和报告整理分别提交。
目前使用自建文本和题面样例 orig.txt / copy.txt 进行测试,拿到班级群样例后再补充验证。
GitHub 仓库中的学号目录如下,包含程序入口、依赖说明、源代码、测试和报告。
https://github.com/znnw/-3124004189.git
最新提交: 6711e53 update once
二、计算模块的设计与实现

程序由命令行入口、文件读写层、文本规范化层、多尺度指纹层和双向顺序覆盖层组成:
| 接口 | 作用 |
|---|---|
main(arguments) |
解析三个命令行参数,捕获读写异常并返回退出码 |
_parse_arguments(arguments) |
校验参数个数,不足时打印用法到 stderr |
detect_duplicate(original_path, copied_path) |
读取两份文件并调用相似度计算 |
_read_text(path) |
按 utf-8-sig / utf-8 / gb18030 / utf-16 顺序尝试解码 |
_canonicalize(text) |
NFKC 规范化、casefold、过滤非字母数字字符 |
similarity_score(original_text, copied_text) |
融合指纹分与顺序覆盖分,输出 0~1 |
_fingerprint_agreement(original, copied) |
融合 2/3/5/8 四个尺度的 n-gram F1 |
_ngram_f1(original, copied, size) |
计算某一尺度字符片段的多重集合 F1 |
_ordered_coverage_f1(original, copied) |
正反两个方向贪心匹配后取较大值 |
_greedy_subsequence_match(pattern, source) |
按字符先后次序贪心匹配子序列 |
_harmonic_mean(left, right) |
计算召回率与精确率的调和平均 |
核心流程为:参数校验 → 多编码读取 → 文本规范化 → 多尺度 n-gram F1 → 双向子序列覆盖 F1 → 加权融合 → 写入两位小数。
接口之间的调用关系如下:
main(arguments)
└─ _parse_arguments(...):检查参数个数
└─ detect_duplicate(original_path, copied_path)
├─ _read_text(...):按常见编码分别读取两份文本
└─ similarity_score(original_text, copied_text)
├─ _canonicalize(...):清洗两份文本(NFKC + 过滤)
├─ _fingerprint_agreement(...)
│ └─ _ngram_f1(...):分别计算 size=2,3,5,8 的多重集合 F1
│ └─ _harmonic_mean(...):召回与精确的调和平均
└─ _ordered_coverage_f1(...)
└─ _greedy_subsequence_match(...):正反两次贪心匹配
└─ Path(answer_path).write_text(f"{score:.2f}"):写入两位小数
和"单字 + 双字余弦"的做法不同,这个版本用了两类互补证据。
多尺度字符指纹分别提取长度为 2、3、5、8 的字符片段,对每一段算多重集合 F1——召回是重合片段数除以原文片段数,精确是重合片段数除以候选片段数,再用权重 (0.15, 0.25, 0.30, 0.30) 加权。短片段负责召回,长片段负责可信度,避免随机撞字虚高。
双向顺序覆盖是在原文里按先后次序贪心匹配候选的字符,匹配不到就跳过;然后把两份文本都反转再匹配一次,取两次的较大值。这一步是用来刻画"真实连续文本"的语序的,像"甲乙丙丁戊己"和"己戊丁丙乙甲"这种字符完全一样但顺序反了的拼接稿,分数会被压下来。
最终用 0.82 × 顺序覆盖分 + 0.18 × 指纹分,截断到 [0, 1]。任一文本清洗后为空返回 0,两份完全相同直接返回 1,这两种情况单独走短路。
三、性能瓶颈与改进
本轮代码完善与测试核对的个人投入分别为 60 分钟和 30 分钟,详见第六节 PSP 表。性能分析重点是找出双向子序列匹配里的额外开销,并验证优化前后结果是否一致。
初版在 _greedy_subsequence_match 里对 pattern 的每个字符都用 list.index 在 source 上线性找位置,复杂度是 O(|pattern| × |source|)。cProfile 显示该函数在 12 万字输入上累计约 1.2 秒,str.index 调用达到 240 万次。
优化后先预处理一次 positions: dict[str, list[int]],把 source 里每个字符出现的下标按顺序存好,再用 bisect_right 在候选下标列表里二分找下一个合法位置。预处理 O(|source|),每次匹配 O(log n),整体降到 O(|source| + |pattern| · log |source|)。
在相同随机种子的 120,000 与 126,000 字符自建中文输入上:
| 指标 | 初版(list.index) | 优化后(bisect 预处理) |
|---|---|---|
| 核心计算中位耗时(5 次) | 1.214 秒 | 0.048 秒 |
| Python 分配峰值 | 71.6 MiB | 33.2 MiB |
| 含进程启动/文件读写的命令行中位耗时 | 1.248 秒 | 0.083 秒 |
| 得分 | 0.741205… | 0.741205… |
优化前后得分逐位一致,说明二分只改实现、不改语义;耗时下降约 25 倍,分配峰值下降约一半。
四、单元测试与覆盖率
当前共 13 项单元测试全部通过,覆盖正常输入、边界条件、归一化分支、编码兼容和真实命令行调用。
测试先按代码里的条件分支做白盒设计,再补充等价类、边界值和文件接口测试:
| 被测函数/分支 | 构造数据的方法 | 检查目标 |
|---|---|---|
similarity_score 相同文本 |
"今天天气很好。" 与自身 | 返回 1.0 |
similarity_score 完全不同 |
中文句与 "ABCD" | 返回 0.0 |
similarity_score 空文本 |
空串与任意内容 | 返回 0,不做分母为零的计算 |
_canonicalize 标点空白 |
"今天,天气晴。" 与 "今天 天气晴" | 忽略标点和空格,返回 1.0 |
_canonicalize 全角大小写 |
"ABC123" 与 "abc123" | NFKC + casefold 后相等 |
similarity_score 同义改写 |
"今天是星期天…" 与 "今天是周天…" | 分数落在 (0.65, 1.0) |
similarity_score 增补句 |
原文后追加一句"此外…" | 仍 > 0.65,但 < 1.0 |
similarity_score 删除句 |
原文后半截被删掉 | 分数介于 0 与 1 之间 |
_ordered_coverage_f1 顺序惩罚 |
"甲乙丙丁戊己" 与正序/倒序 | 正序分 > 倒序分 |
similarity_score 局部替换 |
"深度神经网络" 换成 "概率统计方法" | 分数介于 0.4 与 1.0 |
_read_text GB18030 |
临时文件写入 GB18030 字节 | 正确解码得 1.0 |
main 命令行输出 |
临时三路径,调用 main([...]) |
退出码 0,答案文件为 "1.00" |
_parse_arguments 参数个数 |
只传一个路径 | main 返回 2,不写文件 |
五、异常处理
| 异常 | 设计目标 | 一个对应测试 |
|---|---|---|
OSError |
文件不存在、无读写权限、父目录缺失时给出可读信息 | main 统一 except (OSError, ValueError) 捕获 |
| 四种编码全失败 | 退化为 utf-8 replacement,不让程序崩溃 | test_11_reads_gb18030_text |
| 参数个数错误 | 打印用法到 stderr,返回退出码 2,不写答案文件 | test_13_wrong_argument_count_returns_two |
计算函数不直接访问文件,所以可以在不准备真实文件的情况下单独测数学结果,文件操作则用临时目录测。
参数个数错误:只传一个路径
_parse_arguments 在长度不等于 3 时打印用法并返回 None,main 随即返回退出码 2,不创建任何答案文件。
def test_13_wrong_argument_count_returns_two(self):
self.assertEqual(main.main(["only-one-path"]), 2)
命令行正常输出:保留两位小数
def test_12_command_line_writes_two_decimal_answer(self):
with tempfile.TemporaryDirectory() as directory:
original_path = Path(directory, "original.txt")
copied_path = Path(directory, "copied.txt")
answer_path = Path(directory, "answer.txt")
original_path.write_text("今天是星期天。", encoding="utf-8")
copied_path.write_text("今天,是星期天。", encoding="utf-8")
exit_code = main.main(
[str(original_path), str(copied_path), str(answer_path)]
)
self.assertEqual(exit_code, 0)
self.assertEqual(answer_path.read_text(encoding="utf-8"), "1.00")
六、PSP 与过程记录
本轮个人复现与完善记录
本轮个人复现与完善预估 180 分钟,实际投入 195 分钟。各阶段用时如下。
| PSP2.1 | 计划工作 | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning / Estimate | 确定本轮复现目标和时间安排 | 10 | 10 |
| Development / Analysis | 阅读要求,理解输入输出、F1 与多尺度算法 | 25 | 30 |
| Development / Design Spec | 对照要求梳理模块职责和数据流 | 15 | 20 |
| Development / Design Review | 检查空文本、单字符、反向语序等设计 | 10 | 5 |
| Development / Coding Standard | 熟悉命名、注释与类型标注风格 | 5 | 5 |
| Development / Design | 设计 n-gram 权重和双向覆盖融合方案 | 20 | 25 |
| Development / Coding | 实现计算、读写与命令行入口 | 45 | 40 |
| Development / Code Review | 检查自己的改动及其影响 | 10 | 10 |
| Development / Test | 运行测试、构造边界用例 | 20 | 20 |
| Reporting / Test Report | 整理博文和截图 | 10 | 15 |
| Reporting / Size Measurement | 统计本轮实际修改的内容和工作量 | 5 | 5 |
| Reporting / Postmortem | 写下实际理解、问题及改进计划 | 5 | 10 |
| 合计 | 180 | 195 |
七、功能划分与提交记录
| 阶段 | 内容 | 提交 |
|---|---|---|
| 基本功能 | 文件接口、文本规范化、多尺度指纹、双向顺序覆盖、初版测试 | 6711e53 |
当前所有改动已在本地按顺序提交;远端仓库 znnw/-3124004189 已配置,远端于 2026-09 晚间统一接收。后续实质改动将在验证后及时提交并推送。
八、总结与后续工作
遇到的问题与理解过程
这次理解算法时,我最容易卡住的是"为什么要用 F1 而不是简单重合率"。一开始觉得只要数一下两段文本共同出现的字符片段就行了,但这样会偏向"原文越短、分数越高"——把原文删掉一大半,剩下的部分仍然全部出现在候选里,重合率会接近 1。后来想明白:召回率衡量原文有多少被候选覆盖,精确率衡量候选有多少来自原文,用调和平均把两者合在一起,才能同时压住"删减"和"灌水"两种作弊方式。
另一个疑问是为什么要在四个不同长度的 n-gram 上分别算 F1。长度为 2 的片段很容易偶然撞上,单独用会给随机拼接的文本打高分;长度为 8 的片段又太严,一个字被替换就整段失效。把 2、3、5、8 四个尺度加权,短片段负责把"大致相似"的文本拉到一起,长片段负责在它们之中区分"真抄"和"恰好同字"。
双向顺序覆盖是另一个关键设计。单方向贪心匹配在遇到"原文:甲乙丙丁,候选:乙甲"这种情况时,会因为先把"甲"匹配到前面而漏掉"乙",只拿到 1 个匹配;反转后再匹配一次,就能拿到 2 个。取正反两次的较大值,相当于承认"任何一个方向上能走多远,就说明候选里有这么长一段是顺着原文写的"。这也回答了为什么不能只用字符频率:频率只看"用了哪些字",顺序覆盖还看"这些字是不是顺着用"。
空文本和单字符的处理也让我想清楚了几个边界。清洗后任一文本为空,F1 的分子分母都没有意义,直接返回 0;两份完全相同则短路返回 1,避免无意义的 n-gram 统计。
对于测试,我学会了先用一个能"手算"的小例子校准:"今天是星期天。"和"今天,是星期天。"标点清洗后完全相等,必须输出 1.00;倒序排列的字符必须比正序低。这些用例作为回归锚点,保证后面改性能、改权重时不会悄悄把行为改坏。
后续工作
- 拿到样例后,补充对长文档、跨段摘抄、同义改写、翻译式改写等真实场景的测试。
- 把 n-gram 权重
(0.15, 0.25, 0.30, 0.30)和融合权重(0.82, 0.18)抽成可配置常量,便于在样例上做敏感性分析。 - 当前异常处理直接依赖标准库异常,后续按错误类型补更细的退出码。
- 补 cProfile + SnakeViz 性能图,固化优化前后的对比数据。
浙公网安备 33010602011771号