第一次个人编程作业
第一次个人编程作业——论文查重
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | 计科24级56班 |
| 这个作业要求在哪里 | 第一次个人编程作业 |
| 这个作业的目标 | 完成一个论文查重程序,实践需求分析、设计、编码、测试、性能分析与代码质量检查等软件开发流程 |
| 学号 | 3124007214 |
| GitHub 仓库 | https://github.com/DexJocelyn/3124007214 |
一、PSP 表格
在开始编码前,我对项目各阶段所需时间进行了预估;项目完成后,再根据实际开发过程补充实际耗时。记录如下:
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 20 | 15 |
| · Estimate | · 估计这个任务需要多少时间 | 20 | 15 |
| Development | 开发 | 300 | 245 |
| · Analysis | · 需求分析(包括学习新技术) | 30 | 40 |
| · Design Spec | · 生成设计文档 | 15 | 10 |
| · Design Review | · 设计复审 | 10 | 5 |
| · Coding Standard | · 制定代码规范 | 10 | 5 |
| · Design | · 具体设计 | 30 | 25 |
| · Coding | · 具体编码 | 120 | 100 |
| · Code Review | · 代码复审 | 30 | 25 |
| · Test | · 测试、修改代码并提交 | 55 | 35 |
| Reporting | 报告 | 35 | 25 |
| · Test Report | · 测试报告 | 15 | 10 |
| · Size Measurement | · 计算工作量 | 5 | 5 |
| · Postmortem & Process Improvement Plan | · 事后总结并提出改进计划 | 15 | 10 |
| 合计 | 355 | 285 |
从结果来看,实际总耗时比预估少 70 分钟。编码和测试阶段比预想顺利,但需求分析实际使用了 40 分钟,比预估多 10 分钟。这说明我在开始项目前低估了理解输入输出要求、选择相似度算法以及设计异常处理所需的时间。今后进行类似任务时,我会为需求分析阶段预留更多时间,并在编码前先确定测试范围和性能指标。
二、项目需求分析
本项目需要实现一个能够计算两篇文本重复率的命令行程序。程序接收三个命令行参数:
- 原文文件的绝对路径;
- 抄袭版文件的绝对路径;
- 答案文件的绝对路径。
程序读取两个文本文件,计算它们的相似度,并将结果以保留两位小数的形式写入答案文件。例如:
python main.py "D:\\test\\orig.txt" "D:\\test\\copy.txt" "D:\\test\\ans.txt"
若计算结果为 0.614918...,则答案文件中写入:
0.61
除基本功能外,程序还需要考虑参数数量错误、输入文件不存在、文件编码不同、文本为空以及答案文件无法写入等情况。
三、计算模块接口的设计与实现
3.1 项目结构
项目采用简洁的模块化结构,核心程序和测试代码相互分离:
3124007214/
├── main.py
├── requirements.txt
├── PSP.md
├── testdata/
│ ├── orig.txt
│ └── copy.txt
├── teacher_samples/
│ ├── orig.txt
│ └── orig_0.8_*.txt
├── tests/
│ ├── conftest.py
│ ├── requirements-dev.txt
│ └── test_main.py
└── profiling/
├── before.prof
├── after.prof
├── before_snakeviz.png
└── after_snakeviz.png
程序运行部分只使用 Python 标准库,包括 sys、re、math 和 collections,评测环境不需要额外安装第三方运行依赖。pytest、pytest-cov 和 ruff 只用于开发阶段的测试与代码质量检查。
3.2 模块接口
| 函数 | 功能 |
|---|---|
read_text(path) |
读取文本文件,优先使用 UTF-8,解码失败时尝试 GBK |
clean_text(text) |
使用正则表达式去除标点、空格等非文本字符 |
split_grams(text, n=2) |
使用滑动窗口将文本划分为字符 n-gram,默认使用 2-gram |
count_grams(text, n=2) |
使用 Counter 统计各个二元组的出现次数 |
cosine_similarity(freq_a, freq_b) |
根据两个词频向量计算余弦相似度 |
calc_similarity(text_a, text_b) |
组织文本处理与相似度计算流程 |
write_answer(path, similarity) |
将相似度以两位小数写入答案文件 |
main() |
解析命令行参数并调用各个模块 |
模块之间的调用关系如下:
main()
├── read_text() × 2
├── calc_similarity()
│ ├── count_grams() × 2
│ │ ├── clean_text()
│ │ └── split_grams()
│ └── cosine_similarity()
└── write_answer()
3.3 核心算法
本项目采用“字符二元组词频 + 余弦相似度”的方法计算重复率,主要分为以下四步。
1. 文本清洗
首先使用正则表达式去除标点符号、空格和换行等干扰字符:
def clean_text(text):
return re.sub(r"[^\w]+", "", text)
例如,今天,天气!很好。 清洗后得到 今天天气很好。
2. 构造字符二元组
对清洗后的字符串使用长度为 2 的滑动窗口进行切分:
def split_grams(text, n=2):
return [text[i : i + n] for i in range(len(text) - n + 1)]
例如,字符串 ABCD 会被划分为 AB、BC、CD。使用字符二元组不依赖中文分词库,能够保留相邻字符信息,也避免了不同分词工具带来的结果差异。
3. 构造词频向量
程序使用 Counter 统计每个二元组出现的次数。两个文本中出现过的二元组共同构成向量空间,每个二元组的出现次数就是对应维度的值。
4. 计算余弦相似度
设两个文本的字符二元组词频向量分别为 A 和 B,程序使用余弦相似度衡量两个向量的接近程度,其计算公式为:
similarity(A, B) = (A · B) / (‖A‖ × ‖B‖)
其中,A · B 表示两个向量的点积,‖A‖ 和 ‖B‖ 分别表示两个向量的模。
核心实现如下:
def cosine_similarity(freq_a, freq_b):
dot = 0
for k, v in freq_a.items():
dot += v * freq_b.get(k, 0)
sum_sq_orig = 0
for v in freq_a.values():
sum_sq_orig += v * v
norm_orig = math.sqrt(sum_sq_orig)
sum_sq_copy = 0
for v in freq_b.values():
sum_sq_copy += v * v
norm_copy = math.sqrt(sum_sq_copy)
if norm_orig == 0 or norm_copy == 0:
return 0.0
return dot / (norm_orig * norm_copy)
四、计算模块接口部分的性能改进
4.1 性能分析方法
我先使用 Python 自带的 cProfile 生成性能数据,再使用 SnakeViz 对调用关系和各函数耗时进行可视化。性能分析使用教师样例中的较长文本,以便让程序热点更加明显。
优化前的分析命令示例:
python -m cProfile -o profiling/before.prof main.py teacher_samples/orig.txt teacher_samples/orig_0.8_add.txt profiling/ans_before.txt
snakeviz profiling/before.prof

优化前共发生 110028 次函数调用,总耗时约为 0.0376s。其中,split_grams() 的累计耗时约为 0.0242s,是最明显的性能热点。进一步观察调用结果可以发现,原实现先创建空列表,再在循环中反复调用 append():
def split_grams(text, n=2):
grams = []
for i in range(len(text) - n + 1):
grams.append(text[i : i + n])
return grams
4.2 优化方法
针对上述热点,我将循环和 append() 改为列表推导式:
def split_grams(text, n=2):
return [text[i : i + n] for i in range(len(text) - n + 1)]
该修改没有改变算法逻辑和输出结果,但减少了 Python 层的循环及方法调用开销。优化前后生成的答案文件内容一致,可以说明本次优化没有影响程序正确性。
4.3 优化结果

| 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| 总运行时间 | 0.0376s | 0.0212s | 下降约 43.7% |
| 函数调用次数 | 110028 | 5851 | 下降约 94.7% |
split_grams() 累计耗时 |
0.0242s | 0.0082s | 下降约 66.0% |
从结果可以看出,优化后总运行时间明显缩短,函数调用次数也大幅减少。split_grams() 仍然是文本处理过程中的主要耗时函数之一,但其累计耗时已从约 0.0242s 降至 0.0082s。这次分析让我认识到,性能优化不能只依靠主观判断,而应先通过分析工具定位热点,再进行有针对性的修改,并验证优化前后结果是否一致。
五、计算模块部分的单元测试
5.1 测试方法
本项目使用 pytest 编写单元测试,并使用 pytest-cov 统计语句覆盖率和分支覆盖率。测试命令如下:
python -m pytest -v
python -m pytest --cov=main --cov-branch --cov-report=term-missing
测试文件共设计了 19 个测试用例,覆盖正常情况、边界情况和异常情况。主要测试内容如下:
| 测试对象 | 测试内容 |
|---|---|
calc_similarity() |
相同文本、相似文本、空文本、单侧空文本、标点差异、完全不同文本 |
clean_text() |
验证标点和空格能否被正确清除 |
split_grams() |
验证普通字符串和长度不足 2 的字符串 |
count_grams() |
验证字符二元组及其词频统计结果 |
cosine_similarity() |
验证空向量不会引发除零错误 |
read_text() |
验证 UTF-8、GBK、文件不存在和第二次打开失败等路径 |
write_answer() |
验证保留两位小数以及无效输出路径 |
main() |
验证正常命令行流程、参数不足和参数过多 |
部分核心测试代码如下:
@pytest.mark.parametrize(
("text_a", "text_b", "expected"),
[
(ORIG, ORIG, 1.0),
(ORIG, COPY, 0.6149186938124421),
("", "", 0.0),
("", "任意内容", 0.0),
("今天,天气!", "今天天气", 1.0),
("abc", "xyz", 0.0),
],
)
def test_calc_similarity(text_a, text_b, expected):
result = main.calc_similarity(text_a, text_b)
assert result == pytest.approx(expected)
这里使用参数化测试以同一套逻辑覆盖多组输入,既减少了重复代码,也更直观地展示了相似度函数在不同场景下的预期行为。
5.2 覆盖率结果
测试覆盖了 main.py 中的正常分支、边界分支和异常分支,最终覆盖率达到 100%。入口保护语句 if __name__ == "__main__" 使用 # pragma: no cover 标记,因为其作用只是区分脚本运行和模块导入,具体的 main() 逻辑已经通过直接调用完成测试。


达到 100% 覆盖率并不意味着程序一定不存在缺陷,但它能够说明当前代码中的语句和主要分支均被测试执行。相比只检查一个正常样例,本项目还专门测试了空文本、缺失文件、GBK 编码、非法输出路径和错误参数数量等场景,从而提高了测试的有效性。
六、异常处理说明
6.1 命令行参数数量错误
程序要求恰好接收三个参数。当参数不足或过多时,打印正确用法并结束本次运行:
用法: python main.py <原文路径> <抄袭版路径> <答案输出路径>
对应测试分别模拟零个业务参数和多余参数,确认程序不会在参数错误时继续访问不存在的列表元素。
6.2 输入文件不存在或无法读取
read_text() 使用 try-except 捕获 OSError。如果文件不存在、路径错误或没有读取权限,程序会输出警告并返回空字符串,随后相似度计算结果为 0.00,不会直接崩溃。
except OSError as error:
print("警告:读取文件失败", path, error)
return ""
6.3 文件编码不一致
程序优先按 UTF-8 读取文件。若发生 UnicodeDecodeError,则尝试使用 GBK 重新读取,以兼容常见的中文文本编码。单元测试通过写入一个 GBK 编码的临时文件验证了该分支。
6.4 空文本与零向量
如果文本为空,或清洗后长度不足以构造字符二元组,得到的词频向量为空。此时向量模长为 0,程序直接返回 0.0,避免出现除零异常。
6.5 答案文件无法写入
当答案文件的父目录不存在或目标路径不可写时,write_answer() 捕获 OSError,输出“写入答案文件失败”的警告并返回 False。main() 只有在写入成功后才输出“答案已写入文件”,避免给出错误的成功提示。
七、代码质量分析
我使用 Ruff 对项目进行静态代码检查。Ruff 能够发现未使用的导入、不规范的代码格式、潜在错误以及部分不符合 Python 代码规范的问题。在质量分析阶段,我根据检查结果调整了切片表达式的空格格式,并补充了源码和测试文件末尾的换行,同时将 Ruff 加入开发依赖。相关修改被单独记录在 Git 提交中,使代码质量检查过程可追踪。
检查命令如下:
python -m ruff check .

通过静态检查可以在程序运行前发现一部分代码问题,但 Ruff 不能代替单元测试。因此本项目同时使用 Ruff 检查代码规范、使用 pytest 验证功能、使用 Coverage 检查测试覆盖范围,并使用 cProfile 与 SnakeViz 分析运行性能。
八、运行结果
使用项目自带的简单样例进行测试:
原文:
今天是星期天,天气晴,今天晚上我要去看电影。
抄袭版:
今天是周天,天气晴朗,我晚上要去看电影。
程序计算得到相似度约为 0.614918...,控制台显示重复率,并在答案文件中写入:
0.61
8.1 教师样例测试
本项目使用老师提供的测试文本进行功能测试和性能分析。其中,orig.txt 与 orig_0.8_add.txt 为普通 UTF-8 文本,程序计算得到的重复率为 0.90。另外几份样例包含 HTML 页面包装内容,直接作为纯文本参与计算会引入大量无关标签,影响相似度结果。这也说明当前程序对输入文件格式较为敏感,后续可以增加 HTML 正文提取功能,提高程序的鲁棒性。
整个程序无需第三方运行依赖,能够直接通过 Python 命令行执行。
九、Git 版本管理
本项目使用 Git 记录开发过程,主要提交阶段包括:
- 初始化项目和依赖说明;
- 完成“2-gram 词频 + 余弦相似度”核心功能;
- 添加测试数据;
- 完善单元测试并达到 100% 覆盖率;
- 使用 Ruff 进行代码质量分析;
- 添加优化前的性能分析结果;
- 优化 n-gram 生成过程并添加优化后的性能结果。
将功能实现、测试、质量检查和性能优化分别提交,能够使版本历史更加清晰,也便于在出现问题时定位改动范围。
十、总结与改进计划
通过本次个人编程作业,我完成了从需求分析、算法设计、代码实现到单元测试、代码质量检查和性能优化的完整流程。过去完成程序时,我更关注代码能否运行;这次作业让我认识到,一个较完整的软件项目还需要考虑异常输入、编码兼容、测试覆盖率、性能数据和版本管理。
在算法方面,我使用字符二元组和余弦相似度完成文本查重。该方法结构简单、无需第三方分词库,对局部增删和少量文字修改能够给出相似度结果。但它主要依据相邻字符是否相同,对大段落调换顺序、同义词替换以及语义相近但表述不同的文本识别能力有限。
后续可以从以下方面继续改进:
- 比较不同 n-gram 长度对准确率和运行时间的影响;
- 增加停用词处理或 TF-IDF 权重,降低高频但信息量较低的字符组合影响;
- 增加更多长文本、段落重排和同义改写测试,提高测试数据的代表性;
- 将文件读取失败与合法空文本区分开,使用更明确的返回值或自定义异常;
- 在继续优化前先建立稳定的性能基线,并多次运行取平均值,减少单次测量误差。
本次作业中,PSP 表格帮助我比较预估耗时和实际耗时,Coverage 帮助我发现未覆盖路径,Ruff 帮助我检查代码质量,SnakeViz 则让我能够根据真实调用耗时定位性能热点。通过这些工具,我对“可运行、可测试、可维护、可分析”的软件开发过程有了更加具体的认识。
浙公网安备 33010602011771号