软工论文查重个人项目

第一次个人编程作业:论文查重(Python 3.13)

GitHub 作业仓库https://github.com/czhuohao1-glitch/3124004162

(代码位于仓库内以学号命名的文件夹 3124004162/ 中)

  • 题目:论文查重

  • 语言 / 环境:Python 3.13,Windows(代码跨平台)

  • 运行方式:python ``main.py`` 原文绝对路径 抄袭版绝对路径 答案文件绝对路径

  • 核心算法:中文分词(jieba)+ 词频向量 + 余弦相似度,并带字符 bigram 零依赖回退


一、PSP 表格

在正式编码前先按 PSP2.1 预估各阶段耗时,完工后回填实际耗时(单位:分钟)。

01_psp_table


二、需求分析与算法选型

2.1 问题理解

输入一份原文和一份在原文基础上做了增、删、改抄袭版论文,输出二者的重复率(一个 0.00~1.00 的浮点数,保留两位小数)。三个绝对路径全部由命令行参数给出,程序只读写这三个文件、全程不联网。

题目示例:

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

  • 抄袭版:今天是周天,天气晴朗,我晚上要去看电影。

2.2 方案对比与选型

我对比了三类常见方案,从实现复杂度、对 “增删改” 的鲁棒性、短文本区分度等角度权衡,最终选择「分词 + 词频向量 + 余弦相似度」。

02_scheme_compare

为什么用余弦相似度:抄袭版与原文共享大量主题词,词频向量方向接近、夹角小,余弦值接近 1;主题完全不同时共享词极少,余弦值接近 0。余弦对文本长度不敏感(计算时对向量模长做了归一化),正好契合 “增、删” 带来的长度变化。


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

3.1 模块组织:几个类、几个函数、关系如何

采用单一职责 + 门面模式:把读文件、归一化、分词、词频统计、相似度计算、异常处理拆成独立模块,上层 main.py 只和门面类 PlagiarismChecker 打交道,耦合低、便于单独测试。

03_class_diagram

各文件职责与关键接口如下:

04_module_table

3.2 关键流程

主流程(main.py)负责参数校验、调度、写答案,并对所有业务异常做统一受控处理,保证评测时 “不异常退出”:

05_main_flow

门面内部的计算链路是一条清晰的线性流水线:

06_calc_flow

3.3 算法关键(核心代码)

余弦相似度公式:

              Σ w\_a(k)·w\_b(k)

sim(A,B) = ─────────────────────────────

           √Σw\_a(k)²  ·  √Σw\_b(k)²

词频用单次字典遍历 O (n) 得到;余弦只遍历较短的词频字典求交集点积,不再构造与词表等长的稠密向量,兼顾时间与空间:

def term\_frequency(tokens):

    counter = {}

    for token in tokens:                       # O(n) 单次遍历

        counter\[token] = counter.get(token, 0) + 1

    return counter

def cosine\_similarity(tf\_a, tf\_b):

    if not tf\_a or not tf\_b:                   # 空向量约定,避免除零

        return 1.0 if not tf\_a and not tf\_b else 0.0

    if len(tf\_a) > len(tf\_b):                  # 只遍历较短字典

        tf\_a, tf\_b = tf\_b, tf\_a

    dot = sum(c \* tf\_b.get(w, 0) for w, c in tf\_a.items())

    if dot == 0:

        return 0.0

    norm = vector\_norm(tf\_a) \* vector\_norm(tf\_b)

    return min(1.0, max(0.0, dot / norm))      # 浮点误差钳制到 \[0,1]

3.4 独到之处

  1. 零依赖安全降级:优先用 jieba 精确分词;一旦运行环境缺少 jieba(评测机可能离线),自动改用标准库字符 bigram,程序绝不因缺第三方库而崩溃,两条路径结果接近(示例分别为 0.65 / 0.63)。

  2. 停用词降噪:剔除 “的、是、在、和” 等高频功能词,避免无关文本仅因虚词被抬高相似度 —— 无关主题文本的重复率由 0.21 降到 0.01

  3. 文本归一化:用 NFKC + str.translate 处理全角 / 半角、大小写、BOM、连续空白与标点,使 “AI123” 与 “ai123”、仅标点不同的文本判为一致。

  4. 多编码自动探测:按 utf-8-sig → gb18030 → big5 → latin-1 顺序解码,兼容带 BOM 的 UTF-8 与 Windows 中文 GBK 文本。

  5. 完备边界约定:两篇都空判为 1.0、仅一篇空判为 0.0,从根本上避免除零;结果钳制在 [0,1]

实测一组梯度,重复率随改写程度平滑变化、符合直觉:

07_gradient_table


四、计算模块的性能改进

4.1 首个版本的瓶颈

完成首个可运行版本后,我用 Python 标准库 cProfile(对应 Studio Profiling Tools)剖析,定位到两处可优化点:

  1. 词频统计最初写成 “对每个不同词都 list.count 全表扫描”,复杂度 O (n・V);余弦最初构造两条与词表等长的稠密向量再逐位点积,空间 O (V)。

  2. 全角转半角最初是逐字符 ord/chr 的 Python 循环,cProfile 显示它是项目自身代码里最耗时的函数之一。

4.2 改进与量化对比

  • 词频改为单次字典累加 O (n);余弦改为只遍历较短字典的稀疏点积,空间降为 O (不同词数)。

  • 全角转半角改为静态转换表 + str.translate,在 C 层一次完成。

优化前后对比如下(词频 + 余弦这一主要环节提速约 28 倍):

08_perf_chart

09_perf_table

4.3 程序中消耗最大的函数

对 10 万字符完整流程做 cProfile(3 次共 9,103,909 次函数调用、2.644 秒),按自身耗时 tottime 排序:

10_cprofile_table

结论:完整流程的主要固定成本在第三方分词器 jieba 内部,而我们负责的词频与余弦计算在 Top 榜中几乎不可见(10 万字仅约 3.5ms),说明计算模块已无明显瓶颈。实测 30 万字端到端(含解释器启动与 jieba 词典加载)仅约 1.35 秒,远低于评测 5 秒时限;内存只保存稀疏字典,远低于 2048MB。


五、单元测试展示

5.1 测试设计思路(白盒)

以白盒为主,结合等价类划分、边界值与异常路径,使用标准库 unittest(无需额外框架即可运行),共 45 个用例:

11_test_design

5.2 代表性测试代码

构造数据的思路覆盖正常等价类(相同 / 相似 / 无关)、边界值(空文本、单字、双空)、异常路径(缺文件、目录、坏编码、不可写目录)与性能回归(大文本限时):

def test\_21\_given\_example\_in\_range(self):

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

    suspicious = "今天是周天,天气晴朗,我晚上要去看电影。"

    rate = self.checker.check\_texts(original, suspicious)

    self.assertGreater(rate, 0.5)      # 抄袭版应中高相似

    self.assertLess(rate, 0.95)        # 但因增删改不等于完全相同

def test\_15\_both\_empty\_is\_one(self):

    self.assertEqual(cosine\_similarity({}, {}), 1.0)   # 双空约定

def test\_39\_large\_text\_within\_time\_limit(self):

    big = (PROJECT\_ROOT / "data" / "orig.txt").read\_text(encoding="utf-8") \* 1200

    start = time.perf\_counter()

    PlagiarismChecker().check\_texts(big, big)

    self.assertLess(time.perf\_counter() - start, 5.0)  # 评测时限

5.3 分支覆盖率

coverage 开启分支覆盖统计,45 个用例全部通过,语句 + 分支总覆盖率 99%;唯一未覆盖的是 tokenizer.py 中 “import jieba 失败” 的兜底分支(本机已装 jieba,该环境级防御代码无法自然触发):

12_coverage_term


六、异常处理说明

设计目标:评测明确要求程序不得异常退出,因此我把所有可预期错误收敛为自定义异常,由 main.py 统一捕获、打印清晰提示后以退出码 1 受控退出,绝不向评测平台抛出未处理堆栈(整体处理路径见第三节主流程图右侧)。每类异常都配有单元测试:

13_exception_table

入口统一捕获的实现:

def main(argv=None):

    args = sys.argv\[1:] if argv is None else argv

    try:

        original, suspicious, answer = parse\_args(args)

        run(original, suspicious, answer)

    except PaperCheckError as exc:        # 捕获全部业务异常

        print(USAGE, file=sys.stderr)

        print(f"错误: {exc}", file=sys.stderr)

        return 1                          # 受控退出,不打印堆栈

    return 0

七、运行效果与目录结构

命令行运行、答案文件内容与重复率梯度实测如下(答案为两位小数字符串 + 换行):

14_run_demo_term

项目目录结构:

15_dir_tree_term


八、实际 PSP 与过程改进

实际耗时合计 925 分钟,比预估的 900 分钟多 25 分钟,主要超支在 “具体编码” 与 “测试”(停用词调参、bigram 回退与覆盖率补齐),而设计 / 规范类阶段比预估更快。

事后总结与改进计划:

  1. 需求分析阶段对 “评测机可能离线、第三方库缺失” 预估不足,临时补了 bigram 回退;下次设计阶段就把运行环境约束列为固定检查项

  2. 性能问题应在写完首版后立刻 profile、用数据定位,而不是凭感觉优化;本次 cProfile 帮助精确锁定 get_DAG 与全半角函数,改进有据可依。

  3. 单元测试与编码同步推进,后期补覆盖率代价更低;下次坚持 “一个功能一组测试” 的节奏

  4. 后续可扩展:TF-IDF 加权进一步压制通用词、SimHash 支持超长文档、支持命令行批量目录。


九、过程与收获

按 PSP2.1 先预估后回填(预估 900 分钟 / 实际 925 分钟),Git 分 “基本功能 / 扩展功能” 多次提交。主要收获:性能优化要先用 Profiler 用数据定位、再动手;分层与门面模式显著提升了可测试性;需求阶段就应把离线、编码、路径等运行环境约束列为固定检查项。后续可扩展 TF-IDF 加权、SimHash 超长文档指纹与批量目录处理。

十、复现方式

\# 1) 安装依赖(可选;缺失时程序会自动降级为 bigram,不影响运行)

py -3.13 -m pip install -r requirements.txt

\# 2) 运行:三个绝对路径,空格分隔,路径中不要含空格

py -3.13 main.py D:\tests\orig.txt D:\tests\orig\_add.txt D:\tests\ans.txt

\# 3) 单元测试

py -3.13 -m unittest discover -s tests -v

\# 4) 分支覆盖率

py -3.13 -m coverage run -m unittest discover -s tests

py -3.13 -m coverage report -m

\# 5) 代码质量检查(All checks passed)

py -3.13 -m ruff check .

\# 6) 性能剖析 / 重新生成博客配图

py -3.13 profiling/benchmark.py

py -3.13 scripts/build\_blog\_images.py
posted @ 2026-09-15 22:11  执枪那年年少  阅读(6)  评论(0)    收藏  举报