第一次个人编程作业

这个作业属于哪个课程 https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS
这个作业要求在哪里 https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS/homework/15693
这个作业的目标 完成一个论文查重程序

我的作业链接:https://github.com/mangnolia913/magnoliia913/tree/main/3224004191


一、PSP 表格(预估用时)

PSP2.1 Personal Software Process Stages 预估耗时(分钟)
Planning 计划 30
· Estimate · 估计这个任务需要多少时间 30
Development 开发 260
· Analysis · 需求分析(包括学习新技术) 40
· Design Spec · 生成设计文档 20
· Design Review · 设计复审 15
· Coding Standard · 代码规范 10
· Design · 具体设计 30
· Coding · 具体编码 80
· Code Review · 代码复审 25
· Test · 测试(自我测试,修改代码,提交修改) 40
Reporting 报告 60
· Test Report · 测试报告 20
· Size Measurement · 计算工作量 10
· Postmortem & Process Improvement Plan · 事后总结,并提出过程改进计划 30
· 合计 350

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

2.1 算法选型

综合考虑算法的时间复杂度、实现难度以及对中文文本的适应性,本系统最终选择字符 2-gram + Jaccard 相似度作为论文查重的核心算法。相比编辑距离和 LCS,字符 n-gram 的计算效率更高,能够避免长文本处理时的时间和内存开销;相比词频余弦相似度,不需要依赖中文分词库,降低了系统实现的复杂度;相比 SimHash,Jaccard 相似度能够更直观、准确地反映两段文本的局部字符重合程度。在 n-gram 的选择上,最终采用 2-gram,既能够保留一定的字符上下文信息,又不会像 3-gram 那样导致短文本之间的特征交集过少,因此在计算效率和相似度准确性之间取得了较好的平衡。

2.2 核心公式

Jaccard(A, B) = |A ∩ B| / |A ∪ B|

其中 A、B 分别为原文和抄袭版文本的 2-gram 集合。

示例

原文:  今天是星期天
2-gram:{今天, 天是, 是星, 星期, 期天}

抄袭版:今天是周天
2-gram:{今天, 天是, 是周, 周天}

交集 = {今天, 天是} = 2
并集 = {今天, 天是, 是星, 星期, 期天, 是周, 周天} = 7
相似度 = 2 / 7 ≈ 0.29

2.3 代码组织

3224004191/
├── main.py                 # 命令行入口
├── similarity.py           # 核心算法
├── requirements.txt
├── tests/
│   ├── test_similarity.py  # 核心算法单元测试
│   ├── test_main.py        # 命令行端到端测试
│   └── data/               # 测试样例
└── docs/                   # PSP、性能分析、覆盖率截图

模块职责:

文件 职责 对外接口
similarity.py 预处理、n-gram、Jaccard、文件读写 read_text, write_answer, preprocess, get_ngrams, similarity
main.py 解析命令行参数、调用核心、异常兜底 main()
tests/ 单元测试与端到端测试 pytest 用例

模块关系图:

main.py
  ├── read_text(orig_path)
  ├── read_text(copy_path)
  ├── similarity(orig_text, copy_text)
  │     ├── preprocess(text)
  │     └── get_ngrams(text, ngram_size=2)
  └── write_answer(ans_path, score)

2.4 关键函数说明

  • preprocess(text):用预编译正则 [^\w] 去除标点、空白、换行,保留中文、字母、数字
  • get_ngrams(text, ngram_size=2):用 zip 滑窗切分字符 n-gram,返回集合
  • similarity(text1, text2):分别计算两个集合,返回 Jaccard 相似度
  • read_text(path) / write_answer(path, score):文件读写,保留两位小数

2.5 算法独到之处

  1. 无第三方依赖:核心算法只用标准库 resys
  2. 中文友好:按字符切分,无需分词库
  3. O(n) 时间、O(n) 空间:远低于 5 秒 / 2048MB 限制
  4. 边界处理完善:两篇空返回 1.00,一篇空返回 0.00
  5. 对插入噪声鲁棒:逐字插入干扰只会破坏部分 2-gram

2.6 真实样例结果

用老师提供的样例测试:

样例 干扰方式 相似度
orig_0.8_dis_1.txt 1% 字符扰乱 0.8478
orig_0.8_del.txt 删除字符 0.6073
orig_0.8_dis_10.txt 10% 字符扰乱 0.5771
orig_0.8_add.txt 逐字插入 0.5137
orig_0.8_dis_15.txt 15% 字符扰乱 0.3521

干扰越多,相似度越低,算法区分度良好。

说明:文件名中的 0.8 表示保留了约 80% 的原始字符,但逐字插入干扰会破坏 2-gram 的相邻关系,导致大量特征断裂,所以输出值低于 0.8。这正说明算法对“逐字插入”这种抄袭方式敏感。


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

3.1 性能分析工具说明

本作业使用 Python 实现,采用 Python 官方性能分析工具 cProfile 替代,能定位耗时最长的函数、统计调用次数、生成可视化报告。

3.2 性能分析图

profile_before

3.3 消耗最大的函数

从 cProfile 输出可见:

  • _io.open(文件 IO)耗时 0.026s,属于不可避免的系统开销
  • get_ngrams(算法核心)耗时 0.005s,属于算法瓶颈

3.4 改进思路与效果

原实现(切片推导)

return {text[i:i + ngram_size] for i in range(len(text) - ngram_size + 1)}

优化实现(zip 滑窗)

return {''.join(t) for t in zip(*(text[i:] for i in range(ngram_size)))}

优化原理:原写法每次切片都创建一个新字符串;新写法用 zip 并行迭代 n 个子串,只在最后拼一次,减少中间对象。

对比测试(50 倍数据,约 50 万字符):

版本 耗时 相似度
优化前(切片推导) 0.264 秒 0.5138
优化后(zip 滑窗) 0.255 秒 0.5138
提升 约 3.4%

结论:当前算法在 50 万字符下耗时 0.26 秒,远低于 5 秒上限。优化收益有限,说明瓶颈主要在集合去重与正则替换,而非 n-gram 提取。


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

4.1 测试函数与思路

单元测试位于 tests/test_similarity.py,共 17 个用例,覆盖三类场景:

正常路径(6 个)

  • 完全相同文本 → 1.00
  • 完全无关文本 → < 0.1
  • 少量增删改 → > 0.3
  • 仅标点不同 → > 0.9
  • 仅空白不同 → > 0.9
  • 英文大小写敏感 → 0 < score < 1

边界路径(5 个)

  • 原文为空 → 0.00
  • 抄袭版为空 → 0.00
  • 两篇都为空 → 1.00
  • 文本长度小于 ngram_size → 空集合
  • 预处理去除标点 → 验证清洗结果

异常路径(3 个)

  • 文件不存在 → 抛 FileNotFoundError
  • 写答案保留两位小数 → 0.856 → "0.86"
  • 四舍五入 → 0.854 → "0.85"

真实数据集成测试(3 个)

  • orig vs orig_0.8_add → 0.3 < score < 0.9
  • orig vs orig_0.8_dis_1 → > 0.7
  • orig vs orig_0.8_dis_15 → < 0.6

4.2 部分测试代码

def test_identical_text_returns_one():
    """完全相同的文本,相似度应为 1.00"""
    assert similarity("今天是星期天,天气晴", "今天是星期天,天气晴") == pytest.approx(1.0)


def test_small_modification():
    """少量增删改,相似度应大于 0.3(短文本 2-gram 敏感,阈值放宽)"""
    score = similarity(
        "今天是星期天,天气晴,今天晚上我要去看电影。",
        "今天是周天,天气晴朗,我晚上要去看电影。"
    )
    assert score > 0.3


def test_empty_original():
    """原文为空,相似度应为 0"""
    assert similarity("", "今天天气晴") == 0.0


def test_both_empty():
    """两篇都为空,视为完全一致"""
    assert similarity("", "") == 1.0


def test_read_text_file_not_found():
    """读取不存在的文件应抛 FileNotFoundError"""
    with pytest.raises(FileNotFoundError):
        read_text("not_exist_file_xyz.txt")

命令行端到端测试位于 tests/test_main.py,共 5 个用例,通过 subprocess 调用 main.py

def test_cli_normal(tmp_path):
    """正常调用:应生成两位小数的答案文件"""
    orig = tmp_path / "orig.txt"
    copy = tmp_path / "copy.txt"
    ans = tmp_path / "ans.txt"
    orig.write_text("今天是星期天,天气晴。", encoding="utf-8")
    copy.write_text("今天是周天,天气晴朗。", encoding="utf-8")

    ret = subprocess.run(
        [sys.executable, str(MAIN), str(orig), str(copy), str(ans)],
        capture_output=True, text=True
    )
    assert ret.returncode == 0
    assert re.fullmatch(r"\d+\.\d{2}", ans.read_text(encoding="utf-8"))

4.3 测试运行结果

image

22 个用例全部通过。

4.4 覆盖率截图

coverage

报告显示核心模块 similarity.py 的语句覆盖率达到 100%,即该模块的每一行可执行代码都在测试中被执行到,无遗漏分支;配合 22 个测试用例全部通过,说明算法逻辑、边界情况和异常处理都得到了充分验证。main.py 未出现在覆盖率报告中,是因为它通过 subprocess 被调用,coverage 默认不跟踪子进程,但其功能已由 5 个命令行端到端测试完整覆盖。总体而言,覆盖率截图客观证明了测试用例设计完整、代码质量可靠。

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

异常 设计目标 测试样例 错误场景
FileNotFoundError 文件不存在时给出明确错误提示,返回非 0 退出码 test_read_text_file_not_found 用户输入的文件路径不存在
UnicodeDecodeError 文件编码不匹配时提示 test_cli_file_not_found 读取非 UTF-8 编码文件
参数数量错误 参数不足 3 个时打印用法,返回非 0 退出码 test_cli_missing_args 用户少传文件路径
空文本 避免除零错误,返回 0.00 或 1.00 test_empty_original / test_both_empty 原文或抄袭版为空文件
输出路径不可写 提示答案文件路径错误 test_write_answer_two_decimal 答案目录无写权限
兜底异常 捕获所有未知异常,保证程序不崩溃 test_cli_file_not_found 任何预料外的异常

异常处理代码示例main.py):

try:
    orig_text = read_text(orig_path)
    copy_text = read_text(copy_path)
    score = similarity(orig_text, copy_text)
    write_answer(ans_path, score)
    print(f"Similarity: {score:.2f}")
    return 0
except FileNotFoundError as exc:
    print(f"Error: file not found - {exc}", file=sys.stderr)
    return 1
except UnicodeDecodeError as exc:
    print(f"Error: encoding failed - {exc}", file=sys.stderr)
    return 1
except Exception as exc:  # pylint: disable=broad-exception-caught
    # 兜底捕获:保证任何异常都返回非 0 退出码,避免程序崩溃
    print(f"Error: {exc}", file=sys.stderr)
    return 1

异常测试样例

def test_cli_missing_args():
    """参数不足:应返回非 0 退出码并提示 Usage"""
    ret = subprocess.run(
        [sys.executable, str(MAIN)],
        capture_output=True, text=True
    )
    assert ret.returncode != 0
    assert "Usage" in ret.stdout or "Usage" in ret.stderr


def test_cli_file_not_found(tmp_path):
    """文件不存在:应返回非 0 退出码并提示错误"""
    ans = tmp_path / "ans.txt"
    ret = subprocess.run(
        [sys.executable, str(MAIN), "no_such.txt", "no_such2.txt", str(ans)],
        capture_output=True, text=True
    )
    assert ret.returncode != 0
    assert "not found" in ret.stderr.lower() or "error" in ret.stderr.lower()

六、代码质量分析

使用 pylintsimilarity.pymain.py 进行代码质量分析。

初始评分:7.55/10,存在 12 条警告:

  • C0304 文件末尾缺换行
  • C0103 变量名 fns1s2e 不符合 snake_case
  • W0718 兜底捕获过宽的 Exception

修复措施

警告 修复方式
C0304 Final newline missing 文件末尾补空行
C0103 变量名 f 改为 file_reader / file_writer
C0103 参数名 n 改为 ngram_size
C0103 变量名 s1 / s2 改为 ngrams1 / ngrams2
C0103 变量名 e 改为 exc
W0718 兜底捕获 加注释说明设计意图,配 # pylint: disable

修复后评分:10.00/10,无任何警告。
如图所示:

pylint_before

pylint_after


七、PSP 表格(实际用时)

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 30 25
· Estimate · 估计这个任务需要多少时间 30 25
Development 开发 260 300
· Analysis · 需求分析(包括学习新技术) 40 35
· Design Spec · 生成设计文档 20 20
· Design Review · 设计复审 15 15
· Coding Standard · 代码规范 10 10
· Design · 具体设计 30 40
· Coding · 具体编码 80 110
· Code Review · 代码复审 25 30
· Test · 测试(自我测试,修改代码,提交修改) 40 40
Reporting 报告 60 65
· Test Report · 测试报告 20 25
· Size Measurement · 计算工作量 10 10
· Postmortem & Process Improvement Plan · 事后总结,并提出过程改进计划 30 30
· 合计 350 390
posted @ 2026-09-15 17:24  cc0913  阅读(3)  评论(0)    收藏  举报