第一次个人编程作业
第一次个人编程作业
| 这个作业属于哪个课程 | 计科24级78班-软件工程 |
|---|---|
| 这个作业要求在哪里 | 个人项目 |
| 这个作业的目标 | 掌握个人软件开发PSP流程,独立完成论文查重小程序开发;熟悉文本相似度算法原理与工程落地;掌握模块化开发、异常处理、单元测试、性能优化;熟练使用Github进行版本迭代管理,规范完成软件工程个人项目文档撰写与成果发布。 |
1. 作业Github仓库链接
https://github.com/zhenglebing/zhenglebing/tree/main/3224004234
2. PSP预估耗时表(开发前预估)
本次预估为编码开发开始前的预估时间,用于规范个人开发流程,后续开发完成后将补充实际耗时。
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) |
|---|---|---|
| Planning | 计划 | 25 |
| ·Estimate | ·估计任务整体耗时 | 25 |
| Development | 开发 | 350 |
| ·Analysis | ·需求分析、算法学习、环境搭建 | 50 |
| ·Design Spec | ·撰写设计思路、模块划分文档 | 30 |
| ·Design Review | ·设计方案自查、逻辑校验 | 20 |
| ·Coding Standard | ·制定代码命名、注释、格式规范 | 15 |
| ·Design | ·算法设计、函数接口设计、流程设计 | 45 |
| ·Coding | ·核心代码编写、功能实现 | 90 |
| ·Code Review | ·代码自查、消除冗余、修正不规范写法 | 30 |
| ·Test | ·自测、造测试用例、修复bug | 30 |
| Reporting | 报告总结 | 55 |
| ·Test Report | ·整理单元测试结果、覆盖率数据 | 20 |
| ·Size Measurement | ·统计代码量、工作量 | 10 |
| ·Postmortem | ·项目复盘、改进方案撰写 | 25 |
| 合计 | 430 |
3. 计算模块接口的设计与实现过程
3.1 命令行接口规范
程序采用命令行方式接收输入输出文件路径,入口文件为main.py,运行格式:
python main.py <原文路径> <待比对文本路径> <输出结果路径>
示例:
python main.py samples/orig.txt samples/orig_0.8_add.txt samples/ans_add.txt
实测输出:
相似度:0.90,结果已写入 samples/ans_add.txt
3.2 项目文件架构
3224004234/
├─ main.py # 主程序,包含全部业务函数
├─ test_main.py # 单元测试代码
├─ requirements.txt # 项目依赖声明(coverage、snakeviz)
├─ .gitignore # git忽略配置
└─ samples/ # 存放测试文本样例
├─ orig.txt
├─ orig_0.8_add.txt
├─ orig_0.8_del.txt
├─ orig_0.8_dis_1.txt
├─ orig_0.8_dis_10.txt
└─ orig_0.8_dis_15.txt
3.3 代码组织与函数调用关系
本项目采用函数式组织代码,main.py 共 5 个独立函数:
- read_file(path):以 UTF-8 读取文件内容,文件不存在时抛出 FileNotFoundError。
- normalize(text):清洗文本,去除标点、空格、换行,英文字母转小写。
- ngram_counter(text, n):对文本滑动截取连续字符片段,统计频次;n<=0 抛出 ValueError。
- cosine_similarity(text_a, text_b, n=2):核心函数,调用预处理与分词,计算余弦相似度,返回 0~1 的浮点数。
- main():入口函数,校验命令行参数,读取文件、计算相似度并写入结果文件。
调用关系:main() → read_file() → cosine_similarity() → normalize() →ngram_counter()。模块仅通过参数与返回值传递数据,无全局可变状态。
3.4 算法关键与独到之处
采用字符 2-gram + 余弦相似度。先清洗文本,再用长度 2 的滑窗截取片段构建词频向量,最后用向量点积除以模长乘积得到相似度,数值越接近 1 代表文本相似度越高。
独到之处:
- 预处理只保留中文、数字、英文字母,消除换行、标点带来的无效特征。
- 使用频次统计而非简单集合,能识别多次重复片段,对增删、局部改写识别效果更好。
- 内置数学保护:清洗后为空直接返回 0,规避模长为 0 的除零错误;文本短于 n 时自动缩小 n,保证极短文本也能比较。
- 预编译正则表达式,并在 n=2 时用 zip 一次配对,减少临时字符串创建,提升运行效率。
3.5 函数流程图
4. 计算模块性能改进
4.1 性能分析方法
安装 snakeviz 后,通过 cProfile 采样并生成 profile.stats:
python -m pip install snakeviz
python -m snakeviz profile.stats
4.2 性能分析结果
在将样例文本放大到约 2.1 亿字符的压力场景下,优化前 profile 结果:
| 函数 | 累计耗时 | 占比 |
|---|---|---|
| ngram_counter | 89.56 s | 主要瓶颈 |
| re.Pattern.sub(normalize) | 47.89 s | 次瓶颈 |
4.3 改进思路与优化效果
| 优化项 | 说明 |
|---|---|
| 预编译正则 | 将 re.sub 的正则提升为模块级 re.compile,避免重复编译 |
| n=2 特殊路径 | 用 Counter(zip(text, text[1:])) 替代循环切片,避免生成大量临时字符串 |
| 极短文本处理 | n = min(n, len(a), len(b)),避免无效计算与除零 |
| 提前判空 | 清洗后为空直接返回 0.00,跳过后续计算 |
优化前后对比(同一 2.1 亿字符压力数据):
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 总耗时 | 139.52 s | 78.79 s | 约 1.77 倍 |
| ngram_counter 耗时 | 89.56 s | 63.15 s | 约 1.42 倍 |
| 计算结果 | 0.8966 | 0.8966 | 保持一致 |
5. 计算模块单元测试展示
5.1 测试设计思路
采用白盒测试为主,边界值、等价类测试为辅,覆盖:正常文本、纯标点、空文本、空白字符、极短文本、完全相同、部分相似、完全无关、非法 n、无效文件路径,以及命令行入口的正常 / 异常路径。共 17 个测试用例(作业要求至少 10 个)。
5.2 测试函数一览
| 编号 | 测试函数 | 验证内容 |
|---|---|---|
| 1 | test_identical_text | 完全相同文本相似度为 1 |
| 2 | test_slight_change | 略有改动相似度介于 0~1 |
| 3 | test_empty_original | 原文为空返回 0 |
| 4 | test_empty_copy | 抄袭文为空返回 0 |
| 5 | test_diff_text | 完全无关文本返回 0 |
| 6 | test_punct_only | 纯标点清洗后返回 0 |
| 7 | test_upper_lower | 英文大小写不敏感 |
| 8 | test_short_text | 极短文本相同为 1 |
| 9 | test_file_not_exist | 文件不存在抛 FileNotFoundError |
| 10 | test_invalid_n | 非法 n 抛 ValueError |
| 11 | test_whitespace | 空格换行干扰被消除 |
| 12 | test_long_text_sample | 长文本集成测试,0.7~0.99 |
| 13 | test_read_file_success | 正常读取文件内容 |
| 14 | test_normalize | 预处理结果正确 |
| 15 | test_main_missing_args | 参数不足仅打印用法 |
| 16 | test_main_normal_run | 命令行正常运行,输出 1.00 |
| 17 | test_main_file_not_exist | 输入文件缺失输出 0.00 不崩溃 |
核心单元测试代码展示:
def test_identical_text(self):
# 测试1:完全相同文本,相似度应为 1.0
score = cosine_similarity("今天是星期天", "今天是星期天")
self.assertAlmostEqual(score, 1.0)
def test_empty_original(self):
# 测试3:原文为空,相似度应为 0.0
self.assertEqual(cosine_similarity("", "任意文本"), 0.0)
def test_invalid_n(self):
# 测试10:非法 n 值应抛出 ValueError
with self.assertRaises(ValueError):
ngram_counter("abc", 0)
def test_long_text_sample(self):
# 测试12:长文本集成测试,相似度应在 0.7~0.99 之间
original_text = read_file("samples/orig.txt")
copied_text = read_file("samples/orig_0.8_add.txt")
score = cosine_similarity(original_text, copied_text, n=2)
self.assertGreater(score, 0.7)
self.assertLess(score, 0.99)
其余测试用例:
def test_punct_only(self):
# 测试6:纯标点,清洗后为空,应为 0.0
self.assertEqual(cosine_similarity("!,。?", "!!!"), 0.0)
def test_short_text(self):
# 测试8:极短文本,n 自动缩小后相同应为 1.0
self.assertAlmostEqual(cosine_similarity("a", "a"), 1.0)
def test_file_not_exist(self):
# 测试9:文件不存在应抛出 FileNotFoundError
with self.assertRaises(FileNotFoundError):
read_file("notexist.txt")
def test_normalize(self):
# 测试14:文本预处理,去标点、去空格、英文转小写
self.assertEqual(normalize("Hello, 世界! 123"), "hello世界123")
命令行入口测试,模拟参数+临时文件验证输出:
def test_main_normal_run(self):
# 测试16:命令行正常运行,结果写入文件且格式为两位小数
fd, orig = tempfile.mkstemp(suffix=".txt"); os.close(fd)
fd, copy = tempfile.mkstemp(suffix=".txt"); os.close(fd)
fd, out = tempfile.mkstemp(suffix=".txt"); os.close(fd)
try:
with open(orig, "w", encoding="utf-8") as f:
f.write("今天是星期天,天气晴")
with open(copy, "w", encoding="utf-8") as f:
f.write("今天是星期天,天气晴")
with mock.patch.object(sys, "argv", ["main.py", orig, copy, out]):
main()
with open(out, "r", encoding="utf-8") as f:
self.assertEqual(f.read(), "1.00")
finally:
for p in (orig, copy, out):
os.remove(p)
5.3 测试运行结果
Ran 17 tests in 0.021s
OK
5.4 代码覆盖率
使用 coverage 生成 HTML 报告:
python -m coverage run -m unittest test_main.py
python -m coverage report -m
python -m coverage html
结果:
Name Stmts Miss Cover Missing
--------------------------------------------
main.py 67 3 96% 32, 68, 96
test_main.py 92 1 99% 136
TOTAL 159 4 97%
main.py 覆盖率 96%,整体覆盖率 97%。未覆盖的 3 行为:极短文本的预置空分支、理论不可达的除零保护、main 入口守卫。
5.5 文本测试样例比对表
| 样例文件 | 参考预期值 | 程序实测值 | 说明 |
|---|---|---|---|
| orig_0.8_add.txt | 0.90 | 0.90 | 正常,真实《活着》正文文本 |
| orig_0.8_del.txt | 0.90 | 0.02 | 文件实际为HTML网页(百度网盘下载页),并非论文正文 |
| orig_0.8_dis_1.txt | 0.97 | 0.02 | 文件实际为HTML网页(百度网盘下载页),并非论文正文 |
| orig_0.8_dis_10.txt | 0.91 | 0.02 | 文件实际为HTML网页(百度网盘下载页),并非论文正文 |
| orig_0.8_dis_15.txt | 0.75 | 0.01 | 文件实际为HTML网页(百度网盘下载页),并非论文正文 |
原因说明:
只有 orig.txt 与 orig_0.8_add.txt 是原始正文文本,其余几个样例文件,是页面的HTML代码,计算得到的相似度极低,属于输入文件本身问题,不是代码缺陷。
我做了另一个版本:
将 4 个 HTML 文件中的正文提取出来
| 样例文件 | 提取单元格正文后查重 |
|---|---|
| orig_0.8_add.txt | 0.90 |
| orig_0.8_del.txt | 0.90 |
| orig_0.8_dis_1.txt | 0.97 |
| orig_0.8_dis_10.txt | 0.91 |
| orig_0.8_dis_15.txt | 0.75 |
6. 异常处理机制设计
6.1 命令行参数异常
设计目标:解决参数数量错误导致的崩溃。
场景:未传入三个路径参数。
方案:main() 校验 len(sys.argv) != 4,打印用法并优雅退出。
测试样例:test_main_missing_args。
6.2 文件不存在异常
设计目标:区分文件读取失败,同时保证评测不异常退出。
场景:传入无效路径。
方案:read_file() 主动抛出 FileNotFoundError(供单元测试校验);main() 捕获 OSError,打印提示并输出 0.00,保证程序正常结束。
测试样例:test_file_not_exist、test_main_file_not_exist。
6.3 空文本 / 极短文本处理
设计目标:规避模长为 0 的除零异常。
场景:文本为空、纯标点、长度小于 n。
方案:清洗后为空直接返回 0.00;文本短于 n 时自动把 n 缩小到可切出片段。
测试样例:test_empty_original、test_empty_copy、test_punct_only、test_short_text。
6.4 非法 n 值异常
设计目标:防止非法的分词粒度导致计算错误。
场景:ngram_counter 收到 n<=0。
方案:抛出 ValueError("n必须大于0")。
测试样例:test_invalid_n。
异常处理核心代码
def read_file(path: str) -> str:
"""读取文件,utf-8编码;文件不存在时抛出FileNotFoundError"""
try:
with open(path, "r", encoding="utf-8") as f:
return f.read()
except FileNotFoundError:
raise FileNotFoundError(f"文件不存在:{path}")
def ngram_counter(text: str, n: int) -> Counter:
if n <= 0:
raise ValueError("n必须大于0")
...
# cosine_similarity 空文本提前返回,规避除零
clean_a = normalize(text_a)
clean_b = normalize(text_b)
if not clean_a or not clean_b:
return 0.0
# main函数捕获文件读取异常
try:
text1 = read_file(orig_path)
text2 = read_file(copy_path)
score = cosine_similarity(text1, text2, n=2)
except OSError as e:
print(f"读取文件失败:{e}")
score = 0.0
异常对应的测试代码示例
# 文件不存在,期望抛出 FileNotFoundError
def test_file_not_exist(self):
with self.assertRaises(FileNotFoundError):
read_file("notexist.txt")
# 抄袭文为空,期望返回 0.0,不抛除零异常
def test_empty_copy(self):
self.assertEqual(cosine_similarity("任意文本", ""), 0.0)
# 非法 n,期望抛出 ValueError
def test_invalid_n(self):
with self.assertRaises(ValueError):
ngram_counter("abc", 0)
# 输入文件缺失时 main() 应输出 0.00 且不崩溃
def test_main_file_not_exist(self):
fd, out = tempfile.mkstemp(suffix=".txt"); os.close(fd)
try:
with mock.patch.object(sys, "argv", ["main.py", "no_a.txt", "no_b.txt", out]):
main()
with open(out, "r", encoding="utf-8") as f:
self.assertEqual(f.read(), "0.00")
finally:
os.remove(out)
7. PSP 实际耗时表
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 25 | 30 |
| ·Estimate | ・估计任务整体耗时 | 25 | 30 |
| Development | 开发 | 350 | 410 |
| ·Analysis | ・需求分析、算法学习、环境搭建 | 50 | 60 |
| ·Design Spec | ・撰写设计思路、模块划分文档 | 30 | 35 |
| ·Design Review | ・设计方案自查、逻辑校验 | 20 | 25 |
| ·Coding Standard | ・制定代码命名、注释、格式规范 | 15 | 15 |
| ·Design | ・算法设计、函数接口设计、流程设计 | 45 | 50 |
| ·Coding | ・核心代码编写、功能实现 | 90 | 110 |
| ·Code Review | ・代码自查、消除冗余、修正不规范写法 | 30 | 40 |
| ·Test | ・自测、造测试用例、修复 bug | 30 | 75 |
| Reporting | 报告总结 | 55 | 70 |
| ·Test Report | ・整理单元测试结果、覆盖率数据 | 20 | 30 |
| ·Size Measurement | ・统计代码量、工作量 | 10 | 10 |
| ·Postmortem | ・项目复盘、改进方案撰写 | 25 | 30 |
| 合计 | 430 | 510 |
8. 项目总结
基于 Python 实现 2-gram + 余弦相似度论文查重,完成编码、17 个单元测试、覆盖率检测与性能分析。实际总耗时 510 分钟,高于预估 430 分钟,主要消耗在调试文本预处理、完善测试覆盖和性能优化上。优化中定位 ngram_counter 为性能热点,通过预编译正则、zip 配对替代切片,将压力场景耗时从 139.5 s 降到 78.8 s(约 1.77 倍),计算结果保持一致。最终 main.py 覆盖率 96%。后续开发时会提前规划测试用例,减少后期调试时间,缩小预估工时与实际工时的差距。




浙公网安备 33010602011771号