第一次个人编程作业

这个作业属于哪个课程 计科24级78班-软件工程
这个作业的要求在哪里 个人项目
这个作业的目标 通过开发一个命令行论文查重工具,完整实践软件工程开发流程,提升算法设计、单元测试、性能优化与 Git 版本控制的综合能力

一、GitHub链接和PSP表格

1.1 本次作业的相关信息

项目仓库:https://github.com/Lilimarlene-hzx/3224004268
开发语言:python 3

1.2 PSP表格

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 10 34
· Estimate · 估计这个任务需要多少时间 10 34
Development 开发 275 291
· Analysis · 需求分析 (包括学习新技术) 120 109
· Design Spec · 生成设计文档 20 19
· Design Review · 设计复审 10 10
· Coding Standard · 代码规范 (为目前的开发制定合适的规范) 5 17
· Design · 具体设计 20 31
· Coding · 具体编码 60 52
· Code Review · 代码复审 10 5
· Test · 测试(自我测试,修改代码,提交修改) 30 48
Reporting 报告 25 44
· Test Repor · 测试报告 10 23
· Size Measurement · 计算工作量 5 5
· Postmortem & Process Improvement Plan · 事后总结, 并提出过程改进计划 10 16
· 合计 310 369

二、计算模块接口的设计与实现过程

2.1 代码组织与模块划分

为了满足“高内聚、低耦合”的软件设计原则,并方便后续进行单元测试,本程序没有使用类(Class),而是采用了纯函数式(Functional)设计。对于 VS Code 这个轻量级命令行工具来说,函数式设计比面向对象更简洁、更易测试,也不需要维护对象状态。
整个项目被拆分为4个核心模块,各司其职:

模块名称 类型 核心职责
read_text 函数 负责与文件系统交互,读取文本内容,屏蔽文件编码与路径细节
preprocess_text 函数 负责文本清洗,去除标点、空格、大小写等干扰因素
plagiarism_rate 函数 核心算法模块。接收纯净文本,计算并返回相似度浮点数
main 函数 调度中心。解析命令行参数,串联读取→预处理→计算→写入流程,处理全局异常

模块间关系图:
image

设计亮点: plagiarism_rate 被设计为纯函数(Pure Function)——给定相同的输入,永远返回相同的输出,不依赖任何外部状态。这使得它非常容易被单元测试覆盖,不需要构造复杂的文件系统环境。

2.2 关键函数流程图

对于核心的 plagiarism_rate 和 read_text 函数,逻辑涉及条件分支和异常处理,画流程图能更清晰地表达设计意图。

2.2.1 plagiarism_rate 算法流程图

image

2.2.2 read_text 异常处理流程图

image

2.3 算法关键与独到之处

本项目的核心算法并未直接使用简单的“字符串完全匹配”或“词频统计”,而是采用了基于序列匹配 + 集合相似度的混合加权算法。以下是算法的四个关键设计点:

2.3.1 基于 Ratcliff-Obershelp 的序列匹配(LCS)

  • 原理: 程序调用 Python 标准库 difflib.SequenceMatcher,该算法底层基于 Ratcliff-Obershelp 算法,本质是寻找两个字符串的最长公共子序列(LCS)。
  • 独到之处: 与单纯的“词频统计”不同,LCS 感知字符的相对顺序。例如,“我吃了苹果”和“苹果吃了我”在词频统计中可能高度相似,但在 LCS 中相似度会大幅降低,能有效识别“语序颠倒”的伪原创行为。

2.3.2 文本预处理(抗干扰机制)

  • 原理: 抄袭者常用“加标点”、“加空格”、“改大小写”来逃避查重。程序在计算前先调用 preprocess_text,用正则表达式 [^\u4e00-\u9fa5a-zA-Z0-9] 将非中文、非英文、非数字的字符全部剔除,并统一转小写。
  • 独到之处: 预处理与算法解耦,是独立函数。这样无论抄袭版加入多少种“噪声”,只要核心内容一致,都会被识别为高度相似。

2.3.3 混合加权算法(LCS + Jaccard)

  • 原理: 单纯使用 LCS 对“部分内容相同但语序被打乱”的情况不敏感,而单纯的 Jaccard 相似度(集合交集/并集)又完全忽略顺序。
  • 独到之处: 程序采用加权混合策略:
    a. SequenceMatcher 相似度 × 0.7(看重语序和结构)
    b. Jaccard 相似度 × 0.3(捕捉词汇重叠度,不受语序影响)
    c. 最终分数 = 0.7 × LCS + 0.3 × Jaccard
  • 效果: 这种混合算法兼顾了顺序结构和词汇集合两个维度,查重结果更稳健,误判率更低。

2.3.4 autojunk=False 的精准匹配

  • 原理: Python 的 SequenceMatcher 默认开启 autojunk 启发式算法,会自动忽略出现频率超过 1% 的“高频字符”(如中文里的“的”、“是”)。
  • 独到之处: 虽然这能加速计算,但会导致查重结果虚低。程序显式关闭 autojunk,确保每一个字符都被纳入相似度计算,保证了查重结果的严谨性。虽然牺牲了微小的性能,但换来了对论文查重这种严肃场景至关重要的准确性。

三、计算模块接口部分的性能改进

3.1 性能改进所花费的时间记录

阶段 工作内容 耗时(分钟)
性能测试 构造长文本,运行程序计时 16
瓶颈定位 使用 cProfile 分析耗时占比,定位热点函数 8
算法优化 重写 plagiarism_rate ,引入分块哈希与自适应采样策略 15
回归测试 验证优化后结果精度无明显下降,且单元测试全部通过 10
性能再测试 再次运行长文本,对比优化前后耗时 5

3.2 性能分析

在优化前,我在终端运行以下命令对程序进行性能剖析:

点击查看代码
py -m cProfile -s tottime main.py <原文文件> <抄袭版文件> <答案文件>
程序运行超过 30 分钟仍未返回结果,CPU 占用率达到 100%,充分说明原版 SequenceMatcher 算法完全不适用于长文本查重场景。因此,我被迫放弃直接测试,转而进行算法层面的优化。

3.3 性能改进思路与实现

针对上述问题,我提出了分块哈希+自适应切换的优化方案:

  • 短文本(< 5000 字符): 继续使用 SequenceMatcher,保证字符级匹配精度。
  • 长文本(≥ 5000 字符): 将文本切分为 50 字符的块,使用 MD5 哈希建立索引表,将匹配复杂度从 O(N×M) 降为 O(N)。

3.4 优化后性能验证

论文查重计算模块性能分析

环境:Python 3.13 / Windows;分析工具:cProfile
image

指标 优化前 优化后 提升
20万字符处理 30分钟+未返回 约1.2秒 数千倍
内存占用 崩溃级 < 50 MB 完全可控

四、 计算模块部分单元测试展示

4.1 单元测试代码

本项目使用 Python 内置的 unittest 框架,测试文件为 test_main.py。测试目标是核心算法函数 plagiarism_rate 和预处理函数 preprocess_text。
test_main.py 完整代码如下:

点击查看代码
import unittest
from main import plagiarism_rate, preprocess_text


class TestPreprocessText(unittest.TestCase):
    """测试文本预处理函数"""

    def test_remove_punctuation(self):
        """测试:去除标点符号"""
        raw = "今天天气好,我去看电影。"
        result = preprocess_text(raw)
        self.assertEqual(result, "今天天气好我去看电影")

    def test_remove_whitespace(self):
        """测试:去除空格和换行"""
        raw = "Hello   World\n\nPython"
        result = preprocess_text(raw)
        self.assertEqual(result, "helloworldpython")

    def test_lowercase(self):
        """测试:统一转为小写"""
        raw = "HELLO World"
        result = preprocess_text(raw)
        self.assertEqual(result, "hello world".replace(" ", ""))

    def test_keep_numbers(self):
        """测试:保留数字"""
        raw = "第1章 第2节"
        result = preprocess_text(raw)
        self.assertEqual(result, "第1章第2节")


class TestPlagiarismRate(unittest.TestCase):
    """测试核心查重算法"""

    def test_identical_text(self):
        """测试1:两段文本完全相同 → 应返回 1.0"""
        text = "今天天气真好,我打算下午去看电影。"
        self.assertAlmostEqual(plagiarism_rate(text, text), 1.0)

    def test_completely_different(self):
        """测试2:两段文本完全不同 → 应返回 0.0"""
        text1 = "今天天气真好"
        text2 = "苹果香蕉梨子"
        self.assertAlmostEqual(plagiarism_rate(text1, text2), 0.0)

    def test_empty_both(self):
        """测试3:两个空文本 → 应返回 1.0"""
        self.assertAlmostEqual(plagiarism_rate("", ""), 1.0)

    def test_one_empty(self):
        """测试4:一空一非空 → 应返回 0.0"""
        self.assertAlmostEqual(plagiarism_rate("今天天气好", ""), 0.0)
        self.assertAlmostEqual(plagiarism_rate("", "今天天气好"), 0.0)

    def test_punctuation_interference(self):
        """测试5:抄袭版加入标点符号干扰 → 应仍能识别为高度相似"""
        text1 = "今天天气好我去看电影"
        text2 = "今天天气好,我去看电影!!!"
        self.assertAlmostEqual(plagiarism_rate(text1, text2), 1.0)

    def test_whitespace_interference(self):
        """测试6:抄袭版加入空格干扰 → 应仍能识别为高度相似"""
        text1 = "HelloWorldPython"
        text2 = "H e l l o   W o r l d   P y t h o n"
        self.assertAlmostEqual(plagiarism_rate(text1, text2), 1.0)

    def test_case_interference(self):
        """测试7:抄袭版大小写不同 → 应仍能识别为高度相似"""
        text1 = "HelloWorld"
        text2 = "helloworld"
        self.assertAlmostEqual(plagiarism_rate(text1, text2), 1.0)

    def test_partial_match(self):
        """测试8:部分抄袭 → 相似度应在 0 到 1 之间"""
        text1 = "这是一篇很长的学术论文,讨论了很多问题"
        text2 = "这是一篇很长的学术论文"
        score = plagiarism_rate(text1, text2)
        self.assertTrue(0.0 < score < 1.0)

    def test_reverse_order(self):
        """测试9:语序颠倒 → 应降低相似度,不能是 1.0"""
        text1 = "我爱你中国"
        text2 = "中国爱你我"
        score = plagiarism_rate(text1, text2)
        self.assertLess(score, 1.0)

    def test_long_text_performance(self):
        """测试10:长文本不会崩溃,且能在合理时间内返回结果"""
        text1 = "今天天气真好" * 1000  # 6000 字符
        text2 = "今天天气很好" * 1000
        score = plagiarism_rate(text1, text2)
        self.assertTrue(0.0 <= score <= 1.0)


if __name__ == '__main__':
    unittest.main()

4.2 测试函数说明与数据构造

4.2.1 测试函数

编号 测试函数 构造思路 覆盖的场景
1 test_identical_text 使用完全相同的字符串,验证算法的"满分"行为 正常场景(基准线)
2 test_completely_different 使用毫无关联的中文字符,验证算法的"零分"行为 正常场景(下界)
3 test_empty_both 构造两个空字符串,验证空文件的边界逻辑 边界场景
4 test_one_empty 构造"一空一非空"的组合,验证单向空文件逻辑 边界场景
5 test_punctuation_interference 给一段文本人为添加标点符号,模拟抄袭者"加标点逃避查重" 反作弊场景
6 test_whitespace_interference 给字符间插入大量空格,模拟抄袭者"加空格逃避查重" 反作弊场景
7 test_case_interference 大小写混用,验证预处理统一小写的有效性 反作弊场景
8 test_partial_match 构造一段是另一段子集的情况,验证分数落在合理区间 部分抄袭场景
9 test_reverse_order 构造语序颠倒的文本,验证算法对语序的敏感性 伪原创场景
10 test_long_text_performance 使用 6000 字符的长文本,验证优化后算法不崩溃 性能相关场景

4.2.2 构造测试数据的核心原则:

  • 等价类划分: 将输入分为"完全相同"、"完全不同"、"部分相同"、"空"四类,每类至少一个用例。
  • 边界值分析: 重点测试空字符串、单字符、超长文本这三种边界情况。
  • 干扰模拟: 真实抄袭者会加入标点、空格、大小写变化来逃避查重,所以必须构造这些"对抗性"数据。
  • 可重复性: 所有测试数据都是硬编码字符串,不依赖外部文件,保证测试可重复运行。

4.3 单元测试运行结果

image

4.4 单元测试覆盖率

安装覆盖率工具

点击查看代码
pip install coverage -i https://mirrors.aliyun.com/pypi/simple/
运行覆盖率分析

image

4.5 测试心得

在编写单元测试的过程中,我最大的收获是"边界场景比正常场景更容易暴露 Bug"。
最初我只写了"完全相同"和"完全不同"两个测试,认为已经够了。但当我尝试构造空字符串时,发现旧版代码在"两文皆空"的情况下返回 0.0(逻辑上应该返回 1.0),暴露了一个真正的边界 Bug。后来通过修改 plagiarism_rate 的边界判断逻辑修复了这个问题。
另外,反作弊测试(加标点、加空格、改大小写)让我意识到预处理函数的必要性。如果没有 preprocess_text,这三个测试都会失败。测试驱动了设计的改进。
覆盖率报告显示 main.py 的 except Exception 兜底分支未被覆盖。这是合理的——触发这个分支需要让程序发生难以复现的未知异常。不过,可以在未来的测试中通过 mock 技术人为注入异常来覆盖它。

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

本程序在设计时高度重视容错性(Fault Tolerance)。对于一个命令行工具而言,用户可能传入各种非法的文件路径或数据,如果程序直接崩溃(Crash),会给用户带来极差的体验,甚至导致批处理任务中断。因此,我针对 4 类典型异常设计了专门的捕获与处理逻辑。

5.1 异常清单与设计目标

序号 异常类型 触发场景 设计目标
1 FileNotFoundError 用户输入的文件路径不存在 提示清晰错误,避免 Traceback 崩溃输出
2 IOError(路径不是文件) 用户传入的是一个目录路径,而非文件 区分"不存在"和"不是文件",给出精准提示
3 IOError(读取失败) 文件存在但无法读取(如权限不足、编码错误) 捕获底层异常并包装为友好信息
4 未知异常(兜底) 程序运行过程中出现的其他不可预期错误 保证程序最终以非 0 状态码退出,便于脚本集成

5.2 异常处理的设计总结

本程序采用分层异常处理策略:在 read_text 函数中,将底层 I/O 异常分类抛出,便于精准定位问题;在 main 函数中,统一捕获所有异常,保证程序始终以友好方式退出。所有错误信息都通过 sys.stderr 输出(而非 stdout),符合命令行工具的标准规范,避免错误信息混入正常结果输出流。异常测试用例全部通过,验证了程序在各类异常输入下的健壮性。

posted @ 2026-09-15 16:07  Lilimarlene  阅读(9)  评论(0)    收藏  举报