第一次个人编程作业
作业 GitHub 链接:3124004161 — 论文查重
本项目代码初稿与文档整理使用了 AI 辅助。我在本机完成了运行验证、依赖配置调整、单元测试、覆盖率检查及性能分析。本文中的测试结果和截图来自本人 Windows 环境。
一、需求分析与运行环境
本次作业使用 Python 实现论文文本相似度计算。程序接收三个命令行参数,分别为原文文件、对比文件和答案文件的绝对路径,读取两篇文本后,将相似度保留小数点后两位写入答案文件。
1. 运行环境
| 项目 | 配置 |
|---|---|
| 操作系统 | Windows |
| Python | 3.9.13 |
| 单元测试框架 | unittest |
| 覆盖率工具 | coverage 7.10.7 |
| 代码质量工具 | Ruff 0.16.6 |
| 性能分析工具 | cProfile、SnakeViz 2.2.2 |
| 运行依赖 | Python 标准库,无第三方运行依赖 |
requirements.txt 说明主程序不需要第三方依赖,requirements-dev.txt 用于声明开发检查工具。
2. 运行方式
cd D:\3124004161
python main.py D:\3124004161\samples\orig.txt D:\3124004161\samples\orig_edit.txt D:\3124004161\samples\ans.txt
Get-Content .\samples\ans.txt
项目内置示例采用题目中的两段文本:
- 原文:今天是星期天,天气晴,今天晚上我要去看电影。
- 对比文:今天是周天,天气晴朗,我晚上要去看电影。
本机输出为:
0.61
这表示按本项目算法计算的文本相似度约为61%,并不等同于严格意义上的抄袭内容比例。

3. 输入输出约定
- 输入文本采用 UTF-8,兼容带 BOM 的 UTF-8。
- 三个文件参数均使用绝对路径。
- 答案输出范围采用0~1,保留两位小数。
- 输出文件的父目录需要已经存在。
- 规范化后两篇非空文本完全一致,返回1。
- 任意一篇规范化后为空,返回0,包括两篇均为空的情况。
4. 教师样例补充测试
使用教师提供的 orig.txt 作为原文,与 orig_0.8_add.txt 进行比较,程序输出为0.90。本次命令行运行耗时为0.059秒,包含Python启动、文件读取、相似度计算及结果写入。
另外四份文件 orig_0.8_del.txt、orig_0.8_dis_1.txt、orig_0.8_dis_10.txt 和 orig_0.8_dis_15.txt 的内容以 <!DOCTYPE html> 开头,包含GitHub网页HTML。因此,暂不将这四组得分作为论文相似度准确性的验证依据,保留原文件等待进一步确认。
上述测试证明新增样例能够正常完成文件输入输出,但没有标准答案可供核对,不能据此认定通过全部评测。
输出范围及空文本处理是本项目的实现约定,题目未明确的部分仍需与评测要求核对。
二、PSP表格
本项目采用的预估耗时合计为255分钟,实际耗时合计为240分钟。各父阶段为其子阶段耗时的小计,计算总耗时时不重复累加。
| PSP2.1 | 阶段 | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 10 | 0 |
| · Estimate | 估计任务时间 | 10 | 0 |
| Development | 开发 | 200 | 190 |
| · Analysis | 需求分析及开发环境排错 | 30 | 60 |
| · Design Spec | 理解并整理接口设计 | 15 | 10 |
| · Design Review | 设计复审 | 10 | 10 |
| · Coding Standard | 确认代码规范 | 5 | 5 |
| · Design | 学习算法及具体设计 | 20 | 15 |
| · Coding | 修改已有代码及依赖配置 | 60 | 20 |
| · Code Review | 阅读、复核代码 | 15 | 20 |
| · Test | 测试、覆盖率、性能分析及截图 | 45 | 50 |
| Reporting | 报告 | 45 | 50 |
| · Test Report | 整理测试与性能报告 | 20 | 35 |
| · Size Measurement | 统计代码及测试工作量 | 5 | 5 |
| · Postmortem & Process Improvement Plan | 总结和改进计划 | 20 | 10 |
| Total | 合计 | 255 | 240 |
其中,Analysis包含理解题目20分钟和工具安装、网络排错40分钟;Test包含测试与覆盖率25分钟、性能分析与截图25分钟。计划估计未单独计时,实际栏记为0。Coding对应已有代码与依赖配置的修改,不代表独立编写初稿。
三、计算模块接口的设计与实现
1. 代码组织
项目使用函数划分职责,没有额外定义业务类。
| 文件或目录 | 职责 |
|---|---|
| main.py | 命令行入口、文件处理与相似度计算 |
| tests/test_main.py | 自动化单元测试 |
| benchmark.py | 稠密参考实现、性能比较及分析 |
| samples/ | 示例输入输出 |
| reports/ | 测试、覆盖率和性能报告 |
| images/ | 博客截图 |
| PSP.md | 开发耗时记录 |

2. 核心函数及关系
| 函数 | 输入 | 输出或职责 |
|---|---|---|
| normalize(text) | 原始字符串 | 规范化后的字符串 |
| features(text) | 规范化字符串 | 字符片段词频 Counter |
| cosine(left, right) | 两个词频向量 | 余弦相似度 |
| similarity(original, candidate) | 两篇文章字符串 | 处理边界情况并返回相似度 |
| compare_files(original, candidate, output) | 三个 Path 对象 | 读取文件、计算并写入答案 |
| main(argv=None) | 命令行参数 | 参数检查、异常处理和退出码 |
程序首先检查参数数量与路径,再读取输入文件。similarity 首先调用 normalize 规范化两篇文本。如果任意一篇规范化后为空,则返回0;否则,如果两篇文本完全一致,则返回1;其余情况分别调用 features 提取特征,再调用 cosine 计算相似度。
纯计算函数不负责读写文件,因此可以直接使用字符串构造测试,不必每次创建文件。
3. 文本规范化
规范化分为三个步骤:
- 使用 Unicode NFKC 统一全半角等兼容字符。
- 使用
casefold()统一大小写。 - 使用
isalnum()保留字母和数字,过滤标点、空白等字符。中文汉字也会保留。
例如:
输入:AbC,123 中文
输出:abc123中文
这样可以减少大小写、空格和标点差异对相似度的影响。
4. 字符二元组与余弦相似度
本项目将相邻两个字符作为一个片段,并统计各片段的出现次数。
例如:
文本:ababa
片段:ab、ba、ab、ba
词频:ab出现2次,ba出现2次
对于只有一个有效字符的文本,退化为单字符特征。
得到词频向量后,计算余弦相似度:
相似度 = Σ(xᵢ × yᵢ) / [√Σ(xᵢ²) × √Σ(yᵢ²)]
以 abc 和 abd 为例:
- 第一篇的片段为
ab、bc。 - 第二篇的片段为
ab、bd。 - 共同片段只有
ab,点积为1。 - 两边向量模长均为√2。
因此,相似度为 1 / (√2 × √2) = 0.5。
5. 方法特点与局限
字符二元组保留了局部字符顺序,不需要外部分词词典。Counter以稀疏形式保存词频,只保存实际出现的片段。
但它仍是文本统计方法:
- 对同义替换和语义改写的识别能力有限。
- 重复同一段内容可能仍获得很高的相似度。
- 段落重排不一定明显降低得分。
- 过滤标点也会丢失部分原始文本信息。
设两篇文本总字符数为n,不同特征总数为k,在通常的哈希表平均复杂度假设下,时间复杂度约为O(n+k),空间复杂度约为O(n+k)。
四、性能对比与分析
1. 改进思路
稠密参考实现首先合并、排序特征集合,再构造两份补零后的词频列表,最后计算余弦相似度。
稀疏实现直接使用Counter:
- 省去词汇表排序。
- 省去两份稠密列表。
- 遍历较小的特征集合计算点积。
- 直接遍历各向量频数计算模长。
两种实现采用相同数学公式,脚本通过一致性断言检查结果。
这里是实现方式的对照实验,不将参考实现描述为本人先后独立开发的历史首版。性能分析与截图实际用时25分钟,计入PSP的Test阶段。
2. 测量方法
执行:
python benchmark.py
测试条件如下:
- 随机种子固定为3124004161。
- 两篇文本各150000个中文字符。
- 对比文本每隔10个字符随机替换一个字符,替换结果可能与原字符相同。
- 每种实现运行5次,取耗时中位数。
- 使用tracemalloc另外测量Python内存分配峰值。
3. 本机结果
| 指标 | 稠密参考实现 | 稀疏实现 |
|---|---|---|
| 耗时中位数(秒) | 0.2031207 | 0.0986331 |
| Python分配峰值(MiB) | 43.2961 | 31.2961 |
| 相似度 | 0.8264184895386419 | 0.8264184895386419 |
在本组测试中:
- 耗时减少约51.4%。
- Python分配峰值减少约27.7%。
- 两种实现的相似度结果一致。
该耗时不包含文件读写和解释器启动;Python分配峰值也不是整个进程的总内存。因此不能仅凭这组结果断言所有输入都满足评测限制。
4. 性能分析图
使用cProfile生成数据,再使用SnakeViz 2.2.2展示:
python -m snakeviz .\reports\optimized.prof

调用图显示,整体计算由文本规范化、余弦计算和特征统计三个主要部分组成。
| 函数 | 累计耗时(秒) |
|---|---|
| similarity | 约0.1955 |
| normalize | 约0.06715 |
| cosine | 约0.06254 |
| features | 约0.06232 |
similarity包含整个计算流程,所以累计耗时最大。三个具体阶段中,normalize略高,但没有单个阶段明显占据绝大部分时间。

表格按tottime降序排列:
tottime是函数自身耗时,不包含子函数。cumtime包含函数调用的子函数耗时。- 两者不能混用,父子函数的累计耗时也不能重复相加。
自身耗时最大的项为 main.py:12(<genexpr>),约0.03434秒,对应规范化过程中的字符过滤生成器。
启用cProfile后,记录调用信息本身有额外开销,因此分析时约0.196秒与未启用分析时的0.0986秒不属于相同测量条件。
本项目实际使用cProfile+SnakeViz进行分析,是否接受作为题目指定工具的Python替代方案,以课程要求为准。
五、单元测试与覆盖率
1. 测试思路
使用unittest设计了27个测试方法,结合白盒分支设计和黑盒数值验证。
| 测试类别 | 代表方法 | 验证内容 |
|---|---|---|
| 相同文本 | test_identical | 非空相同文本返回1 |
| 完全不同文本 | test_disjoint | 无共同特征时返回0 |
| 空文本 | test_empty | 空输入符合约定 |
| 空向量 | test_empty_vector | 不发生除零错误 |
| 规范化 | test_normalize、test_formatting | 大小写、全半角、标点处理 |
| 单字符 | test_single | 短文本特征分支 |
| 特征计数 | test_features | 重复片段的频数 |
| 插入与删除 | test_insertion、test_deletion | 已知数值及对称性 |
| 局部修改 | test_known_value | 手算案例结果为0.5 |
| 重复文本 | test_repeated | 记录重复内容下的算法行为 |
| 长文本 | test_long | 正常返回且结果在合理范围内 |
| 文件接口 | FileTests中的测试 | 参数、文件操作与异常处理 |
白盒测试覆盖了空文本早返回、相同文本早返回、单字符分支、较小特征集合交换和文件接口错误路径。
2. 计算模块测试代码
def test_known_value(self):
self.assertAlmostEqual(main.similarity('abc', 'abd'), 0.5)
def test_insertion(self):
self.assertAlmostEqual(
main.similarity('abc', 'abcd'),
2 / math.sqrt(6)
)
def test_deletion(self):
self.assertAlmostEqual(
main.similarity('abcd', 'abc'),
2 / math.sqrt(6)
)
这些测试使用可以手算的结果,避免仅检查“结果在0和1之间”而遗漏计算错误。
3. 测试结果
python -m unittest discover -s tests -v
本机普通测试结果:
Ran 27 tests in 0.113s
OK
启用coverage后再次运行,27个测试全部通过,用时0.214秒。两次运行条件不同,耗时分别记录。
4. 覆盖率
python -m coverage run --branch --source=main -m unittest discover -s tests -v
python -m coverage report -m
python -m coverage html -d reports/coverage
结果为:
Name Stmts Miss Branch BrPart Cover Missing
main.py 54 1 22 1 97% 81
TOTAL 54 1 22 1 97%

语句覆盖率为53/54,约98.15%;分支覆盖率为21/22,约95.45%。启用分支统计后,coverage将语句和分支覆盖情况合并计算,综合覆盖率为74/76,约97.37%,终端和网页显示为97%。

第80行黄色表示部分分支未覆盖,第81行红色表示语句没有计入本次执行统计:
if __name__ == "__main__":
sys.exit(main())
测试导入模块时不会进入这个分支。虽然独立子进程的命令行测试已经验证程序能够运行,但该子进程没有纳入此次coverage统计。
覆盖率说明测试执行到了哪些代码,不代表查重准确率。27个测试通过也不能替代官方样例和隐藏测试。
5. 代码质量检查
python -m ruff check main.py tests benchmark.py
结果:
All checks passed!

这表示所启用的Ruff规则未发现问题,不代表所有工具、所有规则都已检查。
六、异常处理说明
主入口捕获OSError、UnicodeError和ValueError,将错误信息写入stderr。
- 成功:退出码0。
- 参数数量错误:退出码2。
- 路径、文件读写或编码错误:退出码1。
程序不会把读取失败静默转换成空论文并写入正常答案。
以下代码摘录自FileTests。测试准备阶段创建临时原文、对比文和答案路径;invoke()调用程序入口并捕获stderr,测试完成后清理临时目录。
1. 参数数量错误
目标:提示正确用法,避免参数解包失败。
def test_arguments(self):
for args in [[], ['a'], ['a', 'b', 'c', 'd']]:
self.assertEqual(self.invoke(args), 2)
2. 使用相对路径
目标:遵守绝对路径接口要求。
def test_relative(self):
self.assertEqual(
self.invoke(['relative.txt', str(self.copy), str(self.out)]),
1
)
3. 输入文件不存在
目标:报告错误,不生成伪造答案。
def test_missing_input(self):
self.orig.unlink()
self.assertEqual(self.invoke(), 1)
self.assertFalse(self.out.exists())
4. 输入编码错误
目标:不静默丢弃无法解码的字节,避免错误文本影响结果。
def test_encoding(self):
self.copy.write_bytes(b'\xff\xfe\xfa')
self.assertEqual(self.invoke(), 1)
self.assertFalse(self.out.exists())
5. 输入路径为目录
目标:捕获文件读取异常。
def test_directory_input(self):
self.orig = self.root
self.assertEqual(self.invoke(), 1)
6. 输出父目录不存在
目标:明确报告写入失败,不擅自创建目录。
def test_missing_parent(self):
self.out = self.root / 'missing' / 'ans.txt'
self.assertEqual(self.invoke(), 1)
7. 输出路径为目录
目标:处理不合法的输出目标。
def test_directory_output(self):
self.out = self.root
self.assertEqual(self.invoke(), 1)
8. 输出覆盖输入文件
目标:保护原文和对比文内容。
def test_overwrite(self):
for source in (self.orig, self.copy):
self.assertEqual(
self.invoke([str(self.orig), str(self.copy), str(source)]),
1
)
self.assertEqual(
self.orig.read_text(encoding='utf-8-sig'),
'abc'
)
9. 输出是输入文件的硬链接
目标:路径名称不同但指向同一文件时,同样拒绝覆盖。
def test_hardlink(self):
os.link(self.orig, self.out)
self.assertEqual(self.invoke(), 1)
10. 写入权限不足
目标:捕获PermissionError,返回明确的错误状态。
def test_permission(self):
with patch.object(
Path,
'write_text',
side_effect=PermissionError('test')
):
self.assertEqual(self.invoke(), 1)
权限错误采用mock模拟,避免管理员权限等环境差异影响测试稳定性。
当前版本在校验或读取失败时不会改写答案;如果实际写入过程中发生磁盘错误,仍可能留下部分输出,尚未提供事务式写入保证。
七、环境问题与GitHub管理
1. 依赖兼容问题
最初固定的coverage版本未能在本机Python 3.9.13环境中安装。随后调整开发依赖的版本范围,由pip选择兼容版本,最终成功安装coverage 7.10.7和Ruff 0.16.6。之后安装SnakeViz 2.2.2用于性能可视化。
调整开发依赖后,pip选择了兼容的coverage 7.10.7和Ruff 0.16.6。随后安装SnakeViz 2.2.2用于性能可视化。

2. 网络问题
安装期间出现代理连接被拒绝和镜像403。临时调整当前终端的代理设置后,使用官方PyPI完成安装。
这一过程说明,应先确认解释器版本和网络配置,再排查依赖问题,避免将环境故障误认为程序错误。
3. 提交记录
代码保存在仓库的 3124004161 文件夹中:
3d06390:添加论文查重程序、单元测试及分析材料。80dee76:更新PSP实际耗时,补充覆盖率和性能分析截图。

上图记录的是首次上传后的仓库状态;其中4 Commits是整个仓库的累计记录,并非本作业的功能提交次数。
本次首次上传发生在完整版本经过本机验证之后,未做到每个功能完成即提交。第二次提交属于文档和截图更新,不能当作第二次功能开发记录。后续应在每个真实功能修改通过测试后及时提交,保留更完整的开发过程。
八、总结与改进计划
本次PSP记录的实际投入为240分钟,完成了命令行文件读写验证、27项单元测试、分支覆盖率检查、Ruff质量检查和性能对比。
将计算函数与文件操作分开,使测试能够直接验证算法结果。性能对比显示,在本次测试数据上,稀疏实现较稠密参考实现耗时降低约51.4%,Python内存分配峰值降低约27.7%,计算结果一致。
性能分析过程中,我学习了区分函数自身耗时、累计耗时与分析工具的额外开销。测试过程中,也认识到覆盖率反映的是代码执行情况,不能直接代表算法准确率。
目前已测试题目中的两段示例文本、项目自建测试,以及教师提供的新增样例。教师样例中的另外四份文件包含GitHub网页HTML,暂不用于评价论文查重准确性,后续取得正确文本后继续补测。
后续改进方向包括:
- 尽早检查Python版本、依赖兼容性和网络配置。
- 每完成一个功能并通过测试后及时提交代码。
- 补充正确的教师样例,记录不同增删改方式下的结果。
- 扩大完整命令行耗时测试,并测量进程总内存。
- 补充同义改写、段落重排和重复内容的针对性测试,分析算法适用范围。
本次验证尚不能证明程序能够通过全部隐藏测试,后续还需结合更多文本和明确的评测标准评价准确性。
浙公网安备 33010602011771号