第二周编程作业
第一次个人编程作业 —— 论文查重
GitHub 仓库链接:https://github.com/gitgdut/3124004073
学号:3124004073
〇、如何运行(启动操作)
0.1 环境准备
- Python 3.8+(本项目在 3.12 上开发与测试);
- 安装依赖:
pip install -r requirements.txt(唯一运行时依赖为jieba)。
0.2 启动操作
cd <项目目录> # 进入仓库根目录(clone 后所在目录)
source venv/bin/activate # 激活虚拟环境;没有 venv 时先执行 python -m venv venv
python main.py [原文文件] [抄袭版论文的文件] [答案文件]
命令格式与作业要求一致:python main.py [原文文件] [抄袭版论文的文件] [答案文件]。程序把重复率([0,1] 浮点数,保留两位小数)写入第三个参数指定的答案文件,命令本身不在终端打印结果。
0.3 示例
python main.py TestTxt/orig.txt TestTxt/orig_0.8_add.txt answer.txt
cat answer.txt # 查看重复率,如 0.88
一、PSP 表格
说明:「预估耗时」在开始编程前填写,「实际耗时」在实现完成后回填。
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 30 | 35 |
| · Estimate | · 估计这个任务需要多少时间 | 30 | 35 |
| Development | 开发 | 465 | 585 |
| · Analysis | · 需求分析 (包括学习新技术) | 60 | 90 |
| · Design Spec | · 生成设计文档 | 30 | 25 |
| · Design Review | · 设计复审 | 30 | 25 |
| · Coding Standard | · 代码规范 (为目前的开发制定合适的规范) | 15 | 10 |
| · Design | · 具体设计 | 60 | 75 |
| · Coding | · 具体编码 | 150 | 210 |
| · Code Review | · 代码复审 | 30 | 45 |
| · Test | · 测试(自我测试,修改代码,提交修改) | 90 | 105 |
| Reporting | 报告 | 105 | 130 |
| · Test Report | · 测试报告 | 30 | 40 |
| · Size Measurement | · 计算工作量 | 15 | 15 |
| · Postmortem & Process Improvement Plan | · 事后总结, 并提出过程改进计划 | 60 | 75 |
| · 合计 | 600 | 750 |
二、计算模块接口的设计与实现过程
2.1 代码组织
项目采用模块化的函数式设计(除自定义异常外不使用类),按职责把代码拆成 4 个模块 + 1 个入口,整体结构如下:
全项目共 3 个类(均为 exceptions.py 中的自定义异常)与 7 个函数——入口 main(),以及 compute_similarity、read_text、preprocess、_word_counter、_ngram_counter、_cosine。
.
├── main.py # 入口:解析命令行参数、写答案文件、统一异常捕获
└── plagiarism/ # 计算模块(核心算法所在)
├── exceptions.py # 3 个自定义异常类
├── text_reader.py # 文件读取与编码识别
├── preprocess.py # 文本预处理(去 HTML、只留中文)
└── similarity.py # 核心算法(多粒度特征融合 + 余弦相似度)
各模块职责与调用关系:
main(argv) ──参数个数校验──► 调用 ──► compute_similarity(orig_path, plag_path)
│
┌───────────────────────┼───────────────────────────┐
▼ ▼ ▼
read_text() preprocess() _word_counter()/_ngram_counter()
(text_reader.py) (preprocess.py) + _cosine()
读文件、认编码 去 HTML、留中文 (similarity.py,特征与相似度)
关键函数一览:
| 函数 | 所在文件 | 职责 |
|---|---|---|
main(argv) |
main.py | 参数校验、调用计算、写答案、异常捕获 |
compute_similarity(orig, plag) |
similarity.py | 算法主流程,返回 [0,1] 重复率 |
read_text(path) |
text_reader.py | 按 UTF-8 / GBK / UTF-16 识别编码并读取 |
preprocess(text) |
preprocess.py | 去 HTML 标签/实体,只保留中文字符 |
_word_counter(text) |
similarity.py | jieba 全模式分词得到词频 |
_ngram_counter(text, n) |
similarity.py | 生成字符 n-gram 词频 |
_cosine(a, b) |
similarity.py | 两个词频向量的余弦相似度 |
2.2 算法流程图
命令行:python main.py [原文路径] [抄袭版路径] [答案路径]
│
▼
main() 参数个数校验(≠3 报错退出)
│
▼
compute_similarity(orig, plag)
│
┌───────────────┴───────────────┐
▼ ▼
read_text(原文) read_text(抄袭版)
preprocess() → 纯中文 preprocess() → 纯中文
│ │
└───────────────┬───────────────┘
▼
边界判断:都空→EmptyTextError;一边空→0.0;
完全相同→1.0
│
┌───────────────┼────────────────┐
▼ ▼ ▼
词粒度(jieba) 字符1-gram 字符2/3-gram
Counter Counter Counter
└───────────────┼────────────────┘
▼
四种粒度各算余弦相似度 → 等权平均
▼
重复率 ∈ [0,1],写入答案文件(两位小数)
2.3 算法关键与独到之处
核心思想是多粒度 n-gram 特征融合。中文没有天然分词边界,且抄袭改写手段多样——增词、删词、近义替换、语序调整——单一特征无法全面覆盖,因此我融合了四种粒度:
- 词粒度(jieba 全模式分词):捕捉完整语义单元,如「看电影」;
- 字符 1-gram:捕捉近义替换。例如「星期天→周天」「晴→晴朗」,分词后两者不同词,但字符层面仍共享「天」「晴」,1-gram 能识别这类改写;
- 字符 2-gram / 3-gram:捕捉语序。乱序改写(如把句子打乱)不改变字符集合,却会破坏局部顺序,n-gram 能区分不同乱序程度。
对每种粒度统计词频、各自算余弦相似度,再等权平均得到重复率。
独到之处有三点:
- 针对中文特点选特征:不用单一 jieba 分词(对近义替换、乱序不敏感),而是用「词 + 字符多阶 n-gram」互补;
- 预处理抗噪:评测样例中部分文件混入了网页 HTML 样板(约 1.9 万个英文词),若不清洗会把相似度从 0.9 拉到 0.09。我在预处理阶段去除 HTML 标签/实体并只保留中文,彻底消除这类噪声;
- 同一套特征即可区分乱序程度:实测三个乱序样例的相似度单调递减(0.97 → 0.90 → 0.76),与乱序强度一致。
核心融合逻辑(片段):
word_sim = _cosine(_word_counter(orig), _word_counter(plag)) # 词粒度
ngram_sims = [
_cosine(_ngram_counter(orig, n), _ngram_counter(plag, n)) # 字符 1/2/3-gram
for n in (1, 2, 3)
]
similarity = (word_sim + sum(ngram_sims)) / 4 # 等权平均
三、计算模块接口部分的性能改进
3.1 改进过程(约 30 分钟)
首次实现后,我用 cProfile(Python 自带性能分析工具,等价于题目中的 VS 2017 / JProfiler)对最大样例文件(约 16 万字节)进行剖析,得到如下热点分布:
| 函数 | 累计时间占比 |
|---|---|
jieba.__init__.lcut → cut → __cut_DAG(精确分词) |
约 82% |
_cosine(余弦相似度) |
约 10% |
| 字符 n-gram 统计及其他 | 约 8% |
瓶颈定位:jieba 的「精确模式」内部用 DAG + 维特比动态规划求最优切分,这是最慢的路径,却占了八成以上时间。
改进思路:把 jieba 从精确模式换成全模式(cut_all=True)。全模式不做动态规划,直接匹配词典中所有成词,速度更快,且生成的词更细粒度、更有利于相似度匹配。
3.2 改进效果
单次相似度计算耗时:约 176ms → 约 75ms,提升约 2.4 倍,且各测试样例的相似度结果基本不变(偏差不超过 0.01)。
性能分析图如下(左:优化前热点函数;右:优化前后耗时对比):

程序中消耗最大的函数为 jieba.__init__.lcut(及其内部 __cut_DAG),经上述优化后已显著降低。
四、计算模块部分单元测试展示
4.1 测试思路
采用 pytest 编写测试,共 17 个用例,覆盖正常与异常两大类场景:
- 正常场景:完全一致(=1.0)、完全无关(≈0)、近义替换、部分包含(子集)、增删改写;
- 编码与格式:GBK 编码文件、HTML 噪声剥离、答案文件两位小数格式;
- 异常场景:空文件、纯标点、纯英文、文件不存在、答案路径不可写;
- 性能:长文本在 5 秒内完成。
测试数据构造思路:全部用 tmp_path 临时目录动态生成输入文件,避免依赖外部数据,保证用例可重复、可移植;边界值用「极端输入」(空串、纯标点、纯英文、GBK 编码)逐点覆盖。
部分测试代码:
def test_identical_files(tmp_path):
a = _write(tmp_path, "a.txt", ORIG)
b = _write(tmp_path, "b.txt", ORIG)
assert compute_similarity(a, b) == 1.0
def test_given_example(tmp_path):
a = _write(tmp_path, "orig.txt", ORIG) # 今天是星期天……
b = _write(tmp_path, "plag.txt", PLAG) # 今天是周天……
assert 0.2 <= compute_similarity(a, b) <= 0.95
def test_html_is_stripped(tmp_path):
text = "论文查重是软件工程课程的重要实践。"
a = _write(tmp_path, "a.txt", text)
html = "<html><head><title>x</title></head><body>" + text + "<script>var a=1;</script></body></html>"
b = _write(tmp_path, "b.txt", html)
assert compute_similarity(a, b) == 1.0 # HTML 噪声被剥离
运行结果:17 个用例全部通过,分支覆盖率 95%。

五、计算模块部分异常处理说明
本项目定义了 3 类可预见的错误,均在 main() 中统一捕获,打印友好提示并以退出码 1 结束(不抛异常堆栈,避免评测机判为「异常退出」):
| 异常 | 设计目标 | 触发场景 | 对应测试用例 |
|---|---|---|---|
FileReadError |
输入文件不可用 | 文件不存在、无权限、编码无法识别 | test_nonexistent_file_raises |
EmptyTextError |
无有效可比文本 | 两文件都为空、纯标点、纯英文(无中文) | test_both_empty_raises、test_punctuation_only_raises、test_english_only_raises |
OSError(答案写入失败) |
输出路径不可写 | 答案文件所在目录不存在 | test_main_handles_unwritable_answer |
各异常的单元测试样例(节选):
def test_nonexistent_file_raises(tmp_path):
with pytest.raises(FileReadError):
compute_similarity(str(tmp_path / "not_exist.txt"), PLAG_PATH)
def test_both_empty_raises(tmp_path):
a = _write(tmp_path, "a.txt", "")
b = _write(tmp_path, "b.txt", "")
with pytest.raises(EmptyTextError):
compute_similarity(a, b)
def test_main_handles_unwritable_answer(tmp_path):
a = _write(tmp_path, "a.txt", ORIG)
b = _write(tmp_path, "b.txt", PLAG)
bad_ans = str(tmp_path / "no_such_dir" / "ans.txt")
assert main([a, b, bad_ans]) == 1
六、总结
本次作业实现了一个基于「多粒度 n-gram 特征融合」的中文论文查重程序:词粒度 + 字符 1/2/3-gram 四路特征取余弦相似度后等权平均。过程中通过 cProfile 定位并消除了 jieba 精确分词这一性能瓶颈(提速约 2.5 倍),并用 17 个单元测试保证了 95% 的分支覆盖率与三类异常的正确处理。
【可在此补充个人心得、踩坑记录等】
浙公网安备 33010602011771号