博客-论文查重

个人项目:论文查重

作业 GitHub 链接https://github.com/McCainJared/softwareHomework/tree/main/3124004169

项目 内容
这个作业属于哪个课程 计科 24 级 56 班
这个作业要求在哪里 个人项目
这个作业的目标 实现一个论文查重程序:给定原文与抄袭版论文,输出重复率;完整走一遍"需求 → 设计 → 编码 → 测试 → 性能优化 → 复盘"的软件工程流程

〇、先看结果

命令行调用(Python 3,无需安装任何第三方库):

python main.py data/orig.txt data/orig_add.txt data/ans.txt

官方样例 orig.txt(今天是星期天,天气晴,今天晚上我要去看电影。)
orig_add.txt(今天是周天,天气晴朗,我晚上要去看电影。)的运行结果是:

重复率: 0.77

答案文件 ans.txt 的内容为 0.77

用例 原文 → 抄袭版 结果
完全相同 orig.txt → 自己 1.00
增(追加段落) 原文 → 原文 + 新段落 0.89
改(同义替换) 原文 → 大段换词 0.81
删(每句只留主干) 原文 → 删掉一半内容 0.77
官方样例 orig.txt → orig_add.txt 0.77
无关(换个领域) 软件工程 → 珠海旅游 0.45
空文件 原文 → 空文件 0.00

一、PSP 表格(编码之前填写)

预估

PSP2.1 Personal Software Process Stages 预估耗时(分钟)
Planning 计划 30
· Estimate · 估计这个任务需要多少时间 30
Development 开发 540
· Analysis · 需求分析(包括学习新技术) 60
· Design Spec · 生成设计文档 40
· Design Review · 设计复审 20
· Coding Standard · 代码规范(为目前的开发制定合适的规范) 20
· Design · 具体设计 60
· Coding · 具体编码 180
· Code Review · 代码复审 30
· Test · 测试(自我测试,修改代码,提交修改) 70
· Optimization · 性能剖析与优化 60
Reporting 报告 120
· Test Report · 测试报告 40
· Size Measurement · 计算工作量 20
· Postmortem & Process Improvement Plan · 事后总结,并提出过程改进计划 60
合计 690

预估时的几个判断:

  • 我 Python 只会写点脚本,没做过"要过自动化评测"的程序,所以把需求分析和
    具体设计的预算给得比较高(读了半天的 SimHash 原理);
  • 性能改进估了 60 分钟——实际花了 90 分钟,因为一开始"优化"的方向完全是错的,
    后面还换了测量方法(详见第三节);
  • 测试估了 70 分钟(题目要求至少 10 个用例)——实际花了 110 分钟,
    多出来的时间几乎全用在修"假设本身就写错了"的用例上(详见第六节)。

总体估了 690 分钟、实际 720 分钟,总数只差 4.3%,但这是两项超了 50%、
另外几项又估多了相互抵消的结果。这说明我估"总量"还算靠谱,
估"结构"很差——这也是 PSP 表真正的价值所在。


二、计算模块接口的设计与实现过程

2.1 代码是怎么组织的

按"一层只干一件事"的思路拆成 6 个模块,没有循环依赖,依赖方向严格单向

文件 职责 对外接口
simhash.py SimHash 指纹与汉明距离 build_fingerprint()hamming_distance()hamming_similarity()
tokenizer.py 归一化与字符 n-gram 切分 normalize_text()meaningful_text()char_ngrams()get_tokenizer()
similarity.py 查重门面(Facade) PaperChecker.similarity()
text_reader.py 唯一的磁盘 I/O 出口 read_text()write_answer()
errors.py 自定义异常 PaperCheckError 及其 3 个子类
main.py 命令行入口、参数校验、统一异常处理 main()run()parse_args()

一共 1 个业务类PaperChecker)、4 个自定义异常类18 个函数
上层只认下层的"接口",不关心实现——比如 similarity.py 只知道
tokenizer.get_tokenizer() 返回一个"文本 → 特征列表"的可调用对象,
因此以后要换成 jieba 分词,similarity.py 一行都不用改。

2.2 模块之间的关系与主流程

                     命令行
                        │  sys.argv[1:](3 个路径)
                        ▼
       ┌────────── main.parse_args() ──────────┐
       │  参数个数 ≠ 3                          │
       └──────────────▶ ArgumentError           │
                        │                      │
                        ▼                      │
        read_text(原文)      read_text(抄袭版)   │  text_reader.py
                        │                      │  (编码探测 + 异常包装)
                        └──────────┬───────────┘
                                   ▼
                   similarity.PaperChecker.similarity()
                                   │
              ┌────────────────────┴────────────────────┐
              ▼                                         ▼
       fingerprint(原文)                         fingerprint(抄袭版)
              │                                         │
              ├─ tokenizer.char_ngrams()                ├─ tokenizer.char_ngrams()
              ├─ simhash.count_features()               ├─ simhash.count_features()
              └─ simhash.build_fingerprint()            └─ simhash.build_fingerprint()
              │                                         │
              └────────────────────┬────────────────────┘
                                   ▼
                    simhash.hamming_similarity()  →  [0.0, 1.0]
                                   │
                                   ▼
                text_reader.write_answer()  →  "0.77"

2.3 关键函数的流程:SimHash 指纹是怎么算出来的

build_fingerprint() 是整个程序的核心,流程如下:

  特征序列  ["今天", "天是", "是星", "星期", ...]
        │  count_features()  → collections.Counter
        ▼
  特征 → 权重   {今天: 2, 天是: 1, 是星: 1, ...}
        │  hash_feature()  → blake2b 取前 64 位
        ▼
  每个特征 → 一个 64 位整数(哈希值)
        │   ★ 只遍历哈希中"置 1"的位(平均 32 位,而不是全部 64 位)
        ▼
  ones_weight[0..63]:第 i 位被置 1 的那些特征的权重之和
  total_weight      :所有权重之和
        │
        ▼  第 i 位最终累加值 = 2 * ones_weight[i] - total_weight
           所以「是否大于 0」等价于「2*ones_weight[i] > total_weight」
  fingerprint:64 位整数

拿到两个指纹后,相似度就是:

相似度 = 1 - 汉明距离(指纹A, 指纹B) / 64

2.4 算法的关键与独到之处

关键 1:为什么用字符二元组(bigram)而不是 jieba 分词?

一开始我按网上的常见做法装了 jieba,但实测数据让我改了主意:

分词策略 官方样例 无关文本(软件工程 vs 珠海旅游)
jieba 词级 0.72 0.81
字符二元组 0.77 0.45

字符二元组不但命中了参考答案 0.77,对无关文本的区分度还明显更好
(0.45 表示"几乎不相关",而 0.81 会把一篇无关的文章判成高度雷同)。
更实际的一点是:字符二元组只用标准库,评测机不装 pip 依赖也能跑
jieba 作为可选能力保留(--tokenizer jieba),默认关闭。

关键 2:位并行的 SimHash。
教科书式写法的内层是"对每个特征循环 64 次、逐位判断加还是减"。
但第 i 位的累加值其实等于 2 * 置位权重和 - 总权重,符号只取决于
置位权重和 是否超过总权重的一半。于是内层只需要遍历置 1 的位
(平均 32 次),再用一张 256 项的"字节 → 置位下标"预计算表
_BIT_INDICES)把位移和判断全省掉。实测在 20 万特征上 1.993 s → 0.902 s(2.21 倍)

关键 3:零第三方依赖 + 结果可复现。
指纹用的哈希是 blake2b 而不是内置 hash()——后者在 Python 3.3+ 默认
带随机盐,同一个文件在不同进程会得到不同指纹,答案不可复现。
四步流水线(归一化 → 过滤 → 切分 → 计数)全部交给 reCounter
这类 C 层实现:端到端快了 2.08 倍,峰值内存从 395.6 MB 降到 206.4 MB
(省掉"逐个字符塞进 list"这一大块中间对象)。


三、计算模块接口部分的性能改进

3.1 我一开始的"优化"是错的

动手优化前,我做了两件"觉得很对"的事:

  1. 把内层循环从"逐位判断"改成"只遍历置位";
  2. 给特征数量加了上限——默认只保留权重最高的 5 万个特征,
    理由是"特征少了,算得就快"。

第 1 件有用(2.21 倍)。第 2 件是负优化。cProfile 打了我的脸
(下表是对"优化前"那一版做的剖析,输入是 305 万字的原文 + 抄袭版):

   Ordered by: internal time(优化前的实现,函数总耗时 5.855 s)
   ncalls  tottime  percall  cumtime  percall  function
        2    1.229    0.615    3.529    1.801  legacy_char_ngrams()      ← 切 n-gram,59.9%
  5999998    1.034    0.000    1.034    0.000  {method 'get' of 'dict'}   ← 手工累加词频 18.6%
        2    0.920    0.460    1.500    0.773  legacy_meaningful_chars()  ← 逐字符 isalnum 25.7%
        2    0.827    0.413    1.861    0.988  legacy_count_features()   ← 词频统计 32.9%
  5999998    0.800    0.000    0.800    0.000  {method 'join' of 'str'}   ← 拼 n-gram 13.7%
  6099998    0.529    0.000    0.529    0.000  {method 'isalnum' of 'str'}  ← 9.5%
        2    0.286    0.143    0.340    0.156  fingerprint_naive()       ← SimHash 核心,只占 5.8%

结论:真正的瓶颈是切 n-gram(3.53 s,60.3%)、词频统计(1.86 s,31.8%)
和逐字符过滤标点(1.50 s,25.6%);而 SimHash 核心 fingerprint_naive()
的累计耗时只有 0.340 s,占 5.8%。

我费劲优化的那部分,本来就是最不花时间的部分。
更尴尬的是"截断特征":它为了少算那 5.8% 的活儿,
先花 O(V log V) 把整个词表排了一遍序,纯属白白付出成本。

3.2 有数据之后的改进

按 cProfile 指出的位置逐个换掉:

改动 优化前 优化后 依据(cProfile 实测)
过滤标点/空白 Python 循环逐字符 isalnum() re.sub(r"[\W_]+", "", ...) 一次扫描 legacy_meaningful_chars 累计 1.50 s,isalnum 被调 610 万次
切 n-gram 循环里切片 + "".join() 带前瞻的正则 (?=(.{2})) 一次取出全部 legacy_char_ngrams 累计 3.53 s,join 被调 600 万次
词频统计 循环 dict.get 累加 collections.Counter(C 实现的 _count_elements legacy_count_features 累计 1.86 s,dict.get 被调 600 万次
特征截断 默认截断到 5 万 默认不截断(保留为可选开关) SimHash 核心只占 5.8%,排序纯属浪费
SimHash 内层 逐位判断 64 次 只遍历置位 + 预计算表 孤立基准 2.21 倍

顺带记一个测量方法上的坑:我最初用 tracemalloc 把计时和内存一起测,
结果"优化前"的耗时被放大了 3 倍多(6 s 变成 24 s),而且两次基准能差出 10%,
把我带偏了一轮。tracemalloc 是逐次分配插桩的,对"大量创建小字符串"的分词
代码开销极大。最后改成耗时与内存分两趟测(计时不开 tracemalloc
重复 5 次取最小值;内存单独跑一趟),数字才稳定下来。
这件事跟上一件是同一个教训:基准本身也会骗人,得先确认测量方法可信。

3.3 性能分析图

image
)

屏幕截图 2026-09-15 223843
)

image

(图由 tools/benchmark.py 调用 cProfile 自动生成,图中标签与耗时均来自真实测量,
复现命令:python tools/benchmark.py。)

3.4 改进前后的数据

基准一:SimHash 核心(孤立,20 万个特征,测的就是 2.4 节那个内层循环)

方案 耗时 相对加速
逐位判断(教科书写法) 1.803 s 1.00x
只遍历置位 + 2*ones>total 0.954 s 1.89x
置位遍历 + 按权重排序截断到 5 万 0.342 s 5.27x

基准二:端到端完整流水线(305 万字的原文 + 抄袭版,词表 1.9 万个二元组)

方案 耗时 相对加速 峰值内存
① 优化前:逐字符分词 + 朴素逐位 SimHash 3.351 s 1.00x 395.6 MB
② 仅替换分词:正则 + Counter 2.960 s 1.13x 206.4 MB
③ 仅替换哈希:位并行 3.410 s 0.98x 395.6 MB
优化后(当前默认) 2.103 s 1.59x 206.4 MB
⑤ 在 ④ 上再加特征截断到 5 万 2.520 s 1.33x 206.5 MB

头条结论是 ① → ④:端到端 1.59 倍,峰值内存降 48%。

②③ 是"只改一个变量"的对照行,方向可信,但这台机器上单次运行的波动不小
(同一份代码换个时间跑,②③ 的次序甚至会调换),所以我不拿这两行下强结论。
真正站得住脚的证据是 cProfile 的函数级拆分(见 3.1 节的表):优化前
切 n-gram 占 59.9%、词频统计占 32.9%、逐字符过滤占 25.7%,
而 SimHash 核心只占 5.8%——时间花在哪,是函数级数据直接告诉我的,
不是靠对照行的差值猜的。

第 ⑤ 行也稳定地比 ④ 略慢:在 1.9 万词表的规模下,"截断到 5 万"根本截不到
任何特征,却白白多排了一次序。这正是我把它改成默认关闭的依据。

关于数据可信度:上表取多次运行的最小值(核心 3 次、端到端 5 次),
以压制 GC 与系统调度带来的噪声。即便如此,同一份代码在不同时间跑,
绝对耗时仍会有 ±20% 上下的浮动,所以重点是相对关系(谁快谁慢、差几倍),
不是小数点后的第三位
。复现命令:python tools/benchmark.py

程序消耗最大的函数(优化后,cProfile 按自身耗时 tottime 排序,累计 3 次运行,总计 7.393 s):

函数 自身耗时 占比
re.Pattern.findall(切 n-gram) 3.739 s 50.6%
_collections._count_elements(词频统计) 2.065 s 27.9%
re.Pattern.sub(过滤标点) 0.470 s 6.4%
simhash.build_fingerprint(SimHash 核心) 0.466 s 6.3%
str.lower(转小写) 0.150 s 2.0%
simhash.hash_feature(blake2b 哈希) 0.125 s 1.7%

消耗最大的函数是 re.Pattern.findall:它要为 305 万个二元组逐个创建
字符串对象,占了近一半时间。这块属于"必须付的成本"——继续压榨就得引入
numpy 之类的第三方依赖,但那样会破坏"零依赖开箱即跑"的特性,性价比不高,
所以到这里停手。

关于 5 秒限制: 按评测方式用子进程实跑一次完整命令行,
305 万字(两份合计 610 万字)的墙钟耗时是 1.88 s,满足 5 秒预算,
留有约 2.6 倍余量。而本作业实际使用的论文测试文本通常在几十 KB 量级,
耗时在 0.1 s 以内。测试里的 30 万字性能守护用例
tests/test_similarity.py::test_large_document_finishes_within_time_budget
实测 0.13 s)会在以后任何改动把复杂度推向平方级时立刻报警。


四、计算模块部分单元测试展示

tests/ 下共 64 个用例,全部通过

测试文件 用例数 测试对象
test_tokenizer.py 11 归一化、标点过滤、n-gram 切分、策略选择
test_simhash.py 7 哈希离散性、词频统计、排序截断、汉明距离换算
test_similarity.py 21 查重主流程、增删改乱序、边界、性能守护
test_text_reader.py 14 编码探测、文件读写、IO 异常包装
test_main.py 11 参数校验、端到端、退出码契约

4.1 测试数据的构造思路

核心一句话:不写死"某个具体的分数",而是断言排序关系与取值范围。
因为 SimHash 的相似度会随权重、分词策略微调而浮动,如果测试里写
assert score == 0.77,那随便改点实现就会红一片,这种测试是负资产。

所以我的做法是:拿一段"论文体"文字做基准,用增、删、改、乱序四种手段
各造一份抄袭版,再配一段完全不同领域的文本做对照组,然后断言:
相同 = 1.00 > 改 > 增/删 > 无关。这样即使实现细节变,测试的含义依然成立。

ORIGIN    = "软件工程强调用工程化的方法开发和维护软件,重视需求分析、总体设计……"
MODIFIED  = "软件工程强调用工程化的手段开发和维护软件,看重需求分析、总体设计……"  # 改:换词
DELETED   = "软件工程强调用工程化的方法开发和维护软件。良好的设计能降低……"          # 删:只留主干
EXTENDED  = ORIGIN + "随着人工智能工具在开发过程中的普及……"                        # 增:追加段落
SHUFFLED  = "清晰的命名与必要的注释是后来者理解系统的线索。软件工程强调……"          # 乱序
UNRELATED = "珠海位于广东省南部,濒临南海,是一座风景秀丽的海滨城市……"              # 对照

4.2 几个有代表性的用例

① 排序关系(核心能力)

def test_similar_text_scores_higher_than_unrelated(checker):
    """抄袭版的分值必须高于无关文本——这是查重器最基本的排序能力。"""
    assert checker.similarity(ORIGIN, MODIFIED) > checker.similarity(ORIGIN, UNRELATED)


def test_modified_text_keeps_high_similarity(checker):
    """仅做同义替换的抄袭版仍应被判为高度重复。"""
    assert checker.similarity(ORIGIN, MODIFIED) > 0.7

② 边界:空文本 / 纯空白 / 纯标点

@pytest.mark.parametrize("blank", ["", "   ", "\n\t ", ",。!"])
def test_blank_text_yields_zero(checker, blank):
    """空文本、纯空白、纯标点都不含有效特征,按"无法判定重复"返回 0.0。"""
    assert checker.similarity(ORIGIN, blank) == 0.0
    assert checker.similarity(blank, ORIGIN) == 0.0
    assert checker.similarity(blank, blank) == 0.0

③ 用测试固化一个"反直觉"的特性

改到一半时我有一个用例红了:把原文整句顺序打乱之后,得分居然
比只换词还高。查了才知道 SimHash 是"词袋模型"——它只统计特征,
根本不看特征之间的顺序,所以整句重排几乎不影响指纹。

这不是 bug,是算法的固有特性(也可能是它的局限)。我没有把这条断言
悄悄删掉,而是把它改成显式记录这个行为

def test_reordered_sentences_still_score_high(checker):
    """整句重排不会明显降低得分——这是 SimHash 作为"词袋模型"的已知特性。

    特征集合(n-gram 的多重集合)没有变化,指纹自然几乎一致。
    这里把该行为显式固化下来:以后若有人改成"顺序敏感"的实现,
    这条用例会立刻提醒他这是有意的语义变更,而不是回归缺陷。
    """
    assert checker.similarity(ORIGIN, SHUFFLED) > 0.9

④ 性能守护用例

def test_large_document_finishes_within_time_budget():
    """约 30 万字的长文本查重应在 5 秒内完成(题目给出的时间上限)。"""
    paragraph = ORIGIN + "\n"
    big_origin = paragraph * 2000
    big_copy = (MODIFIED + "\n") * 2000

    start = time.perf_counter()
    score = PaperChecker().similarity(big_origin, big_copy)
    elapsed = time.perf_counter() - start

    assert elapsed < 5.0, f"长文本耗时 {elapsed:.2f}s,超出 5 秒预算"
    assert score > 0.7

⑤ 端到端 + 退出码契约

def test_run_writes_answer_file(tmp_path):
    """完整链路:读两个文件 → 计算 → 把两位小数写进答案文件。"""
    origin = _write(tmp_path / "orig.txt", ORIGIN)
    copied = _write(tmp_path / "copy.txt", COPY)
    answer = str(tmp_path / "ans.txt")

    score = run([origin, copied, answer])

    assert 0.0 <= score <= 1.0
    assert (tmp_path / "ans.txt").read_text(encoding="utf-8") == f"{score:.2f}"

4.3 覆盖率

coverage分支覆盖模式统计(branch = True),tests/tools/
不计入:

pytest --cov=. --cov-report=term-missing --cov-report=html
Name             Stmts   Miss Branch BrPart  Cover   Missing
------------------------------------------------------------
errors.py            8      0      0      0   100%
main.py             30      0      2      0   100%
simhash.py          38      0     12      0   100%
similarity.py       26      0      6      0   100%
text_reader.py      33      0     12      0   100%
tokenizer.py        34      0     10      0   100%
------------------------------------------------------------
TOTAL              169      0     42      0   100%

语句覆盖 100%、分支覆盖 100%。 截图如下(docs/htmlcov/index.html):

【在此插入覆盖率截图】打开 docs/htmlcov/index.html,截图首页的统计表。


五、计算模块部分异常处理说明

先说一个刻意的设计决策:空文件、纯空白、纯标点不抛异常,而是返回 0.00
因为"输入是空论文"在评测里是合法场景,抛异常会让整个测试点判为
"异常退出"直接扣分;而"两段文本没有共同内容"本来就该是 0。

在此基础上定义了 3 类异常,全部继承 PaperCheckError
入口处只需 except PaperCheckError 就能统一兜住:

异常 设计目标 触发场景 退出码
ArgumentError 参数用错时立刻给出用法提示,而不是让程序带着错误参数往下跑 参数个数 ≠ 3 1
FileReadError 把"路径错、是目录、编码全失败"等读入问题收敛成一句人话 文件不存在 / 是目录 / 路径为空 / 所有候选编码都解码失败 / 底层 OSError 1
FileWriteError 区分"读不到"和"写不出",便于定位是输入还是输出出了问题 输出目录不存在 / 输出路径为空 / 写入时 OSError 1

5.1 ArgumentError

目标:参数个数不对时,除了报错还要把正确用法打出来,减少"改一次试一次"的来回。

测试样例

@pytest.mark.parametrize("argv", [[], ["a.txt"], ["a.txt", "b.txt"], ["a", "b", "c", "d"]])
def test_parse_args_rejects_wrong_count(argv):
    """参数个数不是 3 个时抛 ArgumentError。"""
    with pytest.raises(ArgumentError):
        parse_args(argv)

对应场景:只传了原文和抄袭版,忘了传答案文件路径。

5.2 FileReadError

目标:把各种"读不进来"的原因统一包装,并且绝不把乱码当正文算进去。

测试样例

def test_read_text_missing_file_raises(tmp_path):
    """文件不存在 → FileReadError。"""
    with pytest.raises(FileReadError):
        read_text(str(tmp_path / "not-exist.txt"))


def test_read_text_directory_raises(tmp_path):
    """路径指向目录 → FileReadError。"""
    with pytest.raises(FileReadError):
        read_text(str(tmp_path))


def test_read_text_all_encodings_fail_raises(tmp_path):
    """候选编码全部失败时给出明确错误,而不是把乱码当正文。

    只用 ``utf-8`` 去读 GBK 字节,即可稳定复现"全部解码失败"的场景。
    """
    target = tmp_path / "gbk.txt"
    target.write_bytes("论文查重,天气晴朗。".encode("gbk"))
    with pytest.raises(FileReadError):
        read_text(str(target), encodings=("utf-8",))


def test_read_text_os_error_is_wrapped(tmp_path, monkeypatch):
    """底层 OSError(如共享冲突、权限不足)应被包装成 FileReadError。"""
    target = tmp_path / "locked.txt"
    target.write_text("内容", encoding="utf-8")
    real_open = open

    def fake_open(file, *args, **kwargs):
        if str(file) == str(target):
            raise PermissionError("文件被占用")
        return real_open(file, *args, **kwargs)

    monkeypatch.setattr("builtins.open", fake_open)

    with pytest.raises(FileReadError):
        read_text(str(target))

对应场景:① 路径打错;② 参数误传成文件夹;③ 同学用记事本把文件存成了
ANSI(GBK) 编码;④ 文件被别的程序占用。

顺带说下编码探测顺序:utf-8-sig → utf-8 → gb18030 → utf-16 → latin-1
utf-8-sig 放第一位是为了吃掉 BOM(否则 BOM 会混进正文第一个字);
gb18030 覆盖中文 Windows 的常见 ANSI 文件;latin-1 永不失败,作为最后兜底。

5.3 FileWriteError

目标:输出失败时明确指出"是写不出去",并给出是哪个目录不存在。

测试样例

def test_write_answer_missing_directory_raises(tmp_path):
    """输出目录不存在 → FileWriteError(而不是 FileNotFoundError)。"""
    with pytest.raises(FileWriteError):
        write_answer(str(tmp_path / "no-such-dir" / "ans.txt"), 0.5)


def test_write_answer_os_error_is_wrapped(tmp_path, monkeypatch):
    """写出时的 OSError 应被包装成 FileWriteError。"""
    target = tmp_path / "readonly.txt"
    real_open = open

    def fake_open(file, *args, **kwargs):
        if str(file) == str(target):
            raise PermissionError("只读")
        return real_open(file, *args, **kwargs)

    monkeypatch.setattr("builtins.open", fake_open)

    with pytest.raises(FileWriteError):
        write_answer(str(target), 0.5)

对应场景:答案文件想写到 C:\不存在的目录\ans.txt;或目标文件是只读的。

5.4 兜底:未预期异常也不能让进程崩

除了上面三类,main() 里还留了一层"未知错误"的兜底,退出码 2。
测试用 monkeypatch 人为让核心计算抛 RuntimeError,验证进程不会异常退出:

def test_main_never_crashes_on_unexpected_error(tmp_path, monkeypatch, capsys):
    """兜底分支:即使计算模块抛出了未预期的异常,也不能让进程崩溃。"""
    origin = _write(tmp_path / "orig.txt", ORIGIN)
    copied = _write(tmp_path / "copy.txt", COPY)

    def boom(self, origin_text, copied_text):
        raise RuntimeError("模拟内部错误")

    monkeypatch.setattr("similarity.PaperChecker.similarity", boom)

    code = main([origin, copied, str(tmp_path / "ans.txt")])

    assert code == 2
    assert "未知错误" in capsys.readouterr().err

六、PSP 表格(编码之后填写)

实际

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 30 25
· Estimate · 估计这个任务需要多少时间 30 25
Development 开发 540 555
· Analysis · 需求分析(包括学习新技术) 60 75
· Design Spec · 生成设计文档 40 35
· Design Review · 设计复审 20 15
· Coding Standard · 代码规范 20 15
· Design · 具体设计 60 45
· Coding · 具体编码 180 150
· Code Review · 代码复审 30 20
· Test · 测试(自我测试,修改代码,提交修改) 70 110
· Optimization · 性能剖析与优化 60 90
Reporting 报告 120 140
· Test Report · 测试报告 40 35
· Size Measurement · 计算工作量 20 15
· Postmortem & Process Improvement Plan · 事后总结,并提出过程改进计划 60 90
合计 690 720

数据说明:上表按实际开发过程记录。其中几项有可验证的硬数字兜底,不是凭感觉写的:
反复运行 pytestcoveragetools\benchmark.py 的总耗时(本次完整基准脚本运行约 155 秒)、
性能改进确实改了两轮方向、测试用例最终定稿 64 个、覆盖率迭代到分支 100%。
预估与实际相差 4.3%(690 → 720),说明我在"估计"这件事上还不算太离谱,
结构性偏差很明显——测试和性能优化两项都超了 50% 以上,见下。

2026-09-15 复验记录:本次重新运行 python main.py data\orig.txt data\orig_add.txt data\ans.txt,答案文件为 0.77;重新运行 python -m pytest,结果为 64 passed;重新运行覆盖率统计,语句覆盖率和分支覆盖率均为 100%;重新运行 python tools\benchmark.py,305 万字级别命令行墙钟耗时为 1.88s / 5.00s

差距最大的两项,以及为什么

① 测试:预估 70 分钟 → 实际 110 分钟(超了 57%)。
我以为"写够 10 个用例"就完事,结果真正花时间的是修那些红了的用例
其中一个用例是"打乱语序后得分应下降",跑出来是 0.95,
比只换词的 0.84 还高。我一开始以为是 bug,查了半小时才想明白
SimHash 是词袋模型、天生不看顺序,最后把这条断言改成了"显式记录该特性"。
这件事让我意识到:测试失败不一定是代码错了,也可能是测试的假设错了
而后者往往逼着你去真正搞懂算法。

② 性能优化:预估 60 分钟 → 实际 90 分钟(超了 50%),而且翻了两回车。
第一回是方向错:我上来就按"直觉"优化了 SimHash 内层循环和特征数量上限,
写完还挺得意;直到 cProfile 摆出来才发现,我优化的是总耗时里只占 5.8% 的部分,
真正的大头在分词和词频统计里。
第二回是测量方法错:我一开始用 tracemalloc 把计时和内存一起测,
结果"优化前"的耗时被插桩开销放大了 3 倍多,两次基准还能差出 10%,
差点让我得出"优化后反而更慢"的错误结论;后来改成耗时/内存分两趟测、
重复取最小值,数字才稳定下来。
教训是 "先测量的前提是测量方法本身可信"——第 3 章讲的"性能优化必须
建立在度量之上",这次是用两轮返工换来的。

代码量

wc -l 统计(含注释与空行):

类别 文件 行数
源码 main.py / similarity.py / simhash.py / tokenizer.py / text_reader.py / errors.py 504
单元测试 tests/ 下 5 个文件 + conftest.py 601
基准工具 tools/benchmark.py 499
合计 1604

七、自我复盘

  • 做得还行的地方:分层清楚、每层都能单独测,最后语句/分支都到了 100%;
    关键决策(用字符二元组还是 jieba、要不要截断特征)都是先做实验再定
    不是拍脑袋。
  • 做得不好的地方:性能优化方向一开始完全错了;测试的边界情况
    也想得不够全(比如"整句重排"这种情况是跑出来才发现的)。
  • 已知局限
    1. SimHash 是词袋模型,对整句调换顺序不敏感——把论文段落顺序打乱
      后重新组织,可能被判为高度重复;
    2. 汉明相似度对无关文本有约 0.5 的"随机地板"(两个随机的 64 位指纹
      平均也有 32 位不同),所以程序输出的是"相对相似度"而不是
      "字面重复百分比";
    3. 超长文本的 findall 仍是热点,进一步提速需要引入第三方数值库,
      与"零依赖开箱即跑"的目标冲突。
  • 下一步可以做:把 SimHash 的汉明距离跟"n-gram 集合的重合度"
    结合起来做二次判定,压掉那个 0.5 的地板;再引入倒排索引支持
    "一次原文 vs 多篇抄袭版"的批量查重。
posted @ 2026-09-15 22:48  maccin  阅读(6)  评论(0)    收藏  举报