第一次个人编程作业 —— 论文查重

项目 内容
这个作业属于哪个课程 计科24级56班
这个作业要求在哪里 第一次个人编程作业
这个作业的目标 完成一个论文查重程序,实践需求分析、设计、编码、测试、性能分析与代码质量检查等软件开发流程
学号 3124004170
GitHub 仓库 https://github.com/ZSylph/plagiarism

代码位于仓库根目录下的 3124004170/ 文件夹中。


一、PSP 表格

在开始编码前,我对项目各阶段所需时间进行了预估;项目完成后,再根据实际开发过程补充实际耗时。记录如下:

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 15 12
· Estimate · 估计这个任务需要多少时间 15 12
Development 开发 605 720
· Analysis · 需求分析(包括学习新技术) 60 75
· Design Spec · 生成设计文档 45 40
· Design Review · 设计复审 20 15
· Coding Standard · 制定代码规范 15 10
· Design · 具体设计 60 70
· Coding · 具体编码 240 300
· Code Review · 代码复审 45 60
· Test · 测试、修改代码并提交 120 150
Reporting 报告 80 87
· Test Report · 测试报告 40 45
· Size Measurement · 计算工作量 15 12
· Postmortem & Process Improvement Plan · 事后总结并提出改进计划 25 30
合计 700 819

从结果来看,实际总耗时比预估多 119 分钟(+17%)。偏差最大的三项指向同一个原因:我习惯按"主流程跑通"来估工作量,而低估了健壮性与测试这两块"看不见的工作"

阶段 偏差 原因
Analysis 需求分析 +25% 题目不指定相似度算法,需要横向比较余弦 / SimHash / 编辑距离 / 压缩距离各自的适用场景与代价,实际调研时间超出预期
Design 具体设计 +17% 原本只打算做单一指标,设计复审时意识到"片段抄袭"场景必须引入非对称的覆盖率指标,设计改了第二轮
Coding 具体编码 +25% 低估了中文文本预处理的琐碎程度(全角字符、标点切段、GBK/BOM 编码),这些"边角料"占了大量时间
Test 测试 +25% 异常分支比预想的多;为把覆盖率从 91% 推到 98%,又补写了一批边界用例与真实子进程用例
Design Spec 生成设计文档 −11% 模块划分在 Design 阶段已经想清楚,写文档时主要是整理,比预想顺利
Design Review 设计复审 −25% 评审时当场发现单指标方案有盲区,这次 15 分钟省下了后面可能重写半小时的返工

今后进行类似任务时,我会把"异常处理 + 测试补全"按主流程耗时的 50% 单独列出来估,而不是并进 Coding 里。


二、项目需求分析

本项目需要实现一个能够计算两篇文本重复率的命令行程序。程序接收三个命令行参数:

  1. 论文原文文件的绝对路径;
  2. 抄袭版论文文件的绝对路径;
  3. 答案文件的绝对路径。

程序读取两个文本文件,计算它们的相似度,并将结果以保留两位小数的形式写入答案文件。例如:

python main.py "D:\test\orig.txt" "D:\test\copy.txt" "D:\test\ans.txt"

若计算结果为 0.614918...,则答案文件中写入:

0.61

除基本功能外,程序还需要考虑参数数量错误、输入文件不存在、文件编码不同、文本为空以及答案文件无法写入等情况。

把题目读下来,我提取出这些硬约束(写代码时当成不可违背的契约):

题目约束 我的应对
三个参数都是绝对路径 只用命令行参数定位文件,不猜任何默认路径
答案文件是浮点型,精确到小数点后两位 输出 0.87 这样的比例值,不带换行、不带其他字符
5 秒内必须给出答案 做性能分析,把 20 万字语料压到 0.3 秒以内
内存不超过 2048 MB 设文件体积上限,宁可明确报错也不让内存失控
程序不得异常退出 兜底路径保证"答案文件一定会被产出"
不得连接网络、不得读写其他文件 全程零第三方运行依赖,只读写参数指定的三个文件

题目没有规定用哪种相似度算法,这反而是本次作业最难的部分 —— 需要自己权衡。


三、计算模块接口的设计与实现

3.1 项目结构

项目采用模块化结构,核心程序、测试代码、分析工具相互分离:

3124004170/
├── main.py                  命令行入口
├── pyproject.toml           Ruff 静态检查配置(代码规范的唯一权威来源)
├── requirements.txt         运行依赖(仅可选的 jieba)
├── requirements-dev.txt     测试/分析/质量检查用依赖
├── README.md
├── src/
│   ├── __init__.py
│   ├── exceptions.py        自定义异常体系
│   ├── text_utils.py        文本规范化与分词
│   ├── similarity.py        六种相似度度量
│   ├── detector.py          特征融合策略
│   └── io_utils.py          文件读写与编码探测
├── tests/
│   ├── conftest.py
│   ├── test_text_utils.py
│   ├── test_similarity.py
│   ├── test_detector.py
│   ├── test_io_utils.py
│   └── test_main_cli.py
├── data/                    样例语料(题目示例 + 自建的 5 种抄袭形态)
├── tools/
│   ├── run_coverage.py      覆盖率统计
│   ├── profile_detector.py  cProfile 性能分析
│   └── make_charts.py       由数据自动生成图表
└── docs/
    ├── blog.md              本博客
    ├── psp.md               PSP 表格
    ├── coverage.png         覆盖率报告图
    ├── profile.png          优化前后总耗时对比图
    ├── profile_before.png   优化前的热点函数图
    └── profile_after.png    优化后的热点函数图

程序运行部分只使用 Python 标准库reunicodedatacollectionsdifflibhashlibzlibmathargparse),评测环境不需要安装任何第三方运行依赖。pytest / coverage / matplotlib / ruff 只用于开发阶段的测试、覆盖率统计与代码质量检查。

3.2 模块接口

函数 功能
io_utils.read_text(path, role) 读取文本文件,自动探测编码(UTF-8 / BOM / GBK / Big5 / UTF-16)
io_utils.decode_bytes(data) 字节流解码,先查 BOM 再依次尝试候选编码
io_utils.write_score(path, score) 把重复率以两位小数写入答案文件
text_utils.normalize_segments(text) NFKC 归一 + 转小写 + 按标点切段
text_utils.char_ngrams(segments, n) 滑动窗口切分字符 n-gram(不跨段、短片段兜底)
text_utils.tokenize(segments, use_jieba) 分词;无 jieba 时退化为字符二元组
similarity.counter_cosine(a, b) 根据两个词频向量计算余弦相似度
similarity.containment(a, b) 覆盖率 |A∩B| / |B|,识别整段照抄
similarity.simhash(tokens) / simhash_similarity(a, b) 局部敏感哈希指纹与汉明相似度(含基线校正)
similarity.sequence_similarity(a, b) 序列比对相似度,对语序敏感
detector.compute_similarity(a, b) 组织特征抽取与加权融合流程,对外唯一接口
detector.format_score(score) 按题目要求格式化浮点数
main.main(argv) 解析命令行参数、串联各模块、统一错误兜底

模块之间的调用关系如下:

main()
 ├── io_utils.read_text() × 2
 │    └── io_utils.decode_bytes()
 ├── detector.compute_similarity()
 │    ├── text_utils.normalize_segments() × 2
 │    ├── text_utils.char_ngrams() / tokenize()
 │    ├── similarity.counter_cosine() × 2~3
 │    ├── similarity.simhash() × 2 → simhash_similarity()
 │    ├── similarity.sequence_similarity()
 │    └── similarity.containment()
 └── io_utils.write_score()
graph TD A["main.py<br/>命令行入口"] -->|读取文本| B["io_utils.py<br/>编码探测 / 读写"] A -->|调用| C["detector.py<br/>compute_similarity()"] C -->|文本规范化 / n-gram| D["text_utils.py"] C -->|六种度量| E["similarity.py"] D -.->|可选| F["jieba<br/>有则用,无则自动降级"] B -->|出错| G["exceptions.py"] C -->|出错| G A -->|统一兜底| G

为什么这样切? 假设我想把"余弦相似度"换成"编辑距离",只需要动 similarity.py;想换一种融合权重,只需要动 detector.py 顶部的一张权重表;想支持新的编码,只需要往 io_utils.CANDIDATE_ENCODINGS 里加一项。三件事互不干扰。

整个项目只有一个类 SimilarityReport(一个冻结的数据类),其余全是函数。这是刻意的选择:

  • 查重本身是一个无状态的纯计算过程,没有需要维护的实例状态,硬套"一个类对应一个概念"反而会把 compute_similarity() 拆得支离破碎;
  • 纯函数好测 —— 想验证余弦相似度,直接调 counter_cosine(),不需要构造对象、不需要 mock 任何东西。

SimilarityReport 存在的唯一理由是:一次计算要返回的东西不止一个数。除了最终重复率,还有每个特征的分项得分、生效权重、以及被跳过的特征及原因。用 @dataclass(frozen=True) 冻结它,是为了防止调用方在拿到报告后偷偷改分数。

3.3 核心算法

直白地说,任何单一的相似度指标都有明显盲区:字符重叠率对同义改写不敏感,SimHash 分辨率有限,编辑距离算不快,集合类指标看不见语序。所以本项目采用 多特征加权融合,六个特征各自负责不同的"抄袭形态"。

第一步:文本规范化

_SEGMENT_SPLITTER = re.compile(r"[^\u4e00-\u9fff\u3400-\u4dbfA-Za-z0-9]+")

def normalize_segments(text: str) -> List[str]:
    if not text:
        return []
    normalized = unicodedata.normalize("NFKC", text).lower()
    return [segment for segment in _SEGMENT_SPLITTER.split(normalized) if segment]

这里有一个我在设计阶段踩过的坑:最直觉的做法是把标点全部删掉,但那样会凭空造出原文中并不存在的组合 —— "天气晴,今天晚上" 删掉标点后切片会产生 "晴今天" 这个二元组,当两份完全无关的文档恰好一前一后出现相同字词时,这种假 n-gram 会制造出虚假的"重复"。

正确做法是把标点当成段边界,n-gram 只在段内滑动。NFKC 归一顺带解决了全角字符问题(ABCabc),.lower() 则避免英文大小写造成假差异。

第二步:特征抽取

def char_ngrams(segments: Sequence[str], n: int) -> List[str]:
    if n < 1:
        raise ValueError(f"n-gram 长度必须是正整数,收到 {n!r}")
    grams: List[str] = []
    extend = grams.extend
    for segment in segments:
        length = len(segment)
        if length < n:
            extend((segment,))          # 短片段整段保留,而不是丢弃
            continue
        extend([segment[start : start + n] for start in range(length - n + 1)])
    return grams

那个兜底分支容易被忽略:如果被标点切出来的片段只有 1 个字(比如 "的"),而我们要取的是三元组,那么 range(1 - 3 + 1) 是个空区间 —— 这个字就彻底消失了。短文本场景下(比如题目给的示例只有二三十个字)这种信息丢失会明显拉低重复率,所以必须兜底。

第三步:六种相似度度量

度量 数学形式 擅长识别 盲区
字符二元组词频余弦 $\dfrac{\sum_t a_t b_t}{|a||b|}$ 字面雷同 长文档上被高频通用字拉高基线
字符三元组词频余弦 同上,n=3 局部改动(比二元组严格) 同义改写
词语级余弦 同上,以词为特征 语义相近的改写 依赖分词质量
SimHash + 汉明距离 $1 - d_H/\text{bits}$(含基线校正) 抗噪声的粗粒度判据 分辨率有限
序列比对 difflib.ratio() 语序调整 O(n·m),长文本算不动
覆盖率 containment $\dfrac{|A \cap B|}{|B|}$ 整段照抄 + 少量新增 不对称,单独用会过判

余弦相似度的核心实现(只遍历较短的那个向量):

def counter_cosine(counter_a, counter_b, norm_a=None, norm_b=None):
    if not counter_a or not counter_b:
        return 0.0
    # 让 a 是元素更少的那个,把字典查找次数压到最小
    if len(counter_a) > len(counter_b):
        counter_a, counter_b = counter_b, counter_a
        norm_a, norm_b = norm_b, norm_a

    dot = 0.0
    for token, weight in counter_a.items():
        other = counter_b.get(token)
        if other is not None:
            dot += weight * other
    if dot == 0.0:
        return 0.0
    ...
    return min(1.0, max(0.0, dot / (norm_a * norm_b)))

朴素写法对两个特征列表做双重循环是 $O(|A| \cdot |B|)$;因为只有两边都出现的特征才对点积有贡献,改成"遍历较短的那个 + 字典 $O(1)$ 查找"就降到了 $O(\min(|A|,|B|))$。

第四步:加权融合

DEFAULT_WEIGHTS = {
    "char_bigram_cosine": 0.28,   # 主力特征,抓字面雷同
    "char_trigram_cosine": 0.18,  # 对局部改动更严格
    "word_cosine": 0.12,          # 仅在 jieba 可用时参与
    "simhash": 0.15,              # 抗噪声的粗粒度判据
    "sequence": 0.13,             # 对语序调整敏感
    "coverage": 0.14,             # 识别"整段照抄 + 少量新增"
}

word_cosine 只在真正走 jieba 分词路径时才生效。没有 jieba 时,"词"会退化成字符二元组,与主力特征完全重复 —— 与其重复计权,不如把它剔除,让权重分给真正独立的特征:

def _renormalize(weights, available) -> Dict[str, float]:
    kept = {name: weights[name] for name in available if name in weights}
    total = sum(kept.values())
    if total <= 0.0:
        return {}
    return {name: value / total for name, value in kept.items()}

这个 8 行的函数是"零依赖也能跑"的关键:无论环境里有没有 jieba,最终分数都落在 [0, 1] 内且量纲一致。实测两条路径对同一对文本给出的分数是 0.5318(零依赖)与 0.5416(jieba),差异在 0.01 以内。

本算法独到的地方

① 覆盖率指标刻意做成不对称的。 Jaccard 系数是对称的 $|A \cap B| / |A \cup B|$,当抄袭版只截取原文一小段时分母被原文长度撑大,会严重低估。而覆盖率 $|A \cap B| / |B|$ 问的是"抄袭版中有多大比例能在原文里找到",这才符合"整段照抄"的直觉。实测:抄袭版取原文前 40 个字时 coverage = 1.00,是极强的判别信号。

② SimHash 做了随机基线校正。 64 位随机指纹的期望汉明距离恰好是 32(每一位独立且各有一半概率不同),直接用 $1 - d/\text{bits}$ 会让毫不相关的两份文档也拿到 0.5 分。实测一段讲"番茄炒蛋"的文字与一段讲"文本相似度"的论文,裸相似度是 0.484。校正后无关语料得分从 0.09 降到 0.01,整个分数区间被重新拉开。

raw = 1.0 - hamming_distance(fingerprint_a, fingerprint_b) / bits
if not correct_baseline:
    return raw
return min(1.0, max(0.0, (raw - 0.5) / 0.5))   # 把 0.5 映射到 0

③ SimHash 权重随文本规模自适应缩放。 SimHash 是统计型指纹,需要足够特征量,"某一位由内容而非哈希噪声决定"的概率才够高。实测一段 74 字的文本插入 3 个字,两个指纹就差了 24 位(裸相似度从 1.0 掉到 0.625),而同样改动在 440 字文本上只差个位数。所以短文本时按比例削弱它的权重:

_SIMHASH_FULL_WEIGHT_GRAMS = 400   # 按去重后的三元组数量计
simhash_scale = min(1.0, min(len(trigram_counter_a), len(trigram_counter_b)) / _SIMHASH_FULL_WEIGHT_GRAMS)

加上这一条之后,"轻量编辑后重复率仍应 > 0.9"这条性质才真正成立(修复前该用例只有 0.897)。

④ 编码探测不使用 latin-1 兜底。 latin-1 能解码任意字节序列,很多实现把它放在候选列表最后当"万能兜底"。代价是真正的编码错误被伪装成乱码继续往下走,最后算出一个看似正常却毫无意义的分数。我刻意不用它,让解码失败能被如实报成 TextDecodeError


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

4.1 性能分析方法

我先用 Python 自带的 cProfile 生成性能数据,再用 pstats 对调用关系和各函数耗时排序。性能分析使用脚本合成的大规模语料,以便让程序热点更加明显。

python tools/profile_detector.py --size 200000 --label before
python tools/profile_detector.py --size 200000 --label after

脚本合成了两份约 20 万字符的中文语料(抄袭版 = 原文前 70% + 30% 新内容)。

合成语料时为每个片段附加了递增编号,目的是保证字符 n-gram 不会重复 —— 如果直接把同一句话复制几千遍,特征集合会小得可怜,测出来的耗时不能代表真实语料下的表现。

4.2 优化前发现的热点

优化前总耗时:333.0 ms
    326.16 ms(自身  11.74 ms,调用      1 次)compute_similarity  @ detector.py:111
    186.83 ms(自身 123.31 ms,调用      4 次)char_ngrams        @ text_utils.py:58
     74.37 ms(自身  47.46 ms,调用      2 次)simhash            @ similarity.py:154
     58.53 ms(自身  58.53 ms,调用 738948 次)<method 'append' of 'list' objects>
     54.21 ms(自身  54.21 ms,调用      6 次)_collections._count_elements

问题一目了然:char_ngrams() 一个人吃掉了 37% 的累计耗时,它为 20 万字的输入创建了 738948 个临时字符串对象,全部靠 Python 层的 append 循环堆出来。

优化前的性能分析图

优化前的性能分析图

4.3 优化方法

针对上述热点,我做了三轮优化。

第一轮:用列表推导替代显式 append 循环。

# 优化前
grams = []
append = grams.append
for segment in segments:
    for start in range(len(segment) - n + 1):
        append(segment[start : start + n])
return grams

# 优化后
grams: List[str] = []
extend = grams.extend
for segment in segments:
    length = len(segment)
    if length < n:
        extend((segment,))
        continue
    extend([segment[start : start + n] for start in range(length - n + 1)])
return grams

LIST_APPEND 是专门为列表推导优化的字节码,省掉了每轮一次的方法查找与函数调用。char_ngrams 的自身耗时从 123 ms 降到 33 ms。

第二轮:消除重复的特征统计。 优化前同一段文本被反复处理:二元组构建了一次列表再喂给 Counter,三元组构建了一次列表、又建了一次 Counter、之后还要 set(trigrams) 再数一遍。

# 二元组只需要词频 → 用生成器直喂 Counter,省掉与原文等长的中间列表
bigrams_a = similarity.build_counter(text_utils.iter_char_ngrams(segments_a, 2))

# 三元组既要词频(余弦)又要集合(覆盖率、SimHash)→ 只建一次 Counter 再复用
trigram_counter_a = similarity.build_counter(text_utils.char_ngrams(segments_a, 3))
trigram_set_a = set(trigram_counter_a)
  • 新增 iter_char_ngrams() 生成器版本,二元组不再需要中间列表;
  • set(Counter) 只需对去重后的 20 万个对象算哈希,而 set(列表) 要对 40 万个对象算哈希 —— 直接省掉一半;
  • simhash() 现在直接接受 Counter,省掉第三次重复计数。

第三轮:jieba 探测从"真导入"改成"看有没有"。 这是一个不影响单次计算、但影响每次进程启动的瓶颈:

find_spec('jieba') :     0.21 ms
import jieba       :   239.09 ms

差了 1100 倍。而且导入 jieba 会连带触发它依赖的 pkg_resources 打印弃用警告,污染 stderr。改成先判断"明确关闭"、再用 importlib.util.find_spec() 轻量探测,只有真的要分词时才真正导入。端到端实测:python main.py 的墙钟时间从约 450 ms 降到 250 ms

4.4 优化结果

优化后总耗时:237.6 ms      (优化前 333.0 ms,提速约 29%)
     237.63 ms(自身  4.59 ms,调用      1 次)compute_similarity
     121.02 ms(自身   0.03 ms,调用      4 次)build_counter
      58.32 ms(自身  46.18 ms,调用      2 次)simhash
      49.73 ms(自身  48.49 ms,调用 379374 次)iter_char_ngrams
      39.95 ms(自身  33.10 ms,调用      2 次)char_ngrams

优化后的性能分析图

优化后的性能分析图

指标 优化前 优化后 变化
总运行时间(20 万字) 333.0 ms 237.6 ms 下降约 28.7%
char_ngrams() 自身耗时 123.31 ms 33.10 ms 下降约 73.2%
list.append 调用次数 738948 0(改用 extend
jieba 探测开销 239.09 ms 0.21 ms 下降约 99.9%

我程序中消耗最大的函数build_counter / _collections._count_elements(121 ms,占 51%)。它已经是 CPython 的 C 实现,继续优化的空间在于减少需要统计的 gram 数量,而这会牺牲准确度,因此我没有再动它 —— 这是有意的取舍。第二名是 simhash(58 ms),已通过把参与指纹计算的 token 数限制在词频最高的 8000 个来约束成本。

优化前后生成的答案文件内容完全一致,可以说明这些优化没有改变程序正确性。此外,针对每一处优化都补了等价性回归测试

def test_iter_matches_list_version(self):
    """生成器版本与列表版本必须给出完全一致的序列(性能优化不能改变语义)。"""
    segments = text_utils.normalize_segments("今天是星期天,天气晴。")
    for n in (1, 2, 3, 4):
        assert list(text_utils.iter_char_ngrams(segments, n)) == text_utils.char_ngrams(segments, n)

4.5 规模扫描

语料规模(字符) 耗时(毫秒) 距离 5 秒上限
5,271 53.2 1.1%
21,026 42.7 0.9%
52,415 74.8 1.5%
104,795 109.0 2.2%
209,477 177.4 3.5%
522,936 366.9 7.3%

结论:即使输入是 50 万字符的超长论文,也只用了 0.37 秒,距离 5 秒上限还有一个数量级的余量。这次分析让我认识到,性能优化不能只依靠主观判断,而应先通过分析工具定位热点,再进行有针对性的修改,并验证优化前后结果是否一致。


五、计算模块部分的单元测试

5.1 测试方法

本项目使用 pytest 编写单元测试,并使用 coverage 统计语句覆盖率与分支覆盖率。测试命令如下:

python -m pytest -v
python tools/run_coverage.py      # 等价于 coverage run --branch --source=src,main -m pytest

测试按模块一一对应,共设计了 105 个测试用例,覆盖正常情况、边界情况和异常情况:

测试文件 测试对象 测试内容
test_text_utils.py normalize_segments / char_ngrams / tokenize 标点切段、全角转半角、大小写归一、n-gram 不跨段、短片段兜底、非法 n、分词的两条路径
test_similarity.py 六种度量 相同/无交集/空向量的余弦、Jaccard、覆盖率的不对称性、SimHash 基线校正、序列比对的语序敏感性与超长跳过、NCD
test_detector.py compute_similarity 自比得满分、无关语料低分、轻改高不改低的单调性、权重重归一化、空文档异常、自定义权重
test_io_utils.py 读写与编码 UTF-8 / BOM / GBK / UTF-16 探测、目录路径、超限文件、读写失败、答案格式
test_main_cli.py 端到端集成 正常流程、GBK 输入、参数不足、文件缺失、空文档、真实子进程入口

测试设计以白盒为主:余弦相似度、Jaccard、覆盖率等纯函数都写死了手工推导的期望值。举两个例子:

def test_hand_computed_overlap(self):
    """手工推导的中间值。

    a = {ab: 2, bc: 1},b = {ab: 1, bc: 2}:
    * 点积 2×1 + 1×2 = 4
    * 两个模长均为 √(2² + 1²) = √5
    * 余弦 = 4 / 5 = 0.8

    这个用例同时验证了"重复次数确实参与计算"——如果实现里误用了集合,
    结果会变成 1.0,测试立刻失败。
    """
    result = similarity.counter_cosine(Counter({"ab": 2, "bc": 1}), Counter({"ab": 1, "bc": 2}))
    assert result == pytest.approx(0.8)
def test_containment_is_asymmetric(self):
    """覆盖率**刻意**不对称,这正是它能识别"整段照抄"的原因。"""
    a = {"a", "b", "c", "d"}
    b = {"a"}
    assert similarity.containment(a, b) == pytest.approx(1.0)
    assert similarity.containment(b, a) == pytest.approx(0.25)

另外,为了不让测试随权重调整而变脆,算法层面的断言多用单调性而非绝对值:

def test_heavy_rewrite_scores_between(self):
    """同义改写幅度很大时,分数应当明显低于轻度改动、又高于无关文本。"""
    heavy = compute_similarity(ORIGIN, rewritten, use_jieba=False)
    light = compute_similarity(ORIGIN, ORIGIN, use_jieba=False)
    unrelated = compute_similarity(ORIGIN, "今天中午我打算去食堂吃饭然后回宿舍睡觉。", use_jieba=False)
    assert unrelated.score < heavy.score < light.score

5.2 覆盖率结果

Name                Stmts   Miss Branch BrPart  Cover   Missing
---------------------------------------------------------------
main.py                48      4     10      1    91%   37, 124-126
src\__init__.py         1      0      0      0   100%
src\detector.py        71      0     16      0   100%
src\exceptions.py      13      0      0      0   100%
src\io_utils.py        57      0     20      0   100%
src\similarity.py      92      0     48      0   100%
src\text_utils.py      73      4     28      0    96%   124-125, 141-142
---------------------------------------------------------------
TOTAL                 355      8    122      1    98%

总体覆盖率达到 98%,分支覆盖 121/122。核心计算模块(detector / similarity / io_utils / exceptions)均达到 100% 语句覆盖与 100% 分支覆盖。

Coverage 总体覆盖率报告

覆盖率报告

未覆盖的几行集中在两处:

  • main.py:37 —— sys.path 去重分支,只有 python -m main 这种少见调用方式才会走到;
  • text_utils.py:124-125, 141-142 —— importlib.find_spec()import jieba 抛出异常时的防御分支,需要人为破坏安装环境才能触发。

入口保护语句 if __name__ == "__main__" 已通过真实子进程用例覆盖,无需 # pragma: no cover 标记。

达到 98% 覆盖率并不意味着程序一定不存在缺陷,但它能够说明当前代码中的语句和主要分支均被测试执行。相比只检查一个正常样例,本项目还专门测试了空文本、缺失文件、GBK/BOM 编码、非法输出路径和错误参数数量等场景,从而提高了测试的有效性。

5.3 对测试设计的自我评价

能满足要求吗? 对于本项目的规模,我认为基本满足,但仍有明确的不足:

  1. 缺少真实语料上的标注数据。 现有语料的"期望重复率"是我自己判断的,没有人工标注的 ground truth,因此无法计算准确率/召回率这类指标,只能验证单调性和方向性。
  2. 没有做性质测试(property-based testing)。 例如"交换两个参数结果不变"这类性质目前只挑了几个代表点验证,用 hypothesis 做随机化测试会更有说服力。
  3. 极端规模的用例偏少。 我测到了 50 万字符,但没有测试 64 MB(代码里的体积上限)附近的行为,那里可能存在内存峰值问题。

第 1 点是最值得补的 —— 它决定了我只能证明"算法自洽",而不能证明"算法准确"。


六、异常处理说明

本项目把"预期内的错误"和"预期外的缺陷"分开处理,定义了一棵以 PlagiarismError 为根的异常树,共 7 个子类。每个异常都有明确的触发场景和对应的单元测试。

graph TD P["PlagiarismError<br/>基类:统一兜底"] --> E1["InvalidArgumentError<br/>参数不合法"] P --> E2["InputFileNotFoundError<br/>文件不存在 / 不可读"] P --> E3["InputPathNotFileError<br/>路径不是普通文件"] P --> E4["EmptyDocumentError<br/>文档为空"] P --> E5["TextDecodeError<br/>编码无法识别"] P --> E6["OutputFileError<br/>答案文件不可写"] P --> E7["ResourceLimitError<br/>超出体积上限"]

6.1 命令行参数不合法(InvalidArgumentError)

设计目标:区分"参数压根没给对"和"参数给了但文件出问题"。如果不拦,Path("") 会指向当前工作目录,报出"文件不存在:."这种会把人带偏的错误。

触发场景:路径参数是空字符串或纯空白。

def test_empty_path_raises(self):
    """空字符串路径要给出明确错误,而不是让 Path("") 指向当前目录。"""
    with pytest.raises(InvalidArgumentError):
        io_utils.read_text("   ")

6.2 输入文件不存在或无法读取(InputFileNotFoundError)

设计目标:把"文件不在那儿"和"文件在那儿但读不动"合并成同一类用户可见错误,因为它们对用户的处理动作是一样的(去检查路径)。

触发场景:路径不存在,或文件存在但被占用、权限不足。

def test_missing_file_raises(self, tmp_path):
    with pytest.raises(InputFileNotFoundError):
        io_utils.read_text(str(tmp_path / "not-exist.txt"))

def test_read_error_raises(self, write_file, monkeypatch):
    """底层读取失败(权限不足 / 文件被占用)要转成自定义异常而不是裸 OSError。"""
    path = write_file("a.txt", SAMPLE)

    def boom(self):                       # 模拟 Path.read_bytes 的签名
        raise OSError("device not ready")

    monkeypatch.setattr(Path, "read_bytes", boom)
    with pytest.raises(InputFileNotFoundError):
        io_utils.read_text(path)

6.3 路径不是普通文件(InputPathNotFileError)

设计目标:把目录、设备文件这类"存在但不可当文本读"的路径单独拎出来。否则 read_bytes() 会抛出 IsADirectoryError(一个对用户毫无意义的 OSError 子类)。

def test_directory_raises(self, tmp_path):
    """传进来一个目录时必须报错,否则读文件会抛出难懂的 OSError。"""
    with pytest.raises(InputPathNotFileError):
        io_utils.read_text(str(tmp_path))

6.4 空文本与零向量(EmptyDocumentError)

设计目标:空文档(或整篇只有标点)没有特征可比,硬算会得到毫无意义的 0 分,或者触发除零。宁可明确报错,也不输出一个假的重复率。

def test_punctuation_only_raises(self):
    """整篇都是标点等同于空文档,必须报错而不是算出一个 0 分。"""
    with pytest.raises(EmptyDocumentError):
        compute_similarity(",。!?——", ORIGIN)

6.5 文件编码不一致(TextDecodeError)

设计目标:程序优先按 UTF-8 读取,失败时依次尝试 GB18030(GBK 的超集)与 Big5,并优先识别 BOM 以正确解析 UTF-16。绝不猜测 —— 如果所有候选编码都解不开,就如实报错。这里刻意不使用 latin-1,因为它能解码任意字节,会把错误伪装成乱码继续往下走。

def test_undecodable_raises(self):
    """所有候选编码都失败时必须报 TextDecodeError,而不是给出乱码。"""
    with pytest.raises(TextDecodeError):
        io_utils.decode_bytes(b"\xff\xff\xfe\xff\xff")

6.6 答案文件无法写入(OutputFileError)

设计目标:答案文件是程序的唯一交付物,它的失败必须被单独、响亮地报告,而不是混在通用 OSError 里。

触发场景:目标目录不存在、目标路径是个目录、磁盘写满。

def test_missing_directory_raises(self, tmp_path):
    """目标目录不存在时报 OutputFileError,提示要足够明确。"""
    with pytest.raises(OutputFileError):
        io_utils.write_score(str(tmp_path / "no-such-dir" / "ans.txt"), 0.5)

def test_write_error_raises(self, tmp_path, monkeypatch):
    """底层写入失败(磁盘满 / 无权限)要转成 OutputFileError。"""

    def boom(*args, **kwargs):            # 模拟内置 open
        raise OSError("no space left on device")

    monkeypatch.setattr("builtins.open", boom)
    with pytest.raises(OutputFileError):
        io_utils.write_score(str(tmp_path / "ans.txt"), 0.5)

6.7 超出文件体积上限(ResourceLimitError)

设计目标:直接服务于"占用内存不超过 2048 MB"这条评测约束。与其让程序在读取一个几百 MB 的日志文件时内存暴涨、被评测方判为超限,不如提前明确拒绝。

触发场景:单个输入文件超过 MAX_FILE_BYTES(64 MB)。

def test_oversize_file_raises(self, write_file, monkeypatch):
    """超过体积上限时抛 ResourceLimitError(用 monkeypatch 缩小上限来模拟)。"""
    path = write_file("big.txt", "x" * 100)
    monkeypatch.setattr(io_utils, "MAX_FILE_BYTES", 10)
    with pytest.raises(ResourceLimitError):
        io_utils.read_text(path)

6.8 兜底:为什么"出错了还要写答案文件"

main.py 的最外层捕获所有异常,打印中文提示,然后尽力写出兜底答案 0.00

except PlagiarismError as exc:
    _report_failure(str(exc), args.answer)   # 打印 + safe_write_score(0.00)
    return 1
except Exception as exc:                      # 有意捕获全部异常:这是进程的最后一道防线
    _report_failure(f"未预期的内部错误:{exc!r}", args.answer)
    return 1

这个设计是有意的取舍:"答案文件存在"是比"答案正确"优先级更高的契约。评测脚本会直接读取答案文件并做浮点解析,文件缺失会导致整个测试点失败;而写一个格式合法的 0.00 至少能让流程继续,具体错误信息则通过 stderr 完整暴露给使用者。归根到底,程序要"优雅地失败",而不是"静默地失败"。


七、代码质量分析

我使用 Ruff 对项目进行静态代码检查。Ruff 能够发现未使用的导入、不规范的代码格式、潜在缺陷以及与 Python 代码规范不符的问题。

检查命令:

python -m ruff check .
python -m ruff check . --fix        # 自动修复可修复的问题

7.1 代码规范

我把规则集固化在仓库根目录的 pyproject.toml 里,让"代码规范"从口头约定变成可执行的配置:

[tool.ruff]
target-version = "py38"
line-length = 120

[tool.ruff.lint]
select = ["E", "W", "F", "I", "B", "C4", "SIM"]
ignore = [
    "E501",   # 行长度交由人工把握:中文注释按字符数硬性折行反而破坏可读性
    "B905",   # zip(strict=...) 需要 Python 3.10+,与本项目声明的 3.8 底线冲突
]

在 Ruff 默认规则集(E4 / E7 / E9 / F)之上,我额外启用了 I(导入顺序)、B(flake8-bugbear 缺陷模式)、C4(推导式写法)、SIM(可简化代码)四组规则。

7.2 检查与修复结果

第一次检查共报出 3 个 F401(未使用的导入)main.pytyping.Listsrc/detector.pytyping.Tupletools/make_charts.pysys。这是真的疏漏 —— 我在重构时删掉了对应的使用点,却忘了同步清理导入。

启用扩展规则集后又暴露出 3 个问题:

规则 位置 问题 处理
C420 ×2 tests/test_detector.py 多余的字典推导式 {name: 0.0 for name in X} 改为 dict.fromkeys(X, 0.0)
I001 tests/test_detector.py 导入顺序不符合规范 --fix 重排
B017 tests/test_detector.py pytest.raises(Exception) 断言过宽 手动收紧为 FrozenInstanceError

其中 B017 是一个真实的测试缺陷,值得单独说明:

# 修改前:只要抛出任何异常就算通过
with pytest.raises(Exception):
    report.score = 0.9

# 修改后:必须是对冻结数据类的写入异常
with pytest.raises(FrozenInstanceError):
    report.score = 0.9  # type: ignore[misc]

原来的写法让这个用例几乎失去意义 —— 如果哪天 frozen=True 被误删,report.score = 0.9 会成功执行,而 pytest.raises(Exception) 反而会因为"没有抛出任何异常"而失败……但换成别的实现错误(比如抛出 AttributeError)时它又会错误地通过。收紧成具体类型之后,这个用例才真正在守护"报告不可变"这条设计约束。

同时我也清理了几处无效的 noqa 注释# noqa: ANN001 等):它们对应的规则并没有被启用,留着只会让人误以为代码里存在需要豁免的问题。

最终检查结果:

$ python -m ruff check .
All checks passed!

零警告。相关修改被单独记录在 Git 提交中,使代码质量检查过程可追踪。

通过静态检查可以在程序运行前发现一部分代码问题,但 Ruff 不能代替单元测试。因此本项目同时使用 Ruff 检查代码规范、pytest 验证功能、coverage 检查测试覆盖范围,并使用 cProfilematplotlib 分析运行性能。


八、运行结果

8.1 题目示例

原文:

今天是星期天,天气晴,今天晚上我要去看电影。

抄袭版:

今天是周天,天气晴朗,我晚上要去看电影。
python main.py data/sample_orig.txt data/sample_copy.txt ans.txt -v

程序在标准错误流上打印各特征的分项得分(不污染答案文件):

重复率 = 0.5691
  字符二元组余弦             0.6299  ×权重 0.381
  字符三元组余弦             0.4181  ×权重 0.245
  SimHash 汉明相似度         0.3438  ×权重 0.006
  序列比对相似度             0.7778  ×权重 0.177
  抄袭版覆盖率               0.4545  ×权重 0.191
  词语级余弦(jieba)          已跳过:jieba 不可用或文本过短,词语级特征退化,已剔除以免重复计权

答案文件中写入:

0.57

这段输出正好现场演示了 3.3 节第 ③ 点:SimHash 的权重只有 0.006。因为这段文本总共才 27 个字,去重后的字符三元组远低于 400 的阈值,自适应缩放把它的权重压到近乎失效 —— 否则短文本上随机性极大的指纹会污染最终结果(这段文本的裸 SimHash 相似度只有 0.3438,若按满权重 0.15 计入,会把总相似度从 0.57 拉低到 0.53 以下)。

8.2 自建样例语料测试

课堂下发的样例文本在网盘上无法访问,因此我另外撰写了一份语料集(data/),覆盖五种典型抄袭形态:

文件 构造方式 期望重复率 实测结果
orig_copy.txt 与原文完全相同 1.00 1.00
orig_add.txt 首尾各插入约 120 字的新内容 0.87
orig_del.txt 删掉原文中间约 1/3 中高 0.77
orig_mod.txt 逐句同义改写(每个词都换掉) 0.38
orig_unrelated.txt 完全无关(番茄炒蛋做法) 接近 0 0.01

构造这份语料的关键考虑是单调性:五种形态按"改动程度"排成一条梯度,任何合理的查重算法都应当给出单调递减的分数。这条梯度比任何单个数字都更能暴露算法问题 —— 事实上 orig_unrelated 拿到 0.09(而不是接近 0)时,正是它把我引向了 SimHash 基线那个 bug。

整个程序无需第三方运行依赖,能够直接通过 Python 命令行执行。


九、Git 版本管理

本项目使用 Git 记录开发过程,按功能划分提交,主要提交阶段包括:

提交 说明
chore: 初始化作业仓库 添加 .gitignore 与仓库说明
feat: 实现基础查重功能 异常体系、文本规范化、文件 IO、字符二元组余弦、命令行入口、样例语料
feat: 扩展查重能力 多特征融合(三元组余弦 / SimHash / 序列比对 / 覆盖率 / NCD)、jieba 可选增强、SimHash 基线校正
test: 补充 105 个单元测试 覆盖正常路径与全部异常分支
build: 新增覆盖率统计、性能分析与图表生成脚本 分析与可视化工具
docs: 完成 PSP 表格与博客正文 文档
chore: 作业目录改用学号命名 目录名规范化
style: 通过 Ruff 静态检查并固化代码规范 消除全部静态检查警告

其中「基础功能」与「扩展功能」分别独立提交,且各自提交时程序均可正常运行(基础版本实测照抄 1.00、增改 0.91、无关 0.01)。按功能划分提交,能够使版本历史更加清晰,也便于在出现问题时定位改动范围。


十、总结与改进计划

通过本次个人编程作业,我完成了从需求分析、算法设计、代码实现到单元测试、代码质量检查和性能优化的完整流程。过去完成程序时,我更关注代码能否运行;这次作业让我认识到,一个较完整的软件项目还需要考虑异常输入、编码兼容、测试覆盖率、性能数据和版本管理。

这次做对的三件事

  1. 先测量,再优化。 性能改进的每一步都有 cProfile 数据支撑。如果凭直觉去优化"看起来慢"的 SimHash,会错过真正占 37% 的 char_ngrams
  2. 用梯度语料而不是单点断言。 增/删/改/照抄/无关五种形态构成的单调梯度,直接暴露了 SimHash 基线那个 bug —— 单个用例是发现不了的。
  3. 把"零依赖可运行"当成硬约束。 这让程序在评测环境里没有安装风险,同时也逼出了"特征剔除 + 权重重归一化"这个更干净的架构。

算法本身的局限

本项目采用字符 n-gram 为主的多特征融合。该方法结构简单、无需第三方分词库,对局部增删和少量文字修改能够给出相似度结果。但它主要依据相邻字符是否相同,对大段落调换顺序、深度同义替换以及语义相近但表述完全不同的文本识别能力仍然有限 —— 实测中逐句改写(orig_mod.txt)只能得到 0.38,就是一个直接证据。

后续可以从以下方面继续改进

  1. 准备一份带人工标注的验证集。 当前只能证明"算法自洽"(单调、对称、值域正确),不能证明"算法准确"。这是最值得补的一块。
  2. 增加 HTML 正文提取。 如果输入文件带页面包装标签,直接作为纯文本参与计算会引入大量无关内容,影响相似度结果。
  3. 比较不同 n-gram 长度与特征权重组合对准确率和运行时间的影响,用消融实验替代现在的经验取值。
  4. 引入更细粒度的异常与返回值区分,例如把"文件读取失败"与"合法空文本"用不同的信号表达,而不是都收敛到兜底答案。
  5. 在继续优化前先建立稳定的性能基线,多次运行取平均值或中位数,减少单次测量误差。
  6. 补充性质测试(property-based testing),用 hypothesis 验证对称性、值域、单调性等不变量。

本次作业中,PSP 表格帮助我比较预估耗时和实际耗时,Coverage 帮助我发现未覆盖路径,Ruff 帮助我检查代码质量,cProfile 帮助我根据真实调用耗时定位性能热点。通过这些工具,我对"可运行、可测试、可维护、可分析"的软件开发过程有了更加具体的认识。

posted @ 2026-09-15 23:48  玖驻  阅读(8)  评论(0)    收藏  举报