第一次个人编程作业

GitHub 作业链接:paper-checker / 3224004157

第一次个人编程作业

学号:3224004157。课程作业:个人项目——论文查重

一、项目目标与开发准备

本项目实现一个 Python 命令行程序,接收原文、待比较文本和答案文件三个绝对路径,在答案文件中写入保留两位小数的重复率。入口文件为 main.py,运行时只使用标准库。

项目按基本功能、性能测量、优化和报告整理分别提交。初版开发时尚未连接远端仓库,提交先保存在本地。

目前使用自建文本进行测试,拿到班级群样例后再补充验证。

GitHub 仓库中的学号目录如下,包含程序入口、依赖说明、源代码、测试和报告。

GitHub 学号目录

二、计算模块的设计与实现

程序由命令行入口、文件读写层、相似度计算层和异常类组成:

接口 作用
main(argv) 检查三个参数,处理帮助和错误退出
compare_files(original, candidate, output) 检查路径、保护输入、读写指定文件
normalize_text(text) 统一全角/大小写、过滤空白与标点
cosine_similarity(left, right) 计算两个频数向量的余弦相似度
similarity(left, right) 提取单字符/双字符特征并加权

核心流程为:路径校验 → UTF-8 读取 → 文本标准化 → 字符片段频数统计 → 余弦计算 → 写入两位小数。

接口之间的调用关系如下:

main(argv)
  └─ compare_files(original, candidate, output)
       ├─ _validate_paths(...):检查绝对路径和输入保护
       ├─ _read_text(...):分别读取两份 UTF-8 文本
       ├─ similarity(left, right)
       │    ├─ normalize_text(...):清洗两份文本
       │    └─ cosine_similarity(...):计算单字符项和双字符项
       └─ output.write_text(...):写入两位小数

CheckerError 是程序预期异常的公共父类,下面区分路径错误、读取错误和写入错误;命令行入口统一捕获并显示错误。计算函数不直接访问文件,因此可以在不准备真实文件的情况下单独测试数学结果。路径检查和文件操作则使用临时文件测试。

相邻双字符特征能保留部分局部语序。例如“论文查重”分成“论文”“文查”“查重”。分别计算单字符和双字符余弦相似度,再使用 0.3 × 单字符余弦 + 0.7 × 双字符余弦 得到 0~1 的得分。

任一文本清洗后为空时返回 0;只有一个字符时采用单字符项。权重是本项目公开的设计选择,并未经过教师样例调参。该方法不依赖中文分词词典,运行时不联网,特征提取的时间随输入长度线性增长。

这种方法主要比较文字和局部语序,对同义改写的识别能力有限;频数分布相近的文章也可能得到较高分数。

三、性能瓶颈与改进

本轮代码完善与测试核对的个人投入分别为 40 分钟20 分钟,详见第六节 PSP 表。性能分析重点是找出余弦计算中的额外开销,并验证优化前后结果是否一致。

初版为余弦计算建立特征并集和两个补零列表。cProfile 显示该函数两次调用累计约 0.08593 秒,字典查找调用达到 289,632 次。

优化后,点积只遍历较小的频数字典,范数分别遍历已有频数,消除额外并集和稠密列表。公式保持不变。

在相同种子的 120,000 与 126,000 字符自建中文输入上:

指标 初版 优化后
核心计算中位耗时(5 次) 0.103169 秒 0.062176 秒
Python 分配峰值 48.49 MiB 28.49 MiB
含进程启动/文件读写的命令行中位耗时 0.135066 秒 0.092911 秒
得分 0.875756509005383 0.875756509005383

下列函数图由 SnakeViz 展示,分别对应初版和优化版运行生成的 cProfile 报告。

优化前的函数调用图与耗时排名:

优化前函数调用图

优化前函数耗时排名

优化后的函数调用图与耗时排名:

优化后函数调用图

优化后函数耗时排名

图中的 tottime 是函数自身耗时,cumtime 包含子函数调用时间;排名按 tottime 降序展示。

优化后,余弦计算累计约 0.02593 秒;自耗时最大的函数变成频数统计使用的 _collections._count_elements,约 0.02214 秒。图上的追踪耗时包含分析工具开销,与未开启分析工具的计时分开统计。

另一组原文 100 万字符、待比较文本 105 万字符的实际命令行检查耗时约 0.764 秒,最大常驻内存约 300 MiB。该结果只代表这台电脑和这组自建输入,不代表老师的隐藏测试。

经向老师确认,本次 Python 作业可以使用 cProfile/SnakeViz 性能图替代页面列出的 VS 2017/JProfiler 图。

四、单元测试与覆盖率

优化后执行 52 项测试全部通过,覆盖正常输入、边界条件、异常分支和真实命令行调用。固定随机种子的额外检查验证相似度对称性、范围和优化前后公式等价性。

测试首先按代码中的条件分支进行白盒设计,再补充等价类、边界值和文件接口测试:

被测函数/分支 构造数据的方法 检查目标
normalize_text 全角字母、大小写、纯标点、组合重音 字符标准化、保留与过滤分支
similarity 空文本分支 双空、原文空、候选空、清洗后为空 返回 0,不把无有效内容当作重复
similarity 相同文本分支 完全相同或仅有排版差异 清洗后相同返回 1
similarity 单字符分支 “甲”与“甲乙” 采用单字符余弦,避免不存在的双字符项
cosine_similarity 零向量、交集为空、两边特征数量不同 分母保护和遍历较小字典的分支
compare_files 临时 UTF-8/BOM 文件、输入输出路径别名 两位小数、错误分类、输入保护
main 参数过少/过多、帮助参数、正常绝对路径 成功、参数错误和文件错误退出码

这些数据覆盖了正常和异常控制路径。150 组随机文本检查对称性与数值范围,100 组随机频数向量与独立稠密公式比较,补充验证优化未改变数学结果。它们仍不能代替老师的 18 个隐藏测试点或真实论文语料的准确率评估。

一个可以手算预期值的例子:abcabd 的单字符余弦为 2/3,双字符余弦为 1/2,所以加权结果为 0.55:

def test_hand_calculated_weighted_score(self):
    self.assertAlmostEqual(similarity("abc", "abd"), 0.55)

程序运行时代码共 93 条可执行语句、26 个分支,行与分支覆盖率均为 100%。覆盖范围不包括测试自身和开发工具,覆盖率也不能代替算法准确性评估。

测试覆盖率

Ruff 代码规则检查和格式检查通过。原始输出:测试日志覆盖率摘要代码质量检查

五、异常处理

异常 设计目标 一个对应测试
InvalidPathError 拒绝相对路径,阻止答案覆盖输入 test_answer_cannot_overwrite_original
TextReadError 清晰说明文件缺失、无效 UTF-8 或读取失败 test_invalid_utf8_does_not_replace_previous_answer
ResultWriteError 说明输出父目录不存在或没有写入权限 test_output_parent_must_exist

以下三个示例均来自 文件接口测试。测试的 setUp 会在临时文件夹写入原文 abc、待比较文本 abd,预期正常结果为 0.55self.compare() 调用三个临时文件路径对应的 compare_files

路径错误:答案路径与原文相同

InvalidPathError 用于拒绝不合规路径和危险的输入输出别名。此例将原文同时作为答案路径,必须报错并保留原文内容。

def test_answer_cannot_overwrite_original(self):
    with self.assertRaises(InvalidPathError):
        compare_files(self.original, self.candidate, self.original)
    self.assertEqual(self.original.read_text(), "abc")

读取错误:待比较文件不是 UTF-8

TextReadError 将文件缺失、读取权限问题和无效编码转换为明确的输入错误。此例构造非法 UTF-8 字节,并验证错误发生后旧答案仍然保留。

def test_invalid_utf8_does_not_replace_previous_answer(self):
    self.candidate.write_bytes(b"\xff\xfe\x00\x00")
    self.output.write_text("previous", encoding="utf-8")
    with self.assertRaises(TextReadError):
        self.compare()
    self.assertEqual(self.output.read_text(), "previous")

写入错误:答案的父文件夹不存在

ResultWriteError 用于报告答案文件不能写入的场景。程序只使用指定的输出路径,不自动猜测其他位置;此例验证父文件夹缺失时给出该异常。

def test_output_parent_must_exist(self):
    with self.assertRaises(ResultWriteError):
        compare_files(self.original, self.candidate, self.root / "missing" / "out.txt")

上述三类异常均由命令行入口捕获并返回退出码 1,具体原因写入标准错误;参数数量不正确时返回 2。

六、PSP 与过程记录

本轮个人复现与完善记录

本轮个人复现与完善预估 200 分钟,实际投入 210 分钟。各阶段用时如下。

PSP2.1 计划工作 预估耗时(分钟) 实际耗时(分钟)
Planning / Estimate 确定本轮复现目标和时间安排 10 15
Development / Analysis 阅读要求,理解输入输出、余弦相似度和算法限制 25 30
Development / Design Spec 对照代码梳理模块职责和数据流 15 20
Development / Design Review 检查空文本、单字符、异常路径等设计 10 5
Development / Coding Standard 熟悉命名、注释和 Ruff 检查规则 5 5
Development / Design 设计补充样例或有实际需要的改进 20 25
Development / Coding 根据本轮发现的问题完善代码或测试 50 40
Development / Code Review 检查自己的改动及其影响 15 10
Development / Test 运行测试、演示与性能分析,核对结果 25 20
Reporting / Test Report 根据实际复现结果完善博文和截图 10 15
Reporting / Size Measurement 统计本轮实际修改的内容和工作量 5 10
Reporting / Postmortem 写下实际理解、问题及改进计划 10 15
合计 200 210

七、功能划分与提交记录

阶段 内容 提交
基本功能 文件接口、字符片段相似度、异常处理和初版测试 15d1de1
基线测量 保存初版性能数据与测量脚本 a9af0f9
性能改进 稀疏余弦计算、回归验证、长输入和覆盖率检查 bf9e75a
报告整理 测试、性能截图和说明材料 6119278

这些阶段在本地按顺序提交;由于仓库与认证稍后完成,远端于 2026-09-11 晚间统一接收。本地 commit 和远端上传的时间分别保留。后续实质改动将在验证后及时提交并同步。

八、总结与后续工作

遇到的问题与理解过程

这次理解算法时,我最容易卡住的是文字与数字之间的对应关系。虽然知道程序要计算相似度,但一开始不太明白字符频数为什么能组成向量,以及两个向量应该怎样比较。通过具体例子的说明,我理解了每个位置必须代表同一种字符。例如比较 abcabd,按照 a、b、c、d 排列,两个向量分别是 [1,1,1,0][1,1,0,1]。某种字符没有出现时记为 0,不能直接把两个字典里的频数按存放顺序相乘。

另一个疑问是为什么不直接按字符位置比较。在句首增加一个字,后面的对应位置就会整体偏移,但文本内容可能没有太大变化。字符频数可以减少这种位置偏移的影响,不过也会丢失顺序信息。例如“甲乙丙丁”和“丁丙乙甲”的单字符频数完全相同,单字符余弦得分为 1;它们的相邻双字符却没有重合,双字符得分为 0,按本项目的权重计算后是 0.30。这让我理解了为什么同时使用单字符和双字符:前者比较字符分布,后者补充部分局部顺序信息。

空文本和单字符的处理也让我有些困惑。后来我理解到,空文本没有有效频数,余弦公式的分母可能为零;只有一个字符时,又无法形成相邻双字符。因此,本项目将清洗后为空的输入判为 0,单字符情况只计算单字符项。这些情况需要明确规定,不能直接套用一般流程。

对于测试,我之前不太清楚应该怎样判断一个相似度数值是否正确。abcabd 的例子比较直观:单字符余弦为 2/3,双字符余弦为 1/2,加权结果为 0.3 × 2/3 + 0.7 × 1/2 = 0.55。这个结果可以独立手算,并与已有测试核对。除此之外,还需要检查增删内容、仅改变排版、空输入和文件异常等情况。输出一个小数只能说明程序完成了计算,不能单独证明计算正确。

性能优化方面,我主要不理解省略补零向量后为什么还能得到相同结果。初版先构造特征并集,再建立两个列表;优化版直接使用频数字典,点积只遍历较小的字典,未出现的特征按 0 处理,两个向量的长度则分别由各自频数计算。缺失特征对应的零项不会影响点积和向量长度,所以省略的是中间数据,计算公式保持一致。已有测试还用 100 组随机频数向量与独立的列表公式比较,验证两种实现的结果。

本轮整理内容与收获

本轮我主要补充了对算法步骤、边界情况和测试依据的理解,并把上述疑问及理解过程整理进博文。项目已有的测试和性能报告用于说明程序的验证结果:52 项测试通过,同一组自建输入下,核心计算中位耗时从约 0.103 秒降到 0.062 秒,得分保持一致。

通过这次梳理,我认识到,查重结果与选取的文本特征直接相关。单字符、双字符和权重的选择都会影响得分,当前的 30% 与 70% 是本项目的设计选择,并没有证明是最优比例。相似度也不能直接当成准确的抄袭比例,尤其是同义替换和较大幅度改写,仍可能超出字符统计方法的识别能力。

我也更清楚测试与性能分析各自的作用:测试需要覆盖正常、边界和异常情况,优化需要同时检查耗时与结果是否变化。老师已同意本次 Python 作业使用 cProfile/SnakeViz 性能图。后续取得老师提供的样例后还需补测;现有测试通过和覆盖率达到 100%,不能代替真实语料和隐藏测试的验证。

参考资料

posted @ 2026-09-12 23:51  wcy11  阅读(27)  评论(0)    收藏  举报