第一次个人编程作业
| 这个作业属于哪个课程 | 软件工程 |
|---|---|
| 这个作业要求在哪里 | 个人项目 |
| 这个作业的目标 | 实现一个论文查重(文本相似度计算)程序:给定原文与抄袭版文本,计算重复率并输出两位小数的结果;同时实践 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 算法关键
- 分词优先用 jieba 词级分词:把“今天是星期天,天气晴”切成“今天/是/星期天/,/天气/晴”。词级切分比字符级更能应对“同义替换”(如“星期天→周天”、“晴→晴朗”虽不同词,但共享大量相邻实词,仍能给出合理相似度)。
- 标点过滤:纯标点 token(如“,”、“。”)会被 _is_word_token 过滤掉,避免标点一致导致相似度虚高。
- 余弦相似度作为重复率:把两篇文章转成词频向量,夹角越小越相似,0°=完全一致,90°=完全无关。
- 离线降级(独到之处):评测机可能无 jieba,代码在 import jieba 失败时自动降级为字符级 bi-gram(相邻两字成 token),保证离线也能跑通且给出合理结果,同时消除 jieba 词典加载的启动开销。
- 输出可配置缩放:OUTPUT_SCALE=1.0 输出 0~1 小数;若老师要求百分比,改成 100.0 即可,其余不动。
- 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 完善 |
七、遇到的问题与解决
开发过程中踩到几个问题,记录如下:
- jieba 首次加载慢(约 0.55s):分词热点在词典 marshal.load。解决:① 离线环境自动降级 bi-gram,彻底绕开加载;② 在线环境保留 jieba 但把 HMM 设为可选开关,把“速度 vs 准确率”的取舍交给配置,而非硬编码。
- 真实测试集相似度异常低(仅约 0.14):排查发现老师给的“删除/新增”版是从 GitHub 预览页导出的,文件里混满了 <...> 网页标签与脚本。解决:新增 strip_html 用正则 <[^>]+>剥离标签,清洗后相似度回升到 0.98 以上,与“约 80% 抄袭”的语义吻合。这也是本实现相对“裸余弦”最关键的鲁棒性增强。
- 覆盖率 88% 的小缺口:未覆盖分支主要是“jieba 导入失败守卫”与“latin-1 兜底”。说明:这两处属于防御性分支(评测环境几乎不会触发),已在单测中用 monkeypatch 专门验证降级逻辑,缺口不影响功能正确性。
- Windows 编码坑:本地 GBK 存的中文文件在 utf-8 下会抛 UnicodeDecodeError。解决:read_text 依次尝试 utf-8 → gbk → latin-1,兼容不同编码来源,避免评测机换编码就崩。
八、个人收获
这是我第一次完整走一遍“预估—开发—实测—复盘”。最大的收获不是算法本身,而是两点认知:① 估算永远偏乐观——我预估 390 分钟、实际 375 分钟,看起来准,但 Test 阶段预估 60、实际 70,说明“自测/改码/提交”永远比想象中费时,下次要给测试留更足的缓冲;② “能跑”和“扛造”是两回事——分词、编码、网页噪声这些“非算法”因素,才是真实评测里最容易翻车的地方。把程序做成“离线能跑、乱码不崩、标签不干扰”,比在相似度公式上抠 0.01 更有价值。后续若文本量大幅上升,我会考虑用 SimHash 指纹把向量维度压下来。
九、总结与过程改进计划(Postmortem)
- 预估略偏长,整体可控:预估 390 分钟、实际 375 分钟;但 Test 阶段预估 60 偏短、实际 70,下次应预留更多自测时间。
- 性能:cProfile 表明分词是热点;若日后文本量极大,可升级为 SimHash 指纹降低向量维度。
- 健壮性:read_text 的多编码兜底增加了容错,但代价是多一次失败 I/O,已在博客中如实说明。
- 可复现:全程使用 Git 管理,按“基础功能 / 测试+性能 / 真实测试修复 / 文档”四次提交,便于回溯。

浙公网安备 33010602011771号