第一次个人编程作业
第一次个人编程作业:论文查重
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | 计科24级5班 |
| 这个作业的要求在哪 | 第一次个人编程作业 |
| 这个作业的目标 | 完成个人编程作业,实现论文查重算法,使用 Git 管理代码,编写单元测试和性能分析,并撰写博客记录开发过程。 |
作业GitHub链接:https://github.com/Charon0921/Charon0921/tree/main/3224004080
一、PSP表格(预估)
在开始编码之前,根据作业要求和自身情况,对各个开发环节的耗时进行预估:
| PSP2.1 | 预估耗时(分钟) |
|---|---|
| Planning | |
| · Estimate(估计这个任务需要多少时间) | 30 |
| Development | |
| · Analysis(需求分析,包括学习新技术) | 60 |
| · Design Spec(生成设计文档) | 30 |
| · Design Review(设计复审) | 20 |
| · Coding Standard(代码规范) | 15 |
| · Design(具体设计) | 40 |
| · Coding(具体编码) | 120 |
| · Code Review(代码复审) | 30 |
| · Test(自我测试,修改代码,提交修改) | 90 |
| Reporting | |
| · Test Report(测试报告) | 30 |
| · Size Measurement(计算工作量) | 15 |
| · Postmortem & Process Improvement Plan | 30 |
| 合计 | 510 |
二、计算模块接口的设计与实现过程
1. 代码组织
项目使用 Python 3 标准库实现,无需安装第三方依赖。代码结构如下:
3224004080/
├── main.py # 查重程序入口
├── requirements.txt # 依赖说明
├── README.md # 项目文档
├── 测试数据/
│ ├── orig.txt # 原文样例
│ ├── orig_add.txt # 增加版抄袭文
│ ├── orig_del.txt # 删除版抄袭文
│ ├── orig_dis_1.txt # 微改版抄袭文
│ ├── orig_dis_10.txt # 中改版抄袭文
│ └── orig_dis_15.txt # 大改版抄袭文
├── tests/
│ └── test_main.py # 单元测试(13项)
└── tools/
└── profile_performance.py # 性能分析脚本
main.py 中定义了以下函数,彼此之间为顺序调用关系:
read_text() → normalize_text() → build_ngrams() → calculate_similarity()
↓
main()(入口)
read_text(path):读取文件,自动尝试 UTF-8 和 GB18030 编码。normalize_text(text):使用正则表达式去除空白和标点,只保留中文、英文和数字。build_ngrams(text, size):将清理后的文本切分为连续的二元字符片段集合。calculate_similarity(original, copied):计算两个片段集合的 Jaccard 相似度。main(arguments):命令行入口,接收三个参数(原文路径、抄袭文路径、答案路径),将结果写入答案文件。
2. 算法关键
采用字符二元片段(bigram)+ Jaccard 相似度算法:
第一步:文本清理。 使用正则表达式 re.compile(r"[\W_]+") 一次性替换所有非字母数字字符,去除空白和标点。例如 "今天,天气晴!" 清理后为 "今天天气晴"。
第二步:二元片段切分。 将清理后的文本按连续 2 个字符切分。例如 "今天天气" 切分为 {"今天", "天天", "天气"}。对于长度不足 2 的文本,将整个文本视为一个片段。
第三步:Jaccard 相似度计算。
$$相似度\text{相似度} = \frac{|A \cap B|}{|A \cup B|}$$
其中 A 和 B 分别为原文和抄袭文的二元片段集合。结果为 0 到 1 之间的浮点数,保留两位小数。
3. 算法流程图
开始
│
▼
读取原文文件 ──→ read_text()
│
▼
读取抄袭文文件 ──→ read_text()
│
▼
文本清理(去除标点空白)──→ normalize_text()
│
▼
切分二元字符片段 ──→ build_ngrams()
│
├──── 原文片段集合 A
│
├──── 抄袭文片段集合 B
│
▼
计算 Jaccard 相似度 = |A ∩ B| / |A ∪ B|
│
▼
保留两位小数写入答案文件
│
▼
结束
4. 算法独到之处
- 适合中文文本:不需要分词工具,直接以字符为单位切分,天然支持中文。
- 零第三方依赖:仅使用 Python 标准库(
re、sys、pathlib),无需安装任何外部库。 - 对少量改字敏感:例如"今天是星期天"和"今天是周天",虽然只改了少数字符,但共享的二元片段比例会明显下降,能检测出局部修改。
- 标点无关:文本清理步骤去除了所有标点和空格,因此标点差异不会影响相似度计算。
三、计算模块接口部分的性能改进
1. 性能分析工具
使用 Python 自带的 cProfile 模块进行性能分析。通过 tools/profile_performance.py 脚本生成约 35 万字的模拟文本,对其执行 10 次查重计算,确保分析结果稳定可信。
运行命令:
python -m cProfile -s cumulative -m tools.profile_performance
性能分析截图:

2. 优化前分析
优化前的性能分析结果:
| 函数 | 累计耗时(秒) |
|---|---|
| 总耗时(10次查重) | 1.420 |
calculate_similarity |
1.410 |
normalize_text |
1.178 |
build_ngrams |
0.231 |
瓶颈定位:normalize_text 函数是最大瓶颈,占总耗时的 83%。原实现使用逐字符遍历判断是否为字母数字,效率较低。
3. 优化措施
将 normalize_text 从逐字符循环改为使用预编译正则表达式一次性替换:
# 优化前:逐字符判断
def normalize_text(text):
return "".join(c for c in text if c.isalnum())
# 优化后:正则表达式批量替换
NON_ALNUM_PATTERN = re.compile(r"[\W_]+", flags=re.UNICODE)
def normalize_text(text):
return NON_ALNUM_PATTERN.sub("", text)
4. 优化后结果
| 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| 10次查重总耗时 | 1.420 秒 | 0.320 秒 | 减少 77.5% |
normalize_text |
1.178 秒 | 0.078 秒 | 减少 93.3% |
build_ngrams |
0.231 秒 | 0.220 秒 | 基本不变 |
| 模拟文本相似度 | 0.62 | 0.62 | 不变 |
优化后程序消耗最大的函数变为 build_ngrams 中的 <setcomp>(集合推导式,0.220 秒),属于生成字符片段的必要工作,当前性能已满足需求。优化未改变计算结果,相似度仍为 0.62。
5. 性能分析截图说明
优化前的 cProfile 输出(关键部分):
ncalls tottime cumtime filename:lineno(function)
10 0.001 1.410 main.py:39(calculate_similarity)
20 0.231 0.231 main.py:30(build_ngrams)
20 1.178 1.178 main.py:25(normalize_text)
优化后的 cProfile 输出(关键部分):
ncalls tottime cumtime filename:lineno(function)
10 0.001 0.299 main.py:39(calculate_similarity)
20 0.220 0.220 main.py:36(<setcomp>)
20 0.000 0.078 main.py:25(normalize_text)
20 0.078 0.078 {method 'sub' of 're.Pattern' objects}
四、计算模块部分单元测试展示
1. 测试代码
使用 Python 自带的 unittest 框架,共编写 13 项测试,覆盖正常输入、边界条件和异常情况。
以下是部分核心测试代码:
class SimilarityTests(unittest.TestCase):
"""覆盖文本相似度计算和命令行入口的测试集合。"""
def test_identical_texts_have_full_similarity(self):
"""相同文本的相似度应为一。"""
self.assertEqual(main.calculate_similarity("今天是星期天", "今天是星期天"), 1.0)
def test_completely_different_texts_have_zero_similarity(self):
"""完全不同文本的相似度应为零。"""
self.assertEqual(main.calculate_similarity("甲乙丙丁", "戊己庚辛"), 0.0)
def test_both_empty_texts_are_identical(self):
"""两个空文本视作相同。"""
self.assertEqual(main.calculate_similarity("", ""), 1.0)
def test_punctuation_and_spaces_do_not_change_similarity(self):
"""标点和空格不应影响相似度。"""
original = "今天,天气很好!"
copied = "今天 天气很好"
self.assertEqual(main.calculate_similarity(original, copied), 1.0)
def test_main_writes_a_two_decimal_answer_file(self):
"""命令行入口应写入保留两位小数的答案。"""
with tempfile.TemporaryDirectory() as temporary_directory:
folder = Path(temporary_directory)
original = folder / "original.txt"
copied = folder / "copied.txt"
answer = folder / "answer.txt"
original.write_text("相同的论文内容", encoding="utf-8")
copied.write_text("相同的论文内容", encoding="utf-8")
status = main.main([str(original), str(copied), str(answer)])
self.assertEqual(status, 0)
self.assertEqual(answer.read_text(encoding="utf-8"), "1.00\n")
def test_main_rejects_wrong_argument_count(self):
"""命令行参数数量错误时应返回错误状态。"""
self.assertEqual(main.main([]), 2)
2. 测试用例说明
| 序号 | 测试方法 | 测试目标 | 构造思路 |
|---|---|---|---|
| 1 | test_identical_texts | 完全相同文本 → 1.0 | 基准用例 |
| 2 | test_completely_different | 完全不同文本 → 0.0 | 基准用例 |
| 3 | test_both_empty | 两个空文本 → 1.0 | 边界条件 |
| 4 | test_empty_original | 空原文 vs 非空 → 0.0 | 边界条件 |
| 5 | test_empty_copied | 非空 vs 空文本 → 0.0 | 边界条件 |
| 6 | test_punctuation_and_spaces | 标点空格不影响结果 | 标点差异场景 |
| 7 | test_newlines | 换行不影响结果 | 格式差异场景 |
| 8 | test_same_single_character | 相同单字符 → 1.0 | 极短文本 |
| 9 | test_different_single_characters | 不同单字符 → 0.0 | 极短文本 |
| 10 | test_small_edit_keeps_partial | 少量改字 → 部分相似 | 局部修改场景(核心需求) |
| 11 | test_english_and_numbers | 英文数字参与比较 | 混合语言场景 |
| 12 | test_main_writes_two_decimal_answer | 命令行入口写入答案 | 端到端集成测试 |
| 13 | test_main_rejects_wrong_argument | 参数错误返回状态码2 | 异常处理 |
3. 测试数据构造思路
- 基准用例(用例1-2):用完全相同和完全不同的文本验证算法的上下界。
- 边界条件(用例3-5):测试空文本的各种组合,确保程序不会因空输入崩溃或产生错误结果。
- 格式无关性(用例6-7):验证标点、空格和换行不会影响相似度,这是论文查重的基本要求。
- 极短文本(用例8-9):测试单个字符的情况,确保算法对极短文本也能正确处理。
- 核心场景(用例10):模拟"今天是星期天"→"今天晚上要看电影"这类少量改字,验证算法对局部修改的检测能力。
- 混合语言(用例11):验证英文和数字同样参与比较。
- 集成测试(用例12-13):测试命令行入口的完整流程和异常处理。
4. 测试结果与覆盖率
运行 python -m unittest discover -s tests -v,13 项测试全部通过:
Ran 13 tests in 0.007s
OK
使用 coverage 工具分析测试覆盖率:
set PYTHONPATH=. && python -m coverage run --source=main tests\test_main.py && python -m coverage report -m
测试覆盖率截图:

覆盖率结果:
Name Stmts Miss Cover Missing
---------------------------------------
main.py 40 4 90% 20-22, 65
---------------------------------------
TOTAL 40 4 90%
覆盖率达 90%。未覆盖的 4 行说明:
- 第 20-22 行:
read_text函数中 GB18030 编码回退分支,测试用例均使用 UTF-8 编码文件,未触发此分支。 - 第 65 行:
if __name__ == "__main__"入口,单元测试通过直接调用main()函数测试,不经过命令行入口。
五、计算模块部分异常处理说明
1. 参数数量异常
设计目标:当命令行参数不等于 3 个时,打印用法提示并返回错误状态码 2,不产生异常退出。
测试样例:
def test_main_rejects_wrong_argument_count(self):
"""命令行参数数量错误时应返回错误状态。"""
self.assertEqual(main.main([]), 2)
对应场景:用户运行程序时未提供足够的参数,例如只输入了 python main.py 或 python main.py 原文.txt。程序不会崩溃,而是输出用法提示:
用法: python main.py 原文路径 抄袭文路径 答案路径
并返回状态码 2,便于脚本或评测系统判断执行失败的原因。
2. 空文本异常
设计目标:当原文或抄袭文为空时,不产生除零错误,返回合理的相似度值。
测试样例:
def test_empty_original_has_zero_similarity(self):
"""空原文与非空文本的相似度应为零。"""
self.assertEqual(main.calculate_similarity("", "论文内容"), 0.0)
对应场景:用户传入了一个空文件作为原文或抄袭文。程序通过以下逻辑避免除零错误:
if not original_ngrams and not copied_ngrams:
return 1.0 # 两个都为空,视作相同
if not original_ngrams or not copied_ngrams:
return 0.0 # 只有一个为空,完全不同
3. 编码异常
设计目标:当文件编码不是 UTF-8 时,自动尝试其他常见编码,避免因编码问题无法读取文件。
对应代码:
def read_text(path):
file_path = Path(path)
for encoding in ("utf-8-sig", "gb18030"):
try:
return file_path.read_text(encoding=encoding)
except UnicodeDecodeError:
continue
return file_path.read_text(encoding="utf-8")
对应场景:评测系统提供的文件可能使用 GB18030 编码(Windows 中文环境常见),程序会先尝试 UTF-8(带 BOM),失败后再尝试 GB18030,确保不同编码的文件都能正常读取。
六、代码质量分析
使用 Pylint 对全部三个 Python 文件进行代码质量检查:
python -m pylint main.py tests\test_main.py tools\profile_performance.py
结果:10.00 / 10,无任何警告。
所有函数和类都有完整的文档字符串,命名符合 PEP 8 规范,无未使用的变量或导入。
七、PSP表格(实际)
编码完成后,回顾实际耗时:
| PSP2.1 | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|
| Planning | ||
| · Estimate(估计这个任务需要多少时间) | 30 | 20 |
| Development | ||
| · Analysis(需求分析,包括学习新技术) | 60 | 50 |
| · Design Spec(生成设计文档) | 30 | 25 |
| · Design Review(设计复审) | 20 | 15 |
| · Coding Standard(代码规范) | 15 | 15 |
| · Design(具体设计) | 40 | 35 |
| · Coding(具体编码) | 120 | 150 |
| · Code Review(代码复审) | 30 | 25 |
| · Test(自我测试,修改代码,提交修改) | 90 | 120 |
| Reporting | ||
| · Test Report(测试报告) | 30 | 20 |
| · Size Measurement(计算工作量) | 15 | 10 |
| · Postmortem & Process Improvement Plan | 30 | 25 |
| 合计 | 510 | 525 |
事后总结
实际总耗时与预估接近,编码和测试阶段略超预期。主要原因是初次使用 Python 进行完整的项目开发,在环境搭建和工具使用上花了一些额外时间。性能优化阶段通过 cProfile 定位瓶颈并使用正则表达式替换逐字符循环,效果显著(耗时减少 77.5%),这一过程加深了对性能分析工具的理解。
八、Git提交记录
项目共进行 6 次提交,遵循功能阶段划分:
| 提交 | 说明 |
|---|---|
0f7d481 |
feat: 完成论文查重程序初版 |
643807d |
添加 .gitignore 文件 |
482ef64 |
test: 添加论文查重单元测试 |
dace4db |
perf: 优化文本清理并添加性能分析脚本 |
979dbef |
style: 完善代码文档并通过 Pylint 检查 |
57d7c9a |
chore: 添加论文查重测试数据文件 |
每次提交对应一个功能阶段完成节点:初版功能 → 项目配置 → 单元测试 → 性能优化 → 代码规范 → 测试数据。

浙公网安备 33010602011771号