论文查重项目作业
第一次个人编程作业——论文查重项目
| 这个作业属于哪个课程 | https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS |
|---|---|
| 这个作业要求在哪里 | https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS/homework/15693 |
| GitHub 仓库地址 | https://github.com/Yuki2024-h/Yuki2024-h |
| 学号 | 3224004078 |
项目按拆成三个模块:命令行入口(main.py)→ 文件读写(file_reader.py / answer_writer.py)→ 查重算法(duplicate.py)。三个模块之间只通过函数参数和返回值通信,互不依赖对方内部实现。
一、PSP 记录
| PSP2.1 | Personal Software Process Stages | 预估耗时(min) | 实际耗时(min) |
|---|---|---|---|
| Planning | 计划 | 30 | 25 |
| · Estimate | · 估计这个任务需要多少时间 | 30 | 25 |
| Development | 开发 | 380 | 200 |
| · Analysis | · 需求分析(包括学习新技术) | 50 | 45 |
| · Design Spec | · 生成设计文档 | 30 | 25 |
| · Design Review | · 设计复审 | 20 | 15 |
| · Coding Standard | · 代码规范 | 10 | 10 |
| · Design | · 具体设计 | 50 | 40 |
| · Coding | · 具体编码 | 120 | 95 |
| · Code Review | · 代码复审 | 30 | 30 |
| · Test | · 测试(自我测试、修改代码) | 70 | 60 |
| Reporting | 报告 | 85 | 80 |
| · Test Report | · 测试报告 | 40 | 35 |
| · Size Measurement | · 计算工作量 | 15 | 15 |
| · Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 30 | 30 |
程序的运行入口与结果(python main.py ../samples/orig.txt ../samples/orig_0.8_add.txt ../result/result.txt):

输出的 result.txt 内容为 orig.txt orig_0.8_add.txt 0.91,即原文与 0.8 倍加噪文本的重复率为 0.91,符合预期。
二、计算模块接口的设计与实现
1. 代码组织
项目的函数式接口:命令行解析、文件读写、查重计算三段彼此分离,其中计算模块对外只暴露 calculate_repeat_rate 一个入口,且不直接访问文件
3224004078/
├── paper_check/ # 源码
│ ├── main.py # 命令行入口,串联三个模块
│ ├── file_reader.py # 双文件读取 + 参数校验
│ ├── duplicate.py # 文本预处理、2-gram 切分、重复率计算(含进阶版算法)
│ └── answer_writer.py # 结果文件写入
├── paper_check_test/ # 单元测试
│ ├── conftest.py # 把项目根目录注入 sys.path
│ ├── pytest.ini # 固定 rootdir
│ ├── reader_test.py # 5 个用例
│ ├── duplicate_test.py # 23 个用例
│ └── writer_test.py # 4 个用例
├── samples/ # 测试样例文本
└── result/ # 结果输出目录
2. 模块与函数接口
| 所属文件 | 模块 / 函数 | 输入 | 输出 | 主要功能 |
|---|---|---|---|---|
main.py |
main() |
sys.argv 中的 3 个绝对路径 |
进程退出码 | 校验参数数量,依次调用读文件、查重、写结果三个接口 |
file_reader.py |
read_two_source_files(orig_file_path, copy_file_path) |
两个文件路径 str |
tuple[str, str]:原文文本、抄袭文本 |
以 UTF-8 批量读取两个文件,为算法层提供纯文本数据 |
file_reader.py |
read_single_file(file_path)(内部函数) |
单个文件路径 str |
文件文本 str |
封装单文件读取,统一把四类读取异常转换为带路径信息的自定义提示 |
answer_writer.py |
write_result_file(output_path, orig_file_path, copy_file_path, repeat_rate) |
输出路径、两个源文件路径、重复率 float |
无(写文件) | 取源文件 basename、重复率保留两位小数,写入一行三列结果;校验输出目录是否存在 |
duplicate.py |
preprocess_text(text) |
原始文本 str |
净化后的纯文本 str |
去除全部空白字符(空格 / 换行 / 制表符),规避无效字符干扰 |
duplicate.py |
get_ngram_list(text, n=2) |
净化文本、切分粒度 n |
list[str]:全部分片(含重复) |
生成连续 n-gram 分片;对文本长度不足 n 的边界单独返回 |
duplicate.py |
calculate_repeat_rate(original_text, copy_text) |
原文、抄袭文本 str |
[0, 1] 内的 float |
计算模块的主接口:预处理 → 切分 → 匹配统计 → 返回重复率,内部含多重边界保护 |
duplicate.py |
normalize_text(text) |
原始文本 str |
规范化文本 str |
进阶版规范化:NFKC 全半角统一、英文小写、剥离标点,并区分中英文的空白语义 |
duplicate.py |
tokenize(text) |
规范化文本 str |
list[str]:特征片段 |
进阶版分词:连续英文/数字按整词切分,连续中文按 2-gram 切分 |
duplicate.py |
calculate_repeat_rate_enhanced(original_text, copy_text) |
原文、抄袭文本 str |
[0, 1] 内的 float |
进阶版算法:次数感知的多重集合匹配,按 min(原文次数, 抄袭次数) 统计命中,详见第四节 |
三、计算模块接口部分的性能改进
1. 性能瓶颈定位
V1 版本使用了 List 存储分片、并用 in 做成员查找:
match_count = 0
for gram in orig_grams:
if gram in copy_grams: # copy_grams 是 list
match_count += 1
list.__contains__ 是线性扫描,最坏情况要把 copy_grams 走一遍。外层还要遍历 orig_grams,因此整体复杂度是 O(n·m)(n、m 分别为两段文本的分片数),在长文本上会明显劣化。这正是计算模块的性能瓶颈所在。
2. 实测数据
用 time.perf_counter() 对同一份输入分别计时
| 文本字符数 | V1(List 查找) | V2(Set 查找) | |
|---|---|---|---|
| 2 000 | 29.9 ms | 0.63 ms | |
| 5 000 | 256.5 ms | 1.84 ms | |
| 10 000 | 1 331.6 ms | 4.44 ms | |
| 20 000 | 4 903.0 ms | 9.98 ms |
3. 改进方案
把用于查找的容器从 List 换成 Set:
# 改进前:线性查找
copy_grams = get_ngram_list(copy_clean, 2)
if gram in copy_grams: # O(m)
# 改进后:哈希查找
copy_grams = set(get_ngram_list(copy_clean, 2))
if gram in copy_grams: # O(1) 均摊
四、计算模块接口的算法改进
这一节把查重函数升级:规范化 → 分词 → 次数感知匹配。
1. 要解决的三个问题
| 问题 | 原实现的结果 | 进阶版的结果 |
|---|---|---|
| 标点干扰 | "床前明月光," vs "床前明月光" → 0.8 |
1.0 |
| 中英文空白语义被混为一谈 | hello world vs helloworld → 1.0(误判为完全相同) |
0.0 |
| 重复片段被重复计数 | ab ab ab vs ab → 0.6("ab"只出现 1 次,却按原文出现次数全部算命中) |
1/3(按 min(原文次数, 抄袭次数) 只计 1 次) |
2. 实现
① 规范化 normalize_text:
def normalize_text(text: str) -> str:
text = unicodedata.normalize("NFKC", text).lower()
# 中文之间的空白与标点没有分词意义,直接删除,避免"床前\n明月光"被拆成两段
text = re.sub(rf"(?<=[{CJK_RANGE}])[^0-9a-z{CJK_RANGE}]+(?=[{CJK_RANGE}])", "", text)
# 其余标点/空白统一折叠为单个空格:英文单词边界得以保留
return re.sub(rf"[^0-9a-z{CJK_RANGE}]+", " ", text).strip()
② 分词 tokenize:
def tokenize(text: str) -> list[str]:
tokens = []
for chunk in re.findall(rf"[0-9a-z]+|[{CJK_RANGE}]+", text):
# 英文单词整体作为一个特征,保留语义边界;单字中文本身即一个片段
if chunk[0].isascii() or len(chunk) == 1:
tokens.append(chunk)
else:
tokens.extend(chunk[i:i + 2] for i in range(len(chunk) - 1))
return tokens
用一条正则把文本切成"连续英文/数字"与"连续中文"两类块,再分别处理:英文整词保留(hello world → ["hello","world"]),中文拆 2-gram(床前明月光 → ["床前","前明","明月","月光"])。
③ 次数感知匹配 calculate_repeat_rate_enhanced:
orig_counter = Counter(orig_tokens)
copy_counter = Counter(copy_tokens)
matched = sum(min(count, copy_counter.get(token, 0)) for token, count in orig_counter.items())
return matched / len(orig_tokens)
3. 效果
准确性。 样例文件名为 orig_0.8_add.txt,
| 算法 | 输出重复率 | 与标称 0.80 的偏差 |
|---|---|---|
| 原算法 | 0.9097 | 高估约 0.11 |
| 进阶版 | 0.7965 | 偏差约 0.004 |
| 文本字符数 | V1(List) | 进阶版 | |||
|---|---|---|---|---|---|
| 2 000 | 32.5 ms | 1.64 ms | |||
| 5 000 | 275.8 ms | 3.92 ms | |||
| 10 000 | 1 161.5 ms | 8.60 ms | |||
| 20 000 | 4 374.6 ms | 16.89 ms |
五、计算模块部分单元测试展示
1. 测试组织与运行方式
三个模块对应三个测试文件,共 32 个用例:
cd paper_check_test
python -m pytest
2. 读文件模块(reader_test.py,5 个用例)
| 测试函数名 | 测试场景 | 预期结果 |
|---|---|---|
test_read_two_files_success |
原文、抄袭文件均存在,UTF-8 正常文本 | 成功返回两段文本,内容与写入一致 |
test_read_two_files_first_not_found |
第一个原文文件不存在,第二个正常 | 抛出 FileNotFoundError |
test_read_two_files_permission |
文件存在但无读取权限 | 抛出 PermissionError,异常信息含"权限不足" |
test_read_two_files_encode_error |
文件不是合法 UTF-8(文件损坏) | 抛出 IOError,携带编码异常提示 |
test_main_entry_wrong_arg_count |
命令行参数数量不等于 4 | 打印用法提示,SystemExit.code == 1 |
3. 写文件模块(writer_test.py,4 个用例)
| 测试函数名 | 测试场景 | 边界类型 | 预期结果 |
|---|---|---|---|
test_write_result_normal_create_file |
输出文件不存在 | 正向边界:新建 | 自动创建文件,写入一行三列,文件名只保留 basename,重复率保留 2 位小数并四舍五入(0.3456 → 0.35) |
test_write_result_overwrite_exist_file |
输出文件已存在 | 正向边界:覆盖 | 原有内容被覆盖,只保留新结果 |
test_write_result_permission_error |
目标路径无写入权限 | 异常边界:权限 | 抛出 PermissionError,信息含"权限不足,无法写入输出文件" |
test_write_result_target_dir_not_exist |
输出文件所在文件夹不存在 | 异常边界:目录缺失 | 抛出 IOError,信息含"输出文件所在目录不存在" |
4. 查重模块(duplicate_test.py,23 个用例)
4.1 原算法(12 个用例)
三个函数各自覆盖"正向 + 边界":
| 被调用函数 | 测试函数名 | 测试场景 | 预期结果 |
|---|---|---|---|
preprocess_text |
test_preprocess_normal |
含空格、换行、制表符 | 空白全部清除,返回连续纯文本 |
test_preprocess_all_blank |
只有空格、换行 | 返回空字符串 | |
test_preprocess_no_blank |
无任何空白字符 | 文本原样返回 | |
get_ngram_list |
test_get_ngram_normal |
长度 > 2,n = 2 | 生成全部连续 2 字片段(abcd → ["ab","bc","cd"]) |
test_get_ngram_short |
长度 = 1,n = 2 | 返回单字符列表 ["啊"] |
|
test_get_ngram_empty |
空字符串 | 返回空列表 | |
calculate_repeat_rate |
test_calc_full_same |
两段文本完全一致 | 重复率 = 1.0 |
test_calc_no_overlap |
完全无重合片段 | 重复率 = 0.0 | |
test_calc_partial_overlap |
部分重合 | 0 < 重复率 < 1 | |
test_calc_origin_empty |
原文清洗后为空 | 返回 0.0(不除零崩溃) | |
test_calc_copy_empty |
抄袭文本清洗后为空 | 返回 0.0 | |
test_calc_single_char |
两段文本均为单字符 | 相同返回 1.0,不同返回 0.0 |
设计上刻意覆盖了三类容易出错的输入:空文本、全空白文本、长度不足 n 的极短文本。
4.2 进阶版算法(11 个用例)
| 被调用函数 | 测试函数名 | 测试场景 | 预期结果 |
|---|---|---|---|
normalize_text |
test_normalize_fullwidth_and_case |
全角字母数字、全角空格 | 统一为 abc 123 |
test_normalize_cjk_cross_newline |
中文之间夹换行与标点 | 分隔符被删除,句子连成一段 | |
tokenize |
test_tokenize_english_word_boundary |
hello world |
["hello","world"],整词保留 |
test_tokenize_chinese_ngram |
床前明月光 |
["床前","前明","明月","月光"] |
|
test_tokenize_mixed |
python很好用(中英混排) |
["python","很好","好用"] |
|
calculate_repeat_rate_enhanced |
test_enhanced_full_same |
两段文本完全一致 | 重复率 = 1.0 |
test_enhanced_ignore_punctuation_and_blank |
仅标点、空白差异 | 重复率 = 1.0 | |
test_enhanced_english_word_boundary |
hello world vs helloworld |
< 1.0(不再误判为相同) | |
test_enhanced_count_aware |
原文 ab ab ab,抄袭 ab |
1/3(只命中 1 次而非 3 次) | |
test_enhanced_no_overlap |
完全无重合片段 | 重复率 = 0.0 | |
test_enhanced_empty_boundary |
一侧为纯空白或纯标点 | 返回 0.0,不除零 |

六、计算模块部分异常说明
底层捕获具体异常类型,转换成带有出错文件路径 / 目录路径的新异常向上抛,由 main() 集中捕获并打印,最后以退出码 1 结束。
1. 参数异常
| 异常场景 | 处理方式 |
|---|---|
| 命令行参数数量不等于 3 | 打印错误信息、标准用法提示及实际传入的参数个数,sys.exit(1) 终止 |
if len(sys.argv) != 4: # argv[0] 是程序名,实际参数需为 3 个
print("参数数量错误!")
print("用法: python main.py [原文路径] [抄袭文件路径] [输出文件路径]")
print(f"你输入了 {len(sys.argv)-1} 个参数,需要3个参数")
sys.exit(1)
2. 文件读取异常
| 异常类型 | 触发场景 | 处理方式(输出提示) |
|---|---|---|
FileNotFoundError |
原文 / 抄袭文件路径错误、文件丢失 | 错误:文件不存在 -> {file_path} |
PermissionError |
系统权限限制,无法读取目标文件 | 错误:权限不足,无法读取文件 -> {file_path} |
UnicodeDecodeError |
非 UTF-8 编码、文件损坏、非文本文件 | 错误:文件编码异常,不是utf-8文本或文件损坏 -> {file_path},统一归类为 IOError |
OSError |
文件被占用、读取失败等未知 IO 问题 | 读取文件IO异常:{原始异常信息} |
def read_single_file(file_path: str) -> str:
try:
with open(file_path, "r", encoding="utf-8") as f:
return f.read()
except FileNotFoundError:
raise FileNotFoundError(f"错误:文件不存在 -> {file_path}")
except PermissionError:
raise PermissionError(f"错误:权限不足,无法读取文件 -> {file_path}")
except UnicodeDecodeError:
raise IOError(f"错误:文件编码异常,不是utf-8文本或文件损坏 -> {file_path}")
except OSError as e:
raise IOError(f"读取文件IO异常:{str(e)}")
3. 文件写入异常
| 异常类型 | 触发场景 | 处理方式(输出提示) |
|---|---|---|
PermissionError |
输出目录被锁定、无写入权限 | 错误:权限不足,无法写入输出文件 -> {output_path} |
| 上级目录不存在 | 输出路径的父目录缺失 | 在 OSError 分支内主动检测目录,抛出 错误:输出文件所在目录不存在 -> {dir_path} |
OSError |
磁盘空间不足、文件被占用等其他写入失败 | 写入文件IO异常:{原始异常信息} |
目录不存在这种情况先于通用 OSError 判断。
except OSError as e:
dir_path = os.path.dirname(output_path)
if dir_path != "" and not os.path.exists(dir_path):
raise IOError(f"错误:输出文件所在目录不存在 -> {dir_path}")
else:
raise IOError(f"写入文件IO异常:{str(e)}")
4. 算法层异常
| 边界场景 | 处理结果 |
|---|---|
| 原文清洗后为空 | 分片列表长度为 0,提前返回 0.0,避免 match_count / 0 除零 |
| 抄袭文本清洗后为空 | 提前返回 0.0 |
| 文本长度 < n(1 个字符) | 返回单字符片段列表,避免下游空列表 |
| 全空白文本 | 预处理阶段即被清洗为 "",走空文本分支 |
| 完全无匹配片段 | match_count = 0,返回 0.0 |
| 两段文本完全匹配 | match_count == len(orig_grams),返回 1.0 |
浙公网安备 33010602011771号