软工论文查重个人项目
第一次个人编程作业:论文查重(Python 3.13)
GitHub 作业仓库:https://github.com/czhuohao1-glitch/3124004162
(代码位于仓库内以学号命名的文件夹 3124004162/ 中)
-
题目:论文查重
-
语言 / 环境:Python 3.13,Windows(代码跨平台)
-
运行方式:
python ``main.py`` 原文绝对路径 抄袭版绝对路径 答案文件绝对路径 -
核心算法:中文分词(jieba)+ 词频向量 + 余弦相似度,并带字符 bigram 零依赖回退
一、PSP 表格
在正式编码前先按 PSP2.1 预估各阶段耗时,完工后回填实际耗时(单位:分钟)。

二、需求分析与算法选型
2.1 问题理解
输入一份原文和一份在原文基础上做了增、删、改的抄袭版论文,输出二者的重复率(一个 0.00~1.00 的浮点数,保留两位小数)。三个绝对路径全部由命令行参数给出,程序只读写这三个文件、全程不联网。
题目示例:
-
原文:今天是星期天,天气晴,今天晚上我要去看电影。
-
抄袭版:今天是周天,天气晴朗,我晚上要去看电影。
2.2 方案对比与选型
我对比了三类常见方案,从实现复杂度、对 “增删改” 的鲁棒性、短文本区分度等角度权衡,最终选择「分词 + 词频向量 + 余弦相似度」。

为什么用余弦相似度:抄袭版与原文共享大量主题词,词频向量方向接近、夹角小,余弦值接近 1;主题完全不同时共享词极少,余弦值接近 0。余弦对文本长度不敏感(计算时对向量模长做了归一化),正好契合 “增、删” 带来的长度变化。
三、计算模块接口的设计与实现
3.1 模块组织:几个类、几个函数、关系如何
采用单一职责 + 门面模式:把读文件、归一化、分词、词频统计、相似度计算、异常处理拆成独立模块,上层 main.py 只和门面类 PlagiarismChecker 打交道,耦合低、便于单独测试。

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

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

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

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 独到之处
-
零依赖安全降级:优先用 jieba 精确分词;一旦运行环境缺少 jieba(评测机可能离线),自动改用标准库字符 bigram,程序绝不因缺第三方库而崩溃,两条路径结果接近(示例分别为 0.65 / 0.63)。
-
停用词降噪:剔除 “的、是、在、和” 等高频功能词,避免无关文本仅因虚词被抬高相似度 —— 无关主题文本的重复率由 0.21 降到 0.01。
-
文本归一化:用 NFKC +
str.translate处理全角 / 半角、大小写、BOM、连续空白与标点,使 “AI123” 与 “ai123”、仅标点不同的文本判为一致。 -
多编码自动探测:按
utf-8-sig → gb18030 → big5 → latin-1顺序解码,兼容带 BOM 的 UTF-8 与 Windows 中文 GBK 文本。 -
完备边界约定:两篇都空判为 1.0、仅一篇空判为 0.0,从根本上避免除零;结果钳制在
[0,1]。
实测一组梯度,重复率随改写程度平滑变化、符合直觉:

四、计算模块的性能改进
4.1 首个版本的瓶颈
完成首个可运行版本后,我用 Python 标准库 cProfile(对应 Studio Profiling Tools)剖析,定位到两处可优化点:
-
词频统计最初写成 “对每个不同词都
list.count全表扫描”,复杂度 O (n・V);余弦最初构造两条与词表等长的稠密向量再逐位点积,空间 O (V)。 -
全角转半角最初是逐字符
ord/chr的 Python 循环,cProfile 显示它是项目自身代码里最耗时的函数之一。
4.2 改进与量化对比
-
词频改为单次字典累加 O (n);余弦改为只遍历较短字典的稀疏点积,空间降为 O (不同词数)。
-
全角转半角改为静态转换表 +
str.translate,在 C 层一次完成。
优化前后对比如下(词频 + 余弦这一主要环节提速约 28 倍):


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

结论:完整流程的主要固定成本在第三方分词器 jieba 内部,而我们负责的词频与余弦计算在 Top 榜中几乎不可见(10 万字仅约 3.5ms),说明计算模块已无明显瓶颈。实测 30 万字端到端(含解释器启动与 jieba 词典加载)仅约 1.35 秒,远低于评测 5 秒时限;内存只保存稀疏字典,远低于 2048MB。
五、单元测试展示
5.1 测试设计思路(白盒)
以白盒为主,结合等价类划分、边界值与异常路径,使用标准库 unittest(无需额外框架即可运行),共 45 个用例:

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,该环境级防御代码无法自然触发):

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

入口统一捕获的实现:
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
七、运行效果与目录结构
命令行运行、答案文件内容与重复率梯度实测如下(答案为两位小数字符串 + 换行):

项目目录结构:

八、实际 PSP 与过程改进
实际耗时合计 925 分钟,比预估的 900 分钟多 25 分钟,主要超支在 “具体编码” 与 “测试”(停用词调参、bigram 回退与覆盖率补齐),而设计 / 规范类阶段比预估更快。
事后总结与改进计划:
-
需求分析阶段对 “评测机可能离线、第三方库缺失” 预估不足,临时补了 bigram 回退;下次设计阶段就把运行环境约束列为固定检查项。
-
性能问题应在写完首版后立刻 profile、用数据定位,而不是凭感觉优化;本次 cProfile 帮助精确锁定
get_DAG与全半角函数,改进有据可依。 -
单元测试与编码同步推进,后期补覆盖率代价更低;下次坚持 “一个功能一组测试” 的节奏。
-
后续可扩展: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
浙公网安备 33010602011771号