第一次个人编程作业

作业 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%,并不等同于严格意义上的抄袭内容比例。

02_run

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 开发耗时记录

01_project

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. 文本规范化

规范化分为三个步骤:

  1. 使用 Unicode NFKC 统一全半角等兼容字符。
  2. 使用 casefold() 统一大小写。
  3. 使用 isalnum() 保留字母和数字,过滤标点、空白等字符。中文汉字也会保留。

例如:

输入:AbC,123 中文
输出:abc123中文

这样可以减少大小写、空格和标点差异对相似度的影响。

4. 字符二元组与余弦相似度

本项目将相邻两个字符作为一个片段,并统计各片段的出现次数。

例如:

文本:ababa
片段:ab、ba、ab、ba
词频:ab出现2次,ba出现2次

对于只有一个有效字符的文本,退化为单字符特征。

得到词频向量后,计算余弦相似度:

相似度 = Σ(xᵢ × yᵢ) / [√Σ(xᵢ²) × √Σ(yᵢ²)]

abcabd 为例:

  • 第一篇的片段为 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

08_snakeviz_graph

调用图显示,整体计算由文本规范化、余弦计算和特征统计三个主要部分组成。

函数 累计耗时(秒)
similarity 约0.1955
normalize 约0.06715
cosine 约0.06254
features 约0.06232

similarity包含整个计算流程,所以累计耗时最大。三个具体阶段中,normalize略高,但没有单个阶段明显占据绝大部分时间。

09_snakeviz_table

表格按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%

06_coverage_summary

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

07_coverage_detail

第80行黄色表示部分分支未覆盖,第81行红色表示语句没有计入本次执行统计:

if __name__ == "__main__":
    sys.exit(main())

测试导入模块时不会进入这个分支。虽然独立子进程的命令行测试已经验证程序能够运行,但该子进程没有纳入此次coverage统计。

覆盖率说明测试执行到了哪些代码,不代表查重准确率。27个测试通过也不能替代官方样例和隐藏测试。

5. 代码质量检查

python -m ruff check main.py tests benchmark.py

结果:

All checks passed!

03_coverage_ruff

这表示所启用的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用于性能可视化。

04_tools

2. 网络问题

安装期间出现代理连接被拒绝和镜像403。临时调整当前终端的代理设置后,使用官方PyPI完成安装。

这一过程说明,应先确认解释器版本和网络配置,再排查依赖问题,避免将环境故障误认为程序错误。

3. 提交记录

代码保存在仓库的 3124004161 文件夹中:

  • 3d06390:添加论文查重程序、单元测试及分析材料。
  • 80dee76:更新PSP实际耗时,补充覆盖率和性能分析截图。

05_github

上图记录的是首次上传后的仓库状态;其中4 Commits是整个仓库的累计记录,并非本作业的功能提交次数。

本次首次上传发生在完整版本经过本机验证之后,未做到每个功能完成即提交。第二次提交属于文档和截图更新,不能当作第二次功能开发记录。后续应在每个真实功能修改通过测试后及时提交,保留更完整的开发过程。

八、总结与改进计划

本次PSP记录的实际投入为240分钟,完成了命令行文件读写验证、27项单元测试、分支覆盖率检查、Ruff质量检查和性能对比。

将计算函数与文件操作分开,使测试能够直接验证算法结果。性能对比显示,在本次测试数据上,稀疏实现较稠密参考实现耗时降低约51.4%,Python内存分配峰值降低约27.7%,计算结果一致。

性能分析过程中,我学习了区分函数自身耗时、累计耗时与分析工具的额外开销。测试过程中,也认识到覆盖率反映的是代码执行情况,不能直接代表算法准确率。

目前已测试题目中的两段示例文本、项目自建测试,以及教师提供的新增样例。教师样例中的另外四份文件包含GitHub网页HTML,暂不用于评价论文查重准确性,后续取得正确文本后继续补测。

后续改进方向包括:

  • 尽早检查Python版本、依赖兼容性和网络配置。
  • 每完成一个功能并通过测试后及时提交代码。
  • 补充正确的教师样例,记录不同增删改方式下的结果。
  • 扩大完整命令行耗时测试,并测量进程总内存。
  • 补充同义改写、段落重排和重复内容的针对性测试,分析算法适用范围。

本次验证尚不能证明程序能够通过全部隐藏测试,后续还需结合更多文本和明确的评测标准评价准确性。

posted on 2026-09-13 16:08  G1ngk0  阅读(24)  评论(0)    收藏  举报