论文查重项目作业

第一次个人编程作业——论文查重项目

这个作业属于哪个课程 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):

image-20260915113116076

输出的 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 helloworld1.0(误判为完全相同) 0.0
重复片段被重复计数 ab ab ab vs ab0.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,不除零

image

六、计算模块部分异常说明

底层捕获具体异常类型,转换成带有出错文件路径 / 目录路径的新异常向上抛,由 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

posted @ 2026-09-15 20:24  Wanyi-Chen  阅读(6)  评论(0)    收藏  举报