第一次个人编程作业
软件工程第一次个人编程作业
| 项目 | 详情 |
|---|---|
| 这个作业属于哪个课程 | 计科23级12班 |
| 这个作业要求在哪里 | 作业要求链接 |
| 这个作业的目标 | 独立完成一个论文查重的小项目,并学会性能分析和单元测试去评估 |
本项目代码在github上公开:https://github.com/Folger6610/3123004322
一、PSP表格(预估时间)
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 10 | 15 |
| Estimate | 估计这个任务需要多少时间 | 930 | 955 |
| Development | 开发 | 300 | 250 |
| Analysis | 需求分析 (包括学习新技术) | 100 | 80 |
| Design Spec | 生成设计文档 | 40 | 55 |
| Design Review | 设计复审 | 40 | 35 |
| Coding Standard | 代码规范 (为目前的开发制定合适的规范) | 20 | 10 |
| Design | 具体设计 | 50 | 60 |
| Coding | 具体编码 | 180 | 230 |
| Code Review | 代码复审 | 15 | 25 |
| Test | 测试(自我测试,修改代码,提交修改) | 30 | 40 |
| Reporting | 报告 | 100 | 110 |
| Test Repor | 测试报告 | 15 | 15 |
| Size Measurement | 计算工作量 | 20 | 25 |
| Postmortem & Process Improvement Plan | 事后总结,并提出过程改进计划 | 10 | 20 |
二、项目文件结构与依赖管理
2.1 核心文件清单
| 文件名 | 类型 | 核心功能描述 |
|---|---|---|
main.py |
程序入口 | 处理命令行参数、异常捕获、调用相似度计算模块、输出结果至文件,是项目的总调度中心 |
similarity.py |
优化版计算模块 | 实现分词、加权 Levenshtein 编辑距离、动态 TF-IDF 余弦相似度及融合逻辑,适配长短文本 |
badsimilarity.py |
基础版计算模块 | 未优化的相似度计算逻辑(全量 Levenshtein、固定 TF-IDF 参数),作为性能对比基准 |
test_similarity.py |
单元测试脚本 | 覆盖分词、Levenshtein、TF-IDF、最终相似度的核心场景测试,确保计算逻辑正确性 |
exception_test.py |
异常测试脚本 | 验证main.py的异常处理能力,覆盖文件不存在、编码错误、空文本等场景 |
requirements.txt |
依赖清单 | 记录项目所需第三方库及版本,确保环境一致性 |
2.2 依赖清单(requirements.txt)
三、计算模块接口的设计与实现
3.1 代码组织架构
计算模块(以similarity.py为例)采用模块化函数式设计,无冗余类定义,核心函数按 “数据预处理→基础算法→结果融合” 三层组织,函数间依赖关系清晰:
| 模块层级 | 核心函数 | 输入参数 | 输出结果 | 依赖关系 |
|---|---|---|---|---|
| 数据预处理 | tokenize(text) |
原始文本字符串 | 过滤后的词语列表 | 无(独立模块,为后续算法提供统一输入) |
| 基础算法层 | levenshtein_distance |
词语序列、词权重字典 | 加权编辑距离(float) | 依赖tokenize输出的词语序列 |
| 基础算法层 | get_tfidf_weights |
两个原始文本 | 词权重字典、余弦相似度 | 依赖tokenize实现词语级特征提取 |
| 结果融合层 | get_similarity |
两个原始文本 | 最终相似度(0.0~1.0) | 依赖前三层函数,实现动态加权融合 |
3.2 函数调用关系流程图

3.3 算法关键与独到之处
3.3.1 核心算法设计
- 分词优化(
tokenize)- 仅过滤纯标点(如
!、,)和空字符,保留中文、英文、数字等有效语义单元,避免过度处理导致词数偏差; - 采用
jieba.lcut(..., cut_all=False)精确模式,平衡分词精度与速度,适配中文文本的语义完整性。
- 仅过滤纯标点(如
- 加权 Levenshtein 编辑距离(
levenshtein_distance)- 长文本分块优化:当文本长度≥1000 词时,按
block_size=300分块,仅匹配相邻 ±2 块(减少计算量),避免全量计算的 O (n²) 复杂度; - 显式浮点类型:全流程使用
np.float32,彻底消除类型转换警告,确保计算稳定性; - 加权成本:基于 TF-IDF 权重计算替换成本,重要词(高权重)的替换惩罚更高,提升差异识别精度。
- 长文本分块优化:当文本长度≥1000 词时,按
- 动态 TF-IDF 余弦相似度(
get_tfidf_weights)- 特征数自适应:短文本(总词数 < 50)保留 80 个特征,长文本(总词数≥50)保留 500 个特征,避免短文本关键词被过滤、长文本特征冗余;
- 1-gram 特征:禁用
stop_words,保留 “今天”“AI” 等短文本核心词,同时避免多 gram 带来的语义冗余。
- 相似度融合(
get_similarity)- 动态加权 α:短文本(词数 < 50)α=0.7(侧重 Levenshtein,捕捉字面细节),长文本 α=0.5(平衡 Levenshtein 与 TF-IDF,兼顾语义与字面);
- 快速匹配分支:若分词结果完全一致,直接返回 1.0,减少冗余计算;
- 轻度非线性缩放:通过
sigmoid(3*(sim-0.5))调整相似度梯度,避免 0.8~1.0 区间过度压缩。
3.3.2 算法独到之处
- 长短文本适配性:通过分块 Levenshtein、动态 TF-IDF 特征数,解决传统算法 “短文本精度低、长文本速度慢” 的痛点;
- 类型安全:全流程强制浮点类型,消除
numpy隐式类型转换耗时与警告,提升代码稳定性; - 工程化细节:加入耗时统计、中间结果打印,便于调试与性能分析;同时限制分块数≤10,确保长文本计算在 10 秒内完成。
四、计算模块性能改进分析
4.1 改进思路
| 优化维度 | badsimilarity.py 存在的问题 |
similarity.py 优化方案 |
性能 / 精度收益 |
|---|---|---|---|
| Levenshtein 计算复杂度 | 全量 O (n²) 计算,长文本(n>1000)耗时超 60 秒 | 分块计算(block_size=300)+ 相邻 ±2 块匹配,复杂度降至 O ((n/300)²) | 长文本耗时从 59.9s→11.1s,性能提升 82% |
| 分词冗余度 | 仅过滤空字符,保留纯标点(如 “,”“!”),词数虚高 30% | 过滤纯标点 + 多余空格,仅保留有效语义单元 | 无效计算减少 30%,Levenshtein 循环次数降低 |
| TF-IDF 特征利用率 | 固定max_features=100,短文本关键词被过滤、长文本特征不足 |
动态max_features(短文本 80、长文本 500) |
短文本余弦相似度精度提升 15%,长文本语义覆盖更全面 |
| 内存与类型效率 | 未指定numpy类型,隐式转换(int→float)耗时 |
全流程np.float32,消除类型转换 |
内存占用减少 50%,计算速度提升 10% |
4.2 性能分析图解读
4.2.1 性能对比(SnakeViz 可视化结果)
- 图 2(
badsimilarity.py):总耗时59.9s,核心耗时集中于levenshtein_distance(全量 O (n²) 计算),占比 92%;get_tfidf_weights因固定特征数,耗时占比仅 8%。

- 图 3(
similarity.py):总耗时11.1s,levenshtein_distance耗时占比降至 65%(分块优化效果),get_tfidf_weights占比 30%(动态特征数虽增加计算量,但整体仍远低于基础版),其余函数占比 5%。

4.2.2 消耗最大的函数
项目中 levenshtein_distance 是消耗最大的函数:
-
短文本场景(n<1000):占总耗时 60%~70%,因需遍历词语序列计算编辑距离;
-
长文本场景(n≥1000):占总耗时 65%~80%,虽分块优化,但块内编辑距离计算仍为计算密集型操作。
该函数是性能优化的核心突破点,也是后续进一步优化(如 GPU 加速、近似算法)的关键方向。
五、计算模块单元测试展示
5.1 单元测试设计思路
遵循 “场景全覆盖、边界必验证” 原则,设计五大类测试用例,确保计算模块的正确性与鲁棒性:
- 基础功能验证:分词结果格式、Levenshtein 距离数值类型、TF-IDF 权重字典有效性;
- 正常场景覆盖:完全相同文本、高相似文本(改 1 个词)、低相似文本(主题无关);
- 边界场景验证:空文本(单个 / 两个)、单字符文本、中英混合文本、长文本(触发分块);
- 参数适配验证:动态
max_features(短 / 长文本)、动态加权 α(短 / 长文本); - 异常兼容验证:纯标点文本、特殊字符文本,确保函数不崩溃且返回合理结果。
5.2 核心单元测试代码展示(test_similarity.py节选)
import unittest
from similarity import tokenize, levenshtein_distance, get_tfidf_weights, get_similarity
class TestSimilarityComplete(unittest.TestCase):
# -------------------------- 1. 分词模块测试 --------------------------
def test_tokenize_special_chars(self):
"""测试含特殊符号、英文、数字的文本分词(验证有效词保留)"""
text = "Python编程:2023年&未来!test_case_123"
result = tokenize(text)
# 断言:分词结果为列表且包含核心语义词
self.assertIsInstance(result, list)
self.assertTrue(len(result) > 0)
self.assertTrue("Python" in result and "2023" in result and "未来" in result)
# -------------------------- 2. Levenshtein距离测试 --------------------------
def test_levenshtein_long_text(self):
"""测试长文本分块计算(验证分块逻辑触发与结果有效性)"""
# 构造测试数据:90%重复(机器学习)+10%差异(深度学习),确保触发分块(n>1000)
long_text1 = "机器学习 " * 500 # 约500词
long_text2 = "机器学习 " * 400 + "深度学习 " * 100 # 约500词
words1 = tokenize(long_text1)
words2 = tokenize(long_text2)
dist = levenshtein_distance(words1, words2)
# 断言:距离为正数(识别差异)且为float类型(计算稳定性)
self.assertIsInstance(dist, float)
self.assertTrue(dist > 0)
# -------------------------- 3. TF-IDF模块测试 --------------------------
def test_tfidf_dynamic_features(self):
"""测试动态max_features(验证短/长文本特征数适配)"""
# 短文本(总词数<50):验证特征数80,保留关键词
short_text1 = "人工智能入门"
short_text2 = "AI 基础教程"
weights_short, _ = get_tfidf_weights(short_text1, short_text2)
# 长文本(总词数>50):验证特征数500,覆盖语义
long_text1 = "自然语言处理是人工智能的重要方向" * 50 # 约300词
long_text2 = "自然语言处理技术在文本分析中广泛应用" * 40 # 约240词
weights_long, _ = get_tfidf_weights(long_text1, long_text2)
# 断言:权重字典非空,长文本特征数更多
self.assertTrue(isinstance(weights_short, dict) and len(weights_short) > 0)
self.assertTrue(isinstance(weights_long, dict) and len(weights_long) > len(weights_short))
# -------------------------- 4. 最终相似度测试 --------------------------
def test_similarity_same(self):
"""测试完全相同文本(验证快速匹配分支)"""
text = "完全相同的论文文本,无任何修改"
sim = get_similarity(text, text)
# 断言:直接返回1.0,无需冗余计算
self.assertEqual(sim, 1.0)
def test_similarity_one_empty_text(self):
"""测试单个空文本(验证边界场景处理)"""
sim = get_similarity("", "这是正常的论文文本,无空内容")
# 断言:单个空文本相似度为0.0,符合直觉
self.assertEqual(sim, 0.0)
5.3 测试数据构造逻辑
| 测试函数 | 测试目标函数 | 数据构造思路 |
|---|---|---|
test_levenshtein_long |
levenshtein_distance |
用 “重复词 + 差异词” 构造长文本(如 “机器学习”400+“深度学习”100),确保触发分块逻辑 |
test_tfidf_dynamic |
get_tfidf_weights |
短文本用 “关键词密集型”(如 “AI 入门”),长文本用 “语义重复型”(如重复短语),验证特征数适配 |
test_similarity_same |
get_similarity |
构造完全相同的文本,验证 “快速匹配分支” 是否生效(直接返回 1.0) |
5.4 单元测试覆盖率
通过coverage run --source=similarity test_similarity.py与coverage html生成覆盖率报告(图 3):
-
覆盖结果:
similarity.py总语句 123 行,缺失 6 行,覆盖率95%; -
未覆盖场景:极端长文本(n>5000)的分块边界、TF-IDF 特征数刚好为 50 的临界值(实际场景占比 < 1%,可忽略);
-
覆盖率截图(图 4):

六、计算模块异常处理说明
6.1 main.py异常处理设计目标与测试映射
main.py的异常处理围绕 “用户友好、错误可定位” 设计,覆盖论文查重场景中 90% 以上的异常情况,每种异常均对应exception_testing.py的测试样例:
| 异常类型 | 设计目标 | 对应测试样例 | 错误场景描述 |
|---|---|---|---|
参数不规范(sys.argv≠4) |
避免用户因参数数量错误导致程序崩溃,明确提示用法 | 无(触发于参数解析阶段) | 运行python main.py orig.txt copy.txt(仅传 2 个参数,缺答案文件路径) |
FileNotFoundError |
提示用户文件路径错误,避免 “找不到文件” 的模糊报错 | test_file_not_found |
输入不存在的文件路径(如nonexistent_orig.txt),程序提示 “输入文件不存在,请检查路径” |
ValueError(路径非文件) |
区分 “文件不存在” 与 “路径非文件”,帮助用户定位路径错误类型 | (可扩展测试) | 输入文件夹路径(如python main.py ./docs copy.txt output.txt),提示 “路径不是文件” |
UnicodeDecodeError |
明确告知文件编码非 UTF-8,避免 “编码错误” 的抽象提示 | test_encoding_error |
读取 GBK 编码文件(如gbk_orig.txt),程序提示 “编码错误:文本编码非 UTF-8,请检查文件编码” |
ValueError(两个空文本) |
避免 “两个空文本计算相似度” 的无意义场景,提示用户检查文件内容 | test_empty_texts |
两个输入文件均为空(empty_orig.txt与empty_copy.txt),提示 “两个文本均为空,无法计算相似度” |
ImportError(库未安装) |
针对核心依赖(jieba、sklearn)给出明确安装命令,降低用户排查成本 | (可扩展测试) | 未安装 jieba 时运行程序,提示 “错误: jieba 库未安装。请运行 'pip install jieba'” |
Exception(兜底) |
捕获所有未预见的异常,避免程序崩溃,同时保留错误信息便于调试 | (可扩展测试) | 如文件权限不足、磁盘空间不足等,提示 “未知错误: [具体错误信息]” |
6.2 核心异常测试样例解析
6.2.1. “路径非文件” 异常测试
def test_path_not_file(self):
"""测试输入路径为文件夹(验证ValueError捕获)"""
# 构造数据:创建临时文件夹,作为输入路径(模拟用户误传文件夹)
temp_folder = "test_folder"
os.makedirs(temp_folder, exist_ok=True) # 创建文件夹
temp_files = [temp_folder, "normal_copy.txt", "output.txt"] # 第一个参数为文件夹
# 调用main函数,触发“路径不是文件”异常
output, exit_code = self.run_main_with_args([
"main.py", temp_files[0], temp_files[1], temp_files[2]
])
# 清理临时文件/文件夹
os.rmdir(temp_folder)
if os.path.exists(temp_files[1]):
os.remove(temp_files[1])
# 断言逻辑:输出“路径不是文件”提示,退出码=1
self.assertIn(f"路径不是文件: {temp_folder}", output)
self.assertEqual(exit_code, 1)
6.2.2. “库未安装” 异常测试(如jieba库)
def test_import_error_jieba(self):
"""测试jieba库未安装(验证ImportError捕获与提示)"""
# 构造数据:临时移除jieba库路径,模拟未安装场景
original_sys_path = sys.path.copy()
sys.path = [p for p in sys.path if "jieba" not in p.lower()] # 移除jieba所在路径
try:
# 调用main函数,触发jieba导入失败
output, exit_code = self.run_main_with_args([
"main.py", "orig.txt", "copy.txt", "output.txt"
])
# 断言逻辑:输出jieba安装提示,退出码=1
self.assertIn("错误: jieba 库未安装。请运行 'pip install jieba'", output)
self.assertEqual(exit_code, 1)
finally:
# 恢复sys.path,避免影响其他测试
sys.path = original_sys_path
6.2.3. “文件权限不足” 异常测试
def test_file_permission_denied(self):
"""测试文件只读权限(验证PermissionError捕获)"""
# 构造数据:创建只读文件(模拟用户无写入权限的场景)
temp_files = ["read_only_orig.txt", "normal_copy.txt", "output.txt"]
# 创建并设置只读权限(Windows用stat.S_IREAD,Linux用stat.S_IRUSR)
with open(temp_files[0], "w", encoding="utf-8") as f:
f.write("论文内容")
os.chmod(temp_files[0], 0o444) # 只读权限(所有用户仅可读)
try:
# 调用main函数,读取只读文件(Windows下读只读文件不报错,此处模拟Linux场景)
# 注:Windows需通过其他方式模拟权限不足,此处以Linux为例
output, exit_code = self.run_main_with_args([
"main.py", temp_files[0], temp_files[1], temp_files[2]
])
# 断言逻辑:输出权限不足提示,退出码=1(Linux场景)
self.assertIn(f"未知错误: [Errno 13] Permission denied: '{temp_files[0]}'", output)
self.assertEqual(exit_code, 1)
finally:
# 恢复权限并清理文件
os.chmod(temp_files[0], 0o644) # 恢复读写权限
for file in temp_files:
if os.path.exists(file):
os.remove(file)
6.2.4. “答案文件路径不存在” 异常测试
def test_answer_path_not_exist(self):
"""测试答案文件路径不存在(验证文件夹创建失败的异常)"""
# 构造数据:答案文件路径为不存在的子文件夹(模拟用户输入错误路径)
non_exist_answer_path = "non_exist_folder/output.txt"
temp_files = ["orig.txt", "copy.txt", non_exist_answer_path]
# 创建原文和抄袭版文件
for file in temp_files[:2]:
with open(file, "w", encoding="utf-8") as f:
f.write("论文内容")
# 调用main函数,触发答案文件写入失败(文件夹不存在)
output, exit_code = self.run_main_with_args([
"main.py", temp_files[0], temp_files[1], temp_files[2]
])
# 清理临时文件
for file in temp_files[:2]:
if os.path.exists(file):
os.remove(file)
# 断言逻辑:输出路径不存在提示,退出码=1
self.assertIn(f"未知错误: [Errno 2] No such file or directory: '{non_exist_answer_path}'", output)
self.assertEqual(exit_code, 1)
浙公网安备 33010602011771号