第一次个人编程作业

这个作业属于哪个课程 软件工程
这个作业要求在哪里 个人项目
这个作业的目标 实现一个论文查重(文本相似度计算)程序:给定原文与抄袭版文本,计算重复率并输出两位小数的结果;同时实践 PSP 个人软件过程、单元测试、性能分析与 Git 版本管理等软件工程方法

技术栈:Python 3
仓库https://github.com/jingmo2005/paper-check/tree/main/3224004307


1. 预估耗时(开发前)

Personal Software Process Stages 预估耗时(分钟)
Planning 计划
· Estimate 估计任务所需时间 20
Development 开发
· Analysis 需求分析(含学习新技术) 30
· Design Spec 生成设计文档 30
· Design Review 设计复审 20
· Coding Standard 代码规范 10
· Design 具体设计 40
· Coding 具体编码 90
· Code Review 代码复审 30
· Test 测试(自测、改码、提交) 60
Reporting 报告
· Test Report 测试报告 30
· Size Measurement 计算工作量 10
· Postmortem 事后总结 & 过程改进计划 20
合计 390

2. 实际耗时(开发后)

Personal Software Process Stages 实际耗时(分钟)
Planning 计划
· Estimate 估计任务所需时间 15
Development 开发
· Analysis 需求分析(含学习新技术) 35
· Design Spec 生成设计文档 25
· Design Review 设计复审 15
· Coding Standard 代码规范 10
· Design 具体设计 45
· Coding 具体编码 80
· Code Review 代码复审 25
· Test 测试(自测、改码、提交) 70
Reporting 报告
· Test Report 测试报告 25
· Size Measurement 计算工作量 10
· Postmortem 事后总结 & 过程改进计划 20
合计 375

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

2.1 总体设计思路

本题要求:给定「论文原文」和「在原文上增删改得到的抄袭版论文」两个文本文件,计算两者的重复率,结果以浮点(保留两位)写入指定的答案文件。

我采用「读取 → 清洗 → 分词 → 向量化 → 余弦相似度」的流水线,把“文本相似”转化为“向量夹角”问题。整体分五层:

原始文本 ──read_text──▶ 字符串
      │
      ▼
字符串 ──strip_html──▶ 去标签后的正文(抄袭版常为网页抓取,含大量标签噪声)
      │
      ▼
正文 ──tokenize──▶ token 列表(jieba 词级 / 离线 bi-gram 降级)
      │
      ▼
token 列表 ──vectorize──▶ 词频向量 Counter
      │
      ▼
两向量 ──cosine_similarity──▶ 余弦相似度 ∈ [0,1] ──write_answer──▶ 答案文件

2.2 模块 / 函数划分与调用流程

函数 职责 关键说明
read_text(path) 容错读取文本 依次尝试 utf-8 / gbk / latin-1 解码
strip_html(text) 去除 HTML/XML 标签 抄袭版常为网页抓取,含大量标签噪声;纯文本为无操作
tokenize(text) 文本分词 优先 jieba 词级分词;无 jieba 时降级为字符 bi-gram
vectorize(tokens) 构造词频向量 用 collections.Counter
cosine_similarity(va, vb) 余弦相似度 处理空向量,结果落在 [0,1]
compute_similarity(o, c) 对外主接口 串联上述步骤,返回重复率
write_answer(result, path) 写答案 按 OUTPUT_SCALE 缩放并保留两位
main(argv) 命令行编排 解析三参数,调用 compute + write

调用关系:main → compute_similarity →(read_text + tokenize + vectorize)→ cosine_similarity;main → write_answer。各函数职责单一、可独立单测,符合高内聚低耦合。

关键函数调用流程图(SVG)

关键函数调用流程图

2.3 算法关键

  1. 分词优先用 jieba 词级分词:把“今天是星期天,天气晴”切成“今天/是/星期天/,/天气/晴”。词级切分比字符级更能应对“同义替换”(如“星期天→周天”、“晴→晴朗”虽不同词,但共享大量相邻实词,仍能给出合理相似度)。
  2. 标点过滤:纯标点 token(如“,”、“。”)会被 _is_word_token 过滤掉,避免标点一致导致相似度虚高。
  3. 余弦相似度作为重复率:把两篇文章转成词频向量,夹角越小越相似,0°=完全一致,90°=完全无关。
  4. 离线降级(独到之处):评测机可能无 jieba,代码在 import jieba 失败时自动降级为字符级 bi-gram(相邻两字成 token),保证离线也能跑通且给出合理结果,同时消除 jieba 词典加载的启动开销。
  5. 输出可配置缩放:OUTPUT_SCALE=1.0 输出 0~1 小数;若老师要求百分比,改成 100.0 即可,其余不动。
  6. HTML 标签清洗:抄袭版论文常由网页抓取得到(如 GitHub 文件预览页、知网导出页),原始文件里混有大量 <...> 标签与页面脚本。strip_html 用正则 <[^>]+> 剥离标签后再分词,避免标签噪声严重干扰相似度;对不含标签的纯文本为无操作。实测:未清洗时 HTML 包裹的抄袭版相似度仅约 0.14,清洗后回升到 0.98 以上

2.4 独到之处小结

相比“直接字符串比较”或“单纯字符 bi-gram”的做法,本实现有 6 点设计取舍值得一提:

  • 鲁棒性:词级分词 + 标点过滤,对同义改写/标点差异不敏感失分。
  • 可移植:离线 bi-gram 降级 + 多编码读取,评测环境零依赖也能运行。
  • 可维护:单一职责函数 + 清晰接口,单测覆盖容易。
  • 噪声免疫:strip_html 剥离网页标签,专门应对“抄袭版来自网页抓取”的真实场景(实测把 0.14 救回 0.98+)。
  • 准确率优先:在 5s 时限绰绰有余的前提下,保留 HMM=True 与词级分词,宁可多花 0.3s 也要更高的查重准确率;速度优化做成可选开关(USE_HMM),而非默认牺牲准确率。
  • 零打包成本:纯 Python 脚本(main.py + paper_checker.py),python main.py a b c 即可运行

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

性能分析与改进共花费约 25 分钟(含 cProfile 运行、离线降级方案实现、HMM 对比实验)。

3.1 改进思路

先用 python -m cProfile 对与真实测试集规模相当的输入(约 150KB,含同义改写)做性能分析,定位瓶颈;再针对性改进并复测。

3.2 性能分析图(由 cProfile 数据生成)

性能分析

(原始数据见仓库 profile_result.txt,节选如下:约 182 万次函数调用,总耗时约 1.28s)

         1817641 function calls (1817625 primitive calls) in 1.276 seconds
   Ordered by: cumulative time
   ncalls  tottime  cumtime  filename:lineno(function)
        1    0.000   1.275  paper_checker.py:113(compute_similarity)
        2    0.014   1.250  paper_checker.py:74(tokenize)
        2    0.011   1.142  jieba/__init__.py:356(lcut)
    74200    0.034   1.131  jieba/__init__.py:289(cut)
    63070    0.036   1.077  jieba/__init__.py:249(__cut_DAG)
     9150    0.125   0.651  jieba/__init__.py:180(get_DAG)

3.3 消耗最大的函数

tokenize → jieba.lcut(分词)是消耗最大的函数,占累计时间约 98%。进一步拆解:

  • 一次性词典加载 marshal.load 约 0.55s(占整段约 82%),仅首次调用发生;
  • 实际分词 get_DAG / __cut_DAG 随文本量线性增长。

3.4 改进措施与效果

改进项 做法 效果
① 离线降级 无 jieba 时改用字符 bi-gram 消除 0.55s 词典加载,离线环境启动开销≈0
② HMM 对比实验 jieba.lcut(text, HMM=False) 分词提速约 1.32×(实测 0.0165s→0.0125s/轮),但会改变 token 数;默认仍保留 HMM=True 以换取最高查重准确率(当前约 1.3s 远低于 5s 时限,无需牺牲准确率换速度)
③ 日志抑制 jieba.setLogLevel(60) 屏蔽分词 INFO 日志,避免污染 stdout

说明:因约 1.3s 已在 5 秒时限内绰绰有余,我选择“准确率优先”,将 HMM 设为可调开关(USE_HMM)而非默认关闭;离线降级则同时提升了鲁棒性与极端环境的性能。

3.5 优化前后对比

下表汇总三种运行形态的关键指标,说明“默认配置”与“极限优化”之间的取舍:

运行形态 启动开销(首次词典加载) 单轮分词耗时 查重准确率 适用环境
离线 bi-gram 降级 ≈ 0s 最低 中(字符级) 无 jieba / 极速要求
在线 jieba(HMM=False) 0.55s 0.0125s 追求速度、可接受精度微降
在线 jieba(HMM=True,默认) 0.55s 0.0165s 最高 评测默认、5s 时限内

结论:默认采用“在线 jieba + HMM=True”,在 5s 时限内把准确率拉满;离线降级作为保底,仅在缺失 jieba 时自动启用。


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

4.1 测试策略(白盒为主)

  • 等价类划分:完全相同 / 完全不同 / 部分相似(增/删/改/同义替换)。
  • 边界值:空文件、空向量、仅标点差异、单字符。
  • 异常路径:参数个数错误、文件不存在、编码异常。
  • 降级路径:用 monkeypatch 强制 _HAS_JIEBA=False,验证离线 bi-gram 仍正确。

23 个测试用例,全部通过,分支覆盖率 88%(未覆盖的为防御性分支:jieba 导入失败守卫、latin-1 兜底等)。

4.2 代表性测试代码

def test_synonym_replacement():
    # 同义改写:星期天/周天,晴/晴朗,应高相似但非完全相同
    a = _write_tmp("今天是星期天,天气晴,今天晚上我要去看电影。")
    b = _write_tmp("今天是周天,天气晴朗,我晚上要去看电影。")
    sim = compute_similarity(a, b)
    assert 0.0 < sim < 1.0


def test_tokenize_fallback_bigram(monkeypatch):
    # 无 jieba 时退化为相邻两字 bi-gram,且不保留纯标点
    monkeypatch.setattr("paper_checker._HAS_JIEBA", False)
    toks = tokenize("天气晴,我要看电影。")
    assert toks
    assert all(any(ch.isalnum() for ch in t) for t in toks)


def test_main_wrong_argc():
    # 参数个数错误应返回码 2
    assert main(["only_one_arg"]) == 2


def test_missing_file_raises():
    # 文件不存在应抛异常,而非静默成功
    with pytest.raises(Exception):
        compute_similarity("no_such_1.txt", "no_such_2.txt")

4.3 覆盖率截图

覆盖率


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

异常场景 设计目标 触发条件 博客对应单测样例
文件不存在(FileNotFoundError) 不静默成功,明确报错退出,便于定位 传入不存在的文件路径 test_missing_file_raises / test_main_missing_file_propagates
参数个数错误 友好提示用法,返回码 2 命令行参数 ≠ 3 test_main_wrong_argc
编码不兼容 多编码容错,避免因编码崩溃 文件非 utf-8/gbk(如 gbk 存的中文) test_read_text_tolerates_gbk
空文件 / 空向量 相似度定义为 0,避免除零 任一文件为空 test_empty_original / test_empty_copy / test_cosine_zero_norm_empty

示例:文件缺失异常

def test_missing_file_raises():
    with pytest.raises(Exception):
        compute_similarity("no_such_1.txt", "no_such_2.txt")

对应场景:评测机若传错路径,程序应直接抛错退出(非零返回),而不是写出错误答案或卡死——这能避免“异常退出”以外的隐性失分。

异常处理设计说明(为何不用全局 try-catch):本实现不使用包裹整个 main 的全局 try/except,而是让“真正的错误”(文件不存在、编码失败)自然抛出 Python 内建异常,由外壳进程捕获并以非零状态码退出;唯独“参数个数错误”这类“用法级”问题,由 main 显式 return 2 返回码处理并给出用法提示。这样设计的好处是:① 真实的 I/O / 编码错误不会被静默吞掉、伪装成“查重成功 0.00”,避免最危险的隐性失分;② 返回码语义清晰,评测机可据此区分“程序崩溃”与“用法错误”;③ 单测能直接 pytest.raises 验证异常路径,可测性更好。


六、版本管理与提交记录

本作业全程使用 Git 管理,按功能进展分 4 次渐进提交

# 提交类型 主要变更
1 feat 基础功能 paper_checker.py + main.py:读取/清洗/分词/向量/余弦主流程跑通
2 test 测试+性能 新增 23 个单测 + cProfile 分析 + perf_chart.svg / profile_result.txt
3 fix 真实测试修复 针对老师提供的真实测试集补充 strip_html,修复 HTML 包裹导致相似度虚低的问题
4 docs 文档 README.md / PSP.md 完善

七、遇到的问题与解决

开发过程中踩到几个问题,记录如下:

  1. jieba 首次加载慢(约 0.55s):分词热点在词典 marshal.load。解决:① 离线环境自动降级 bi-gram,彻底绕开加载;② 在线环境保留 jieba 但把 HMM 设为可选开关,把“速度 vs 准确率”的取舍交给配置,而非硬编码。
  2. 真实测试集相似度异常低(仅约 0.14):排查发现老师给的“删除/新增”版是从 GitHub 预览页导出的,文件里混满了 <...> 网页标签与脚本。解决:新增 strip_html 用正则 <[^>]+>剥离标签,清洗后相似度回升到 0.98 以上,与“约 80% 抄袭”的语义吻合。这也是本实现相对“裸余弦”最关键的鲁棒性增强。
  3. 覆盖率 88% 的小缺口:未覆盖分支主要是“jieba 导入失败守卫”与“latin-1 兜底”。说明:这两处属于防御性分支(评测环境几乎不会触发),已在单测中用 monkeypatch 专门验证降级逻辑,缺口不影响功能正确性。
  4. Windows 编码坑:本地 GBK 存的中文文件在 utf-8 下会抛 UnicodeDecodeError。解决:read_text 依次尝试 utf-8 → gbk → latin-1,兼容不同编码来源,避免评测机换编码就崩。

八、个人收获

这是我第一次完整走一遍“预估—开发—实测—复盘”。最大的收获不是算法本身,而是两点认知:① 估算永远偏乐观——我预估 390 分钟、实际 375 分钟,看起来准,但 Test 阶段预估 60、实际 70,说明“自测/改码/提交”永远比想象中费时,下次要给测试留更足的缓冲;② “能跑”和“扛造”是两回事——分词、编码、网页噪声这些“非算法”因素,才是真实评测里最容易翻车的地方。把程序做成“离线能跑、乱码不崩、标签不干扰”,比在相似度公式上抠 0.01 更有价值。后续若文本量大幅上升,我会考虑用 SimHash 指纹把向量维度压下来。


九、总结与过程改进计划(Postmortem)

  1. 预估略偏长,整体可控:预估 390 分钟、实际 375 分钟;但 Test 阶段预估 60 偏短、实际 70,下次应预留更多自测时间。
  2. 性能:cProfile 表明分词是热点;若日后文本量极大,可升级为 SimHash 指纹降低向量维度。
  3. 健壮性:read_text 的多编码兜底增加了容错,但代价是多一次失败 I/O,已在博客中如实说明。
  4. 可复现:全程使用 Git 管理,按“基础功能 / 测试+性能 / 真实测试修复 / 文档”四次提交,便于回溯。
posted @ 2026-09-15 00:34  jingmo1  阅读(7)  评论(0)    收藏  举报