第一次个人编程作业

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

GitHub 仓库链接:https://github.com/LoVseele/LoVseele/tree/main/3124004443

这个作业属于哪个课程 软件工程
这个作业要求在哪里 作业要求链接
这个作业的目标 实现一个论文查重算法

一、PSP 表格

PSP2.1 阶段 预估(分钟) 实际(分钟)
Planning 估计任务量 10 10
Analysis 需求分析 25 30
Design Spec 生成设计文档 10 8
Design Review 设计复审 5 5
Coding Standard 代码规范 5 5
Design 具体设计 15 12
Coding 具体编码 45 55
Code Review 代码复审 15 12
Test 测试 30 38
Test Report 测试报告 10 8
Size Measurement 计算工作量 5 5
Postmortem 事后总结与改进 10 12
合计 185 200

实际用时超出预估,主要花在调试性能图与补齐异常分支的测试用例上。


二、模块设计与实现

1. 代码组织

main.py
  └─ main(argv)                              # 入口:解析 3 个路径 → 计算 → 写答案
        ├─ text_utils.read_text_file(path)    # 多编码容错读取
        └─ similarity.TextSimilarity.compute(original, plagiarized)
              ├─ text_utils.normalize(text)      # NFKC + 小写 + 去空白
              ├─ similarity.char_ngrams(text)    # 惰性切分字符 n-gram(默认 n=2)
              ├─ similarity.build_vector(tokens) # 统计为词频向量
              └─ similarity.cosine_similarity(a, b) → _cosine(...)  # 交集余弦
  • text_utils.py:只负责把文件变成干净的字符串。normalize() 做归一化,read_text_file()utf-8 → utf-8-sig → gbk → gb18030 依次尝试解码。
  • similarity.py:算法层。char_ngrams / build_vector / _cosine / cosine_similarity 均为纯函数;TextSimilarity 是对外统一接口,通过 methodcosinesimhash 间切换;naive_cosine_similarity 仅用于性能对照。
  • main.py:只做参数解析、文件 IO 与调用算法,不含算法细节。

2. 关键函数流程图

flowchart TD A[main 读取 原文 / 抄袭版 文本] --> B[normalize: NFKC + 转小写 + 去空白] B --> C[char_ngrams: 惰性切分 2-gram] C --> D[build_vector: 统计词频] D --> E{method?} E -->|cosine 默认| F[cosine_similarity: 公共键求点积 → _cosine] E -->|simhash| G[simhash 指纹 + 海明距离] F --> H[结果 x100, 保留两位小数] G --> H H --> I[写入答案文件]

3. 算法要点

  1. 字符 n-gram 而非分词:中文无天然空格,分词需引入 jieba 等依赖,与"不联网、轻量"冲突;字符 n-gram 无需词典即可刻画局部结构,对增删改均稳健。
  2. 余弦相似度:重复率 = 余弦 × 100%。余弦对文本长度不敏感,值天然落在 [0,1]。
  3. 只在公共键上求点积:两向量维度很高但重合很少,先取 vec_a.keys() & vec_b.keys() 再累加,复杂度由 O(|A|·|B|) 降到 O(min(|A|,|B|))。
  4. n-gram 惰性产出:用生成器逐个产出,避免长文本一次性分配完整列表,降低峰值内存。

4. 特点

  • 零第三方运行时依赖,仅用标准库,直接满足评测约束。
  • 双算法可切换:cosine(默认,精度好)+ simhash(O(n),适合超长文档)。
  • 读取鲁棒:多编码依次尝试,最后忽略无法解码的字节,保证有内容可比。
  • 全局异常兜底:任何意外都写出 0.00,不会异常退出。

三、性能改进

用 cProfile 对核心算法做剖析,并针对最耗时处优化。

改进点:把双重循环余弦换成交集余弦。两者结果完全一致(单测 test_naive_equals_efficient 验证),但在去重 gram 数较大时后者快得多。微基准(高熵文本,各测 30 次):

文本规模 交集余弦(ms/次) 朴素双循环(ms/次)
300 字符 0.0256 2.1668
600 字符 0.0588 8.4239

规模翻倍时,朴素版耗时涨约 3.9 倍(≈ O(n²)),交集版涨约 2.3 倍(≈ O(n))。

完整流程耗时:20000 字符文本跑 5 次共 0.077 s,单次约 15 ms,远低于 5 s 上限。

消耗最大的函数(按 tottime):_collections._count_elements(0.027 s,统计词频)> char_ngrams(0.023 s,切 gram)> unicodedata.normalize(0.022 s,NFKC 归一化)。而求相似度的 cosine_similarity_cosine 累计耗时几乎为 0——瓶颈全在预处理,且均为 O(n)。

性能分析图

image

使用py生成


四、单元测试

29 个 pytest 用例(test_similarity.py),源码语句与分支覆盖率均为 100%
image

测试设计:等价类(相同/无关文本、子串抄袭、单字增删)、边界值(空文本、纯空白差异、全角半角、语序颠倒、n 取 1/2/3)、算法单元(对已知向量断言精确数值)、一致性(naivecosine 结果必须一致)、异常与 CLI(缺文件、输出目录不存在均不崩溃)。

代表性用例

def test_cosine_known_vectors():
    va = build_vector(["a", "a", "b"])
    vb = build_vector(["a", "b"])
    expected = 3 / (5 ** 0.5 * 2 ** 0.5)
    assert abs(cosine_similarity(va, vb) - expected) < 1e-9
    assert cosine_similarity(va, build_vector([])) == 0.0

def test_read_text_file_fallback_ignores_bad_bytes():
    with tempfile.TemporaryDirectory() as tmp:
        path = os.path.join(tmp, "broken.txt")
        with open(path, "wb") as f:
            f.write(b"\xff\x81abc")                 # 四种编码均无法解码
        assert read_text_file(path) == "abc"        # 兜底忽略坏字节而非崩溃

说明:.coveragerc 排除开发脚本与测试文件本身;入口守卫 if __name__ == "__main__": 按惯例不计入覆盖率。


五、异常处理

异常场景 处理方式 对应测试
参数不是 3 个 打印用法并 return 1 test_main_bad_argc / test_main_wrong_arg_count_too_many
输入文件不存在 IO 错误交由 main 兜底写出 0.00 test_main_missing_file_no_crash
文件编码异常 多编码依次尝试,最后忽略坏字节 test_read_text_file_fallback_ignores_bad_bytes
文本为 None normalize 返回空串 test_normalize_none_returns_empty
空原文 / 空抄袭版 向量为空时 _cosine 返回 0.0,避免除零 test_empty_original / test_empty_plagiarism
答案文件写不出 内外两层 try/except 兜底,仍正常返回 test_main_unwritable_answer_path_no_crash

示例(缺失文件不崩溃)

def test_main_missing_file_no_crash():
    with tempfile.TemporaryDirectory() as tmp:
        op = os.path.join(tmp, "nonexistent.txt")
        pp = os.path.join(tmp, "plag.txt")
        ap = os.path.join(tmp, "ans.txt")
        with open(pp, "w", encoding="utf-8") as f:
            f.write("hello")
        assert main([op, pp, ap]) == 0              # 缺失文件也不异常退出
        with open(ap, "r", encoding="utf-8") as f:
            assert f.read().strip() == "0.00"       # 兜底写出 0.00

六、运行与复现

# 计算重复率(示例:62.19)
python main.py "sample/orig.txt" "sample/orig_add.txt" "sample/ans.txt"

# 单元测试 + 分支覆盖率
python -m pytest -q --cov=. --cov-branch --cov-report=term-missing

# 代码质量(10.00/10,无警告)
python -m pylint text_utils.py similarity.py main.py

# 性能剖析与画图
python profile_run.py && python make_chart.py

posted @ 2026-09-13 21:03  LoVSeele  阅读(8)  评论(0)    收藏  举报