第一次个人编程作业

这个作业属于哪个课程 https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS/
这个作业要求在哪里 https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS/homework/15693
这个作业的目标 编码实现个人项目“论文查重”,通过github进行版本管理并写一篇技术博客

本项目 GitHub 仓库:https://github.com/Jiyue0321/gdut-homework1

e333b4073e487878df846355162da165

本文是软件工程课程第一次个人项目的总结博客。项目要求实现一个论文查重命令行工具:给定原文、待查重文件和答案文件三个路径,输出 0.00~1.00 的重复率。我以 SimHash + Hamming 距离作为主算法,TF 余弦相似度作为对照,下面从 PSP表格填写、接口设计、性能改进、单元测试、异常处理几个方面展开。

一、PSP 表格(预估)

PSP 阶段 预估耗时 (h)
Planning(计划) 1.0
Estimate(估时) 0.3
Development - Design(设计) 1.5
Development - Code(编码) 3.0
Code Review(代码审查) 1.0
Test - 写用例 2.0
Test - 运行调试 1.5
Postmortem(总结 + 博客) 2.0
合计 12.3

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

模块结构

按"一个模块一个职责"的思路把代码拆成 5 个文件,依赖单向,方便单测时按模块 mock:

3124004106/
├── main.py        # 命令行入口、参数解析、端到端编排
├── fileio.py      # 文件读写(UTF-8 优先、GBK 回退)
├── tokenizer.py   # jieba 分词 + 标点/空白过滤
├── simhash.py     # SimHash 指纹、Hamming 距离、余弦相似度
└── exceptions.py  # PlagiarismError 异常层级

依赖图:main → fileio, tokenizer, simhash → exceptions。后面三个模块互相独立,每个都能单独构造测试用例,不会被其他模块的状态干扰。

关键函数

fileio.py

  • read_text(path) — UTF-8 strict 失败回退 GBK;空文件抛 EmptyFileErrorOSError 包装为 FileReadError
  • write_answer(path, rate) — 写 f"{rate:.2f}"

tokenizer.py

  • tokenize(text)jieba.lcut 切词,过滤标点(Unicode P* 类 + CJK 标点区段),拉丁字母小写化
  • _is_punctuation(token) — 判断 token 是否全是标点/空白

simhash.py

  • compute_simhash(tokens, bits=64) — 词频加权,逐位累加 ± 权重,按符号位定指纹
  • hamming_distance(a, b, bits=64)bin((a ^ b) & mask).count("1")
  • simhash_rate(a, b, bits=64)1 - hamming / bits
  • cosine_rate(tokens_a, tokens_b) — TF 向量余弦,作为对照算法

main.py

  • parse_args(argv) — 校验 --method 和 3 个路径参数
  • main(argv) — 编排:读 → 分词 → 相似度 → 写;捕获 PlagiarismError 输出到 stderr 并返回 1

算法关键

默认路线:SimHash + Hamming 距离

注:算法的选用和设计有借助AI进行资料的查阅。

主流程:原文 → jieba分词 → 去标点 → SimHash(64位指纹) → Hamming → 归一化重复率

  1. 对每个 token 用 md5 取前 8 字节作 64 位稳定哈希。这里没用 Python 内置 hash(),因为 PYTHONHASHSEED 默认开启随机化,同一输入在不同进程里指纹会不一致,不利于复现。
  2. Counter 统计词频作为权重,逐位累加:该位为 1 则 +weight,为 0 则 -weight
  3. 最终每位按符号定值:>0 → 1≤0 → 0,得到 64 位指纹。
  4. 重复率 rate = 1 - hamming_distance / 64

对照路线:TF 余弦相似度--method cosine 触发)

构建词频向量,计算两向量夹角余弦。这条路线主要用来和 SimHash 做对比,下面会展开。

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

性能分析

cProfile 跑了一份约 1 万字的合成文本(脚本见 profile_simhash.py),单次查重总耗时约 1.4 秒,远低于作业 5 秒的预算。

profile

消耗最大的函数

排名 函数 累计耗时 说明
1 jieba.initialize ~0.77s 首次加载默认词典(marshal.load
2 jieba.get_DAG ~0.85s 构建有向无环图
3 jieba.cut ~1.15s 分词主循环
4 tokenizer.tokenize ~1.35s 含上述 jieba 调用
5 _is_punctuation ~0.14s 标点过滤

注:累计耗时是含被调用子函数的总时间,所以 tokenizer.tokenizejieba.cut 还高是正常的——它把 jieba 的开销都算进去了。

改进思路

瓶颈定位:约 99% 的时间花在 jieba 分词,SimHash 本身(compute_simhash + hamming_distance)几乎可以忽略。

已经做的优化

  • Counter 一次性统计词频,避免重复扫描 tokens 列表
  • 逐位累加用 list 而非 numpy,避免大数组对象创建开销
  • md5 只取前 8 字节而不是全部 16 字节,减少后续位运算量

没做但可以考虑的方向

  • jieba 词典加载约 0.6s 是固定开销,单次运行场景下没法优化;若改成批量查重服务,可以复用进程把这部分摊掉

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

测试用例统计

在AI帮助下,写了 75 个测试用例,分支覆盖率 97%:

模块 用例数 覆盖率
exceptions.py 6 100%
fileio.py 12 88%
main.py 20 93%
simhash.py 22 97%
tokenizer.py 15 96%
总计 75 97%

典型用例

test_simhash.py — SimHash 对同义替换的鲁棒性

def test_simhash_rate_assignment_sample_in_range():
    """作业样例:星期天 vs 周天,重复率应在 [0.5, 0.95] 区间。"""
    from tokenizer import tokenize
    t1 = tokenize("今天是星期天,天气晴,今天晚上我要去看电影。")
    t2 = tokenize("今天是周天,天气晴朗,我晚上要去看电影。")
    rate = simhash_rate(compute_simhash(t1), compute_simhash(t2))
    assert 0.5 <= rate <= 0.95

构造思路:直接拿作业样例做集成测试,验证在同义词替换(星期天→周天)和局部增删(去掉"今天")的情况下,SimHash 仍能给出符合常识的重复率区间。

test_fileio.py — 编码回退

def test_read_text_gbk_fallback(tmp_path):
    """UTF-8 strict 失败后应回退 GBK。"""
    p = tmp_path / "g.txt"
    p.write_text("今天星期天", encoding="gbk")
    assert read_text(str(p)) == "今天星期天"

构造思路:中文 Windows 环境下使用 GBK 进行编码的文件很常见,必须能正确读取。用 tmp_path fixture 创建临时文件,测试结束自动清理,不污染工作区。

test_main.py — 纯标点特判

def test_main_only_punctuation_returns_zero(tmp_path):
    """纯标点输入 → tokens 为空 → 特判 0.00。"""
    ans = tmp_path / "ans.txt"
    rc = main([
        str(FIXTURES / "only_punct.txt"),
        str(FIXTURES / "orig.txt"),
        str(ans),
    ])
    assert rc == 0
    assert ans.read_text(encoding="utf-8") == "0.00"

构造思路:纯标点输入会让 tokens 为空,compute_simhash 返回 0,两边都为 0 时 simhash_rate(0, 0) = 1.0 反而会被误判为完全重复。这是一个必须特判的边界情况。

覆盖率截图

image

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

所有异常都继承自 PlagiarismErrormain 函数统一捕获后把错误信息输出到 stderr 并返回退出码 1,保证不会把异常堆栈或乱码写进答案文件。

1. InvalidArgumentError

场景:命令行参数数量错误,或 --method 值非法。

def test_parse_args_unknown_method_raises():
    with pytest.raises(InvalidArgumentError) as exc:
        parse_args(["--method", "levenshtein", "a.txt", "b.txt", "c.txt"])
    assert "未知算法" in str(exc.value)

2. FileReadError

场景:原文/抄袭版文件不存在、无权限、或编码既非 UTF-8 也非 GBK。

def test_read_text_non_utf8_non_gbk_raises(tmp_path):
    p = tmp_path / "bin.txt"
    p.write_bytes(b"\xff\xfe\x00\x01\x02\xff")
    with pytest.raises(FileReadError) as exc:
        read_text(str(p))
    assert "编码" in str(exc.value)

3. EmptyFileError

场景:文件存在但内容为空(或仅空白),无任何字符可查重。

def test_read_text_empty_file_raises(tmp_path):
    p = tmp_path / "empty.txt"
    p.write_text("", encoding="utf-8")
    with pytest.raises(EmptyFileError):
        read_text(str(p))

4. FileWriteError

场景:答案文件目录不存在或不可写。

def test_write_answer_to_nonexistent_dir_raises(tmp_path):
    bad = tmp_path / "no_such_dir" / "ans.txt"
    with pytest.raises(FileWriteError):
        write_answer(str(bad), 0.5)

六、PSP 表格(实际)

PSP 阶段 预估 (h) 实际 (h)
Planning(计划) 1.0 1.2
Estimate(估时) 0.3 0.3
Development - Design(设计) 1.5 1.8
Development - Code(编码) 3.0 3.5
Code Review(代码审查) 1.0 1.2
Test - 写用例 2.0 2.4
Test - 运行调试 1.5 1.6
Postmortem(总结 + 博客) 2.0 2.3
合计 12.3 14.3

七、参考资料

算法参考文献

作业要求遵守的开发规范

posted @ 2026-09-12 21:43  Jiyue2222  阅读(20)  评论(0)    收藏  举报