第二周编程作业

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

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 特征融合。中文没有天然分词边界,且抄袭改写手段多样——增词、删词、近义替换、语序调整——单一特征无法全面覆盖,因此我融合了四种粒度:

  1. 词粒度(jieba 全模式分词):捕捉完整语义单元,如「看电影」;
  2. 字符 1-gram:捕捉近义替换。例如「星期天→周天」「晴→晴朗」,分词后两者不同词,但字符层面仍共享「天」「晴」,1-gram 能识别这类改写;
  3. 字符 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)。

性能分析图如下(左:优化前热点函数;右:优化前后耗时对比):

profiling_chart

程序中消耗最大的函数为 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%。

The rsult of test


五、计算模块部分异常处理说明

本项目定义了 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% 的分支覆盖率与三类异常的正确处理。

【可在此补充个人心得、踩坑记录等】

posted @ 2026-09-14 15:28  Purρ1e  阅读(15)  评论(0)    收藏  举报