第一次个人编程作业
| 这个作业属于哪个课程 | 计科24级78班-软件工程 |
|---|---|
| 这个作业的要求在哪里 | 个人项目 |
| 这个作业的目标 | 通过开发一个命令行论文查重工具,完整实践软件工程开发流程,提升算法设计、单元测试、性能优化与 Git 版本控制的综合能力 |
一、GitHub链接和PSP表格
1.1 本次作业的相关信息
项目仓库:https://github.com/Lilimarlene-hzx/3224004268
开发语言:python 3
1.2 PSP表格
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 10 | 34 |
| · Estimate | · 估计这个任务需要多少时间 | 10 | 34 |
| Development | 开发 | 275 | 291 |
| · Analysis | · 需求分析 (包括学习新技术) | 120 | 109 |
| · Design Spec | · 生成设计文档 | 20 | 19 |
| · Design Review | · 设计复审 | 10 | 10 |
| · Coding Standard | · 代码规范 (为目前的开发制定合适的规范) | 5 | 17 |
| · Design | · 具体设计 | 20 | 31 |
| · Coding | · 具体编码 | 60 | 52 |
| · Code Review | · 代码复审 | 10 | 5 |
| · Test | · 测试(自我测试,修改代码,提交修改) | 30 | 48 |
| Reporting | 报告 | 25 | 44 |
| · Test Repor | · 测试报告 | 10 | 23 |
| · Size Measurement | · 计算工作量 | 5 | 5 |
| · Postmortem & Process Improvement Plan | · 事后总结, 并提出过程改进计划 | 10 | 16 |
| · 合计 | 310 | 369 |
二、计算模块接口的设计与实现过程
2.1 代码组织与模块划分
为了满足“高内聚、低耦合”的软件设计原则,并方便后续进行单元测试,本程序没有使用类(Class),而是采用了纯函数式(Functional)设计。对于 VS Code 这个轻量级命令行工具来说,函数式设计比面向对象更简洁、更易测试,也不需要维护对象状态。
整个项目被拆分为4个核心模块,各司其职:
| 模块名称 | 类型 | 核心职责 |
|---|---|---|
| read_text | 函数 | 负责与文件系统交互,读取文本内容,屏蔽文件编码与路径细节 |
| preprocess_text | 函数 | 负责文本清洗,去除标点、空格、大小写等干扰因素 |
| plagiarism_rate | 函数 | 核心算法模块。接收纯净文本,计算并返回相似度浮点数 |
| main | 函数 | 调度中心。解析命令行参数,串联读取→预处理→计算→写入流程,处理全局异常 |
模块间关系图:

设计亮点: plagiarism_rate 被设计为纯函数(Pure Function)——给定相同的输入,永远返回相同的输出,不依赖任何外部状态。这使得它非常容易被单元测试覆盖,不需要构造复杂的文件系统环境。
2.2 关键函数流程图
对于核心的 plagiarism_rate 和 read_text 函数,逻辑涉及条件分支和异常处理,画流程图能更清晰地表达设计意图。
2.2.1 plagiarism_rate 算法流程图

2.2.2 read_text 异常处理流程图

2.3 算法关键与独到之处
本项目的核心算法并未直接使用简单的“字符串完全匹配”或“词频统计”,而是采用了基于序列匹配 + 集合相似度的混合加权算法。以下是算法的四个关键设计点:
2.3.1 基于 Ratcliff-Obershelp 的序列匹配(LCS)
- 原理: 程序调用 Python 标准库 difflib.SequenceMatcher,该算法底层基于 Ratcliff-Obershelp 算法,本质是寻找两个字符串的最长公共子序列(LCS)。
- 独到之处: 与单纯的“词频统计”不同,LCS 感知字符的相对顺序。例如,“我吃了苹果”和“苹果吃了我”在词频统计中可能高度相似,但在 LCS 中相似度会大幅降低,能有效识别“语序颠倒”的伪原创行为。
2.3.2 文本预处理(抗干扰机制)
- 原理: 抄袭者常用“加标点”、“加空格”、“改大小写”来逃避查重。程序在计算前先调用 preprocess_text,用正则表达式 [^\u4e00-\u9fa5a-zA-Z0-9] 将非中文、非英文、非数字的字符全部剔除,并统一转小写。
- 独到之处: 预处理与算法解耦,是独立函数。这样无论抄袭版加入多少种“噪声”,只要核心内容一致,都会被识别为高度相似。
2.3.3 混合加权算法(LCS + Jaccard)
- 原理: 单纯使用 LCS 对“部分内容相同但语序被打乱”的情况不敏感,而单纯的 Jaccard 相似度(集合交集/并集)又完全忽略顺序。
- 独到之处: 程序采用加权混合策略:
a. SequenceMatcher 相似度 × 0.7(看重语序和结构)
b. Jaccard 相似度 × 0.3(捕捉词汇重叠度,不受语序影响)
c. 最终分数 = 0.7 × LCS + 0.3 × Jaccard - 效果: 这种混合算法兼顾了顺序结构和词汇集合两个维度,查重结果更稳健,误判率更低。
2.3.4 autojunk=False 的精准匹配
- 原理: Python 的 SequenceMatcher 默认开启 autojunk 启发式算法,会自动忽略出现频率超过 1% 的“高频字符”(如中文里的“的”、“是”)。
- 独到之处: 虽然这能加速计算,但会导致查重结果虚低。程序显式关闭 autojunk,确保每一个字符都被纳入相似度计算,保证了查重结果的严谨性。虽然牺牲了微小的性能,但换来了对论文查重这种严肃场景至关重要的准确性。
三、计算模块接口部分的性能改进
3.1 性能改进所花费的时间记录
| 阶段 | 工作内容 | 耗时(分钟) |
|---|---|---|
| 性能测试 | 构造长文本,运行程序计时 | 16 |
| 瓶颈定位 | 使用 cProfile 分析耗时占比,定位热点函数 | 8 |
| 算法优化 | 重写 plagiarism_rate ,引入分块哈希与自适应采样策略 | 15 |
| 回归测试 | 验证优化后结果精度无明显下降,且单元测试全部通过 | 10 |
| 性能再测试 | 再次运行长文本,对比优化前后耗时 | 5 |
3.2 性能分析
在优化前,我在终端运行以下命令对程序进行性能剖析:
点击查看代码
py -m cProfile -s tottime main.py <原文文件> <抄袭版文件> <答案文件>
3.3 性能改进思路与实现
针对上述问题,我提出了分块哈希+自适应切换的优化方案:
- 短文本(< 5000 字符): 继续使用 SequenceMatcher,保证字符级匹配精度。
- 长文本(≥ 5000 字符): 将文本切分为 50 字符的块,使用 MD5 哈希建立索引表,将匹配复杂度从 O(N×M) 降为 O(N)。
3.4 优化后性能验证
论文查重计算模块性能分析
环境:Python 3.13 / Windows;分析工具:cProfile

| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 20万字符处理 | 30分钟+未返回 | 约1.2秒 | 数千倍 |
| 内存占用 | 崩溃级 | < 50 MB | 完全可控 |
四、 计算模块部分单元测试展示
4.1 单元测试代码
本项目使用 Python 内置的 unittest 框架,测试文件为 test_main.py。测试目标是核心算法函数 plagiarism_rate 和预处理函数 preprocess_text。
test_main.py 完整代码如下:
点击查看代码
import unittest
from main import plagiarism_rate, preprocess_text
class TestPreprocessText(unittest.TestCase):
"""测试文本预处理函数"""
def test_remove_punctuation(self):
"""测试:去除标点符号"""
raw = "今天天气好,我去看电影。"
result = preprocess_text(raw)
self.assertEqual(result, "今天天气好我去看电影")
def test_remove_whitespace(self):
"""测试:去除空格和换行"""
raw = "Hello World\n\nPython"
result = preprocess_text(raw)
self.assertEqual(result, "helloworldpython")
def test_lowercase(self):
"""测试:统一转为小写"""
raw = "HELLO World"
result = preprocess_text(raw)
self.assertEqual(result, "hello world".replace(" ", ""))
def test_keep_numbers(self):
"""测试:保留数字"""
raw = "第1章 第2节"
result = preprocess_text(raw)
self.assertEqual(result, "第1章第2节")
class TestPlagiarismRate(unittest.TestCase):
"""测试核心查重算法"""
def test_identical_text(self):
"""测试1:两段文本完全相同 → 应返回 1.0"""
text = "今天天气真好,我打算下午去看电影。"
self.assertAlmostEqual(plagiarism_rate(text, text), 1.0)
def test_completely_different(self):
"""测试2:两段文本完全不同 → 应返回 0.0"""
text1 = "今天天气真好"
text2 = "苹果香蕉梨子"
self.assertAlmostEqual(plagiarism_rate(text1, text2), 0.0)
def test_empty_both(self):
"""测试3:两个空文本 → 应返回 1.0"""
self.assertAlmostEqual(plagiarism_rate("", ""), 1.0)
def test_one_empty(self):
"""测试4:一空一非空 → 应返回 0.0"""
self.assertAlmostEqual(plagiarism_rate("今天天气好", ""), 0.0)
self.assertAlmostEqual(plagiarism_rate("", "今天天气好"), 0.0)
def test_punctuation_interference(self):
"""测试5:抄袭版加入标点符号干扰 → 应仍能识别为高度相似"""
text1 = "今天天气好我去看电影"
text2 = "今天天气好,我去看电影!!!"
self.assertAlmostEqual(plagiarism_rate(text1, text2), 1.0)
def test_whitespace_interference(self):
"""测试6:抄袭版加入空格干扰 → 应仍能识别为高度相似"""
text1 = "HelloWorldPython"
text2 = "H e l l o W o r l d P y t h o n"
self.assertAlmostEqual(plagiarism_rate(text1, text2), 1.0)
def test_case_interference(self):
"""测试7:抄袭版大小写不同 → 应仍能识别为高度相似"""
text1 = "HelloWorld"
text2 = "helloworld"
self.assertAlmostEqual(plagiarism_rate(text1, text2), 1.0)
def test_partial_match(self):
"""测试8:部分抄袭 → 相似度应在 0 到 1 之间"""
text1 = "这是一篇很长的学术论文,讨论了很多问题"
text2 = "这是一篇很长的学术论文"
score = plagiarism_rate(text1, text2)
self.assertTrue(0.0 < score < 1.0)
def test_reverse_order(self):
"""测试9:语序颠倒 → 应降低相似度,不能是 1.0"""
text1 = "我爱你中国"
text2 = "中国爱你我"
score = plagiarism_rate(text1, text2)
self.assertLess(score, 1.0)
def test_long_text_performance(self):
"""测试10:长文本不会崩溃,且能在合理时间内返回结果"""
text1 = "今天天气真好" * 1000 # 6000 字符
text2 = "今天天气很好" * 1000
score = plagiarism_rate(text1, text2)
self.assertTrue(0.0 <= score <= 1.0)
if __name__ == '__main__':
unittest.main()
4.2 测试函数说明与数据构造
4.2.1 测试函数
| 编号 | 测试函数 | 构造思路 | 覆盖的场景 |
|---|---|---|---|
| 1 | test_identical_text |
使用完全相同的字符串,验证算法的"满分"行为 | 正常场景(基准线) |
| 2 | test_completely_different |
使用毫无关联的中文字符,验证算法的"零分"行为 | 正常场景(下界) |
| 3 | test_empty_both |
构造两个空字符串,验证空文件的边界逻辑 | 边界场景 |
| 4 | test_one_empty |
构造"一空一非空"的组合,验证单向空文件逻辑 | 边界场景 |
| 5 | test_punctuation_interference |
给一段文本人为添加标点符号,模拟抄袭者"加标点逃避查重" | 反作弊场景 |
| 6 | test_whitespace_interference |
给字符间插入大量空格,模拟抄袭者"加空格逃避查重" | 反作弊场景 |
| 7 | test_case_interference |
大小写混用,验证预处理统一小写的有效性 | 反作弊场景 |
| 8 | test_partial_match |
构造一段是另一段子集的情况,验证分数落在合理区间 | 部分抄袭场景 |
| 9 | test_reverse_order |
构造语序颠倒的文本,验证算法对语序的敏感性 | 伪原创场景 |
| 10 | test_long_text_performance |
使用 6000 字符的长文本,验证优化后算法不崩溃 | 性能相关场景 |
4.2.2 构造测试数据的核心原则:
- 等价类划分: 将输入分为"完全相同"、"完全不同"、"部分相同"、"空"四类,每类至少一个用例。
- 边界值分析: 重点测试空字符串、单字符、超长文本这三种边界情况。
- 干扰模拟: 真实抄袭者会加入标点、空格、大小写变化来逃避查重,所以必须构造这些"对抗性"数据。
- 可重复性: 所有测试数据都是硬编码字符串,不依赖外部文件,保证测试可重复运行。
4.3 单元测试运行结果

4.4 单元测试覆盖率
安装覆盖率工具
点击查看代码
pip install coverage -i https://mirrors.aliyun.com/pypi/simple/

4.5 测试心得
在编写单元测试的过程中,我最大的收获是"边界场景比正常场景更容易暴露 Bug"。
最初我只写了"完全相同"和"完全不同"两个测试,认为已经够了。但当我尝试构造空字符串时,发现旧版代码在"两文皆空"的情况下返回 0.0(逻辑上应该返回 1.0),暴露了一个真正的边界 Bug。后来通过修改 plagiarism_rate 的边界判断逻辑修复了这个问题。
另外,反作弊测试(加标点、加空格、改大小写)让我意识到预处理函数的必要性。如果没有 preprocess_text,这三个测试都会失败。测试驱动了设计的改进。
覆盖率报告显示 main.py 的 except Exception 兜底分支未被覆盖。这是合理的——触发这个分支需要让程序发生难以复现的未知异常。不过,可以在未来的测试中通过 mock 技术人为注入异常来覆盖它。
五、计算模块部分异常处理说明
本程序在设计时高度重视容错性(Fault Tolerance)。对于一个命令行工具而言,用户可能传入各种非法的文件路径或数据,如果程序直接崩溃(Crash),会给用户带来极差的体验,甚至导致批处理任务中断。因此,我针对 4 类典型异常设计了专门的捕获与处理逻辑。
5.1 异常清单与设计目标
| 序号 | 异常类型 | 触发场景 | 设计目标 |
|---|---|---|---|
| 1 | FileNotFoundError |
用户输入的文件路径不存在 | 提示清晰错误,避免 Traceback 崩溃输出 |
| 2 | IOError(路径不是文件) |
用户传入的是一个目录路径,而非文件 | 区分"不存在"和"不是文件",给出精准提示 |
| 3 | IOError(读取失败) |
文件存在但无法读取(如权限不足、编码错误) | 捕获底层异常并包装为友好信息 |
| 4 | 未知异常(兜底) | 程序运行过程中出现的其他不可预期错误 | 保证程序最终以非 0 状态码退出,便于脚本集成 |
5.2 异常处理的设计总结
本程序采用分层异常处理策略:在 read_text 函数中,将底层 I/O 异常分类抛出,便于精准定位问题;在 main 函数中,统一捕获所有异常,保证程序始终以友好方式退出。所有错误信息都通过 sys.stderr 输出(而非 stdout),符合命令行工具的标准规范,避免错误信息混入正常结果输出流。异常测试用例全部通过,验证了程序在各类异常输入下的健壮性。

浙公网安备 33010602011771号