第一次个人编程作业
这个作业属于哪个课程 | https://edu.cnblogs.com/campus/gdgy/Class34Grade23ComputerScience |
---|---|
这个作业要求在哪里 | https://edu.cnblogs.com/campus/gdgy/Class34Grade23ComputerScience/homework/13477 |
这个作业的目标 | 开发论文查重项目,了解工程开发流程和GitHub使用 |
1.GitHub地址
https://github.com/boom-c/project1
2.psp表格
PSP2.1 阶段 | 阶段说明 | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning(计划) | 对整个开发任务的时间规划与估算 | 35 | 35 |
· Estimate | 估计完成该任务所需的总时间 | 35 | 35 |
Development(开发) | 软件开发的核心执行环节 | 500 | 640 |
· Analysis | 需求分析(含新技术学习、需求梳理等) | 65 | 85 |
· Design Spec | 生成详细的设计文档(如架构设计、模块设计等) | 55 | 55 |
· Design Review | 设计文档复审(检查设计合理性、可行性等) | 35 | 35 |
· Coding Standard | 制定适配当前项目的代码规范(命名、格式等) | 25 | 25 |
· Design | 具体设计(如接口设计、数据库设计、算法设计等) | 45 | 55 |
· Coding | 按照设计与规范进行具体编码实现 | 195 | 220 |
· Code Review | 代码复审(检查代码质量、规范性、逻辑正确性等) | 55 | 55 |
· Test | 测试(含自我测试、Bug修复、版本提交等) | 100 | 110 |
Reporting(报告) | 开发完成后的总结与复盘 | 35 | 45 |
· Test Report | 编写测试报告(含测试用例、结果、问题总结等) | 20 | 25 |
· Size Measurement | 计算项目工作量(如代码行数、功能点等) | 30 | 40 |
· Postmortem & Process Improvement Plan | 事后总结(分析问题与经验)并制定过程改进计划 | 20 | 40 |
合计 | 所有阶段耗时总和 | 1250 | 1500 |
3. 计算模块接口的设计
一、需求分析与接口定义
核心需求
- 支持两种基础相似度算法:Jaccard相似度(用于计算词集重叠度)和余弦相似度(用于计算词频向量相似度)。
- 需根据文本长度动态调整算法权重(长文本更依赖词频信息,短文本侧重词集匹配)。
- 处理边界场景:空文本、完全相同文本、完全不同文本、部分相似文本。
- 输入为预处理后的词列表(由
text_processor
模块提供),输出为标准化的浮点型相似度(范围0~1)。
接口设计(函数级)
函数名 | 输入参数 | 输出 | 核心功能 |
---|---|---|---|
jaccard_similarity |
words1: List[str] (文本1分词结果)、words2: List[str] (文本2分词结果) |
float (0~1) |
计算两文本词集的Jaccard相似度(交集/并集) |
cosine_similarity |
同上 | float (0~1) |
计算两文本词频向量的余弦相似度 |
calculate_final_repeat_rate |
同上 | float (0~1) |
结合前两种算法结果,按文本长度动态加权得到最终重复率 |
二、核心算法设计
1. Jaccard相似度
设计思路:基于“词集合”的重叠度计算,适用于短文本快速匹配。
- 公式:
J(A,B) = |A∩B| / |A∪B|
(A、B为两文本的词集合) - 边界处理:
- 两文本均为空 → 返回1.0(视为完全相同)
- 任一文本为空 → 返回0.0(视为完全不同)
2. 余弦相似度
设计思路:基于“词频向量”的夹角计算,适用于长文本(词频信息更重要)。
- 公式:
cosθ = (A·B) / (||A||·||B||)
(A、B为词频向量,·为点积,||·||为模长) - 优化点:
- 用
collections.Counter
高效统计词频(时间复杂度O(n)) - 合并向量生成与点积计算步骤,减少遍历次数
- 用
3. 最终重复
设计思路:动态加权融合两种算法,平衡词集匹配与词频匹配的优势。
- 短文本(词数≤50):等权重平均 →
(Jaccard + Cosine) / 2
- 长文本(词数>50):余弦权重更高(80%)→
Jaccard×0.2 + Cosine×0.8
4.关键函数流程图
4.计算模块接口部分的性能改进
一,分析图
优化前
优化后
二、性能改进思路
通过 VS 2017 性能探查器分析发现,计算模块的性能瓶颈集中在余弦相似度计算和词频统计两个核心环节(占总耗时的 89%)。改进思路围绕"减少冗余计算""优化数据结构""适配长文本场景"三个方向展开:
1. 词频统计优化(解决 get_word_frequency
低效问题)
原始问题:手动循环构建词频字典(if word in dict
判断导致 O(n²) 时间复杂度),在 10000 词文本中耗时占比 21%。
改进方案:改用 collections.Counter
(基于哈希表的高效计数工具),将词频统计从"遍历 + 判断"的双重操作简化为单次遍历,时间复杂度降至 O(n)。
2. 余弦相似度计算流程重构(解决 cosine_similarity
耗时过高问题)
原始问题:词频向量生成与点积计算分两次遍历词汇表,且依赖 numpy
进行平方根计算(小数据集场景存在转换开销),总耗时占比 68%。
改进方案:
- 合并计算步骤:单次遍历完成"词频获取→点积累加→模长平方累加"三步操作,减少 50% 遍历次数;
- 移除外部依赖:用原生
math.sqrt
计算模长,避免数据类型转换开销; - 优化词汇表处理:用集合
union
运算(cnt1.keys() | cnt2.keys()
)替代列表去重,简化词汇表合并逻辑。
3. 长文本分块计算(解决大文本内存峰值问题)
原始问题:处理 50000 词以上文本时,一次性加载全部词列表并生成向量,导致内存峰值超过 800MB,触发频繁 GC。
改进方案:实现分块计算逻辑(每 1000 词为一块),累计各块相似度后取加权平均,内存峰值降至 200MB 以内。
4.程序中消耗最大的函数
改进前后对比
指标 | 改进前 | 改进后 | 提升幅度 |
---|---|---|---|
cosine_similarity 耗时占比 |
68% | 27% | 60.3% |
10000词文本平均处理时间 | 2.3秒 | 0.8秒 | 65.2% |
内存峰值(50000词文本) | 800MB+ | <200MB | 75%+ |
改进后,cosine_similarity
仍是消耗最大的函数(占总耗时 27%),原因如下:
- 算法复杂度本质较高:核心逻辑涉及词频统计、词汇表合并、点积与模长计算等多步操作,本身复杂度为 O(n)(n 为词汇表大小);
- 长文本累计开销:长文本场景下,即使分块处理,仍需对每块执行完整的向量运算,累计开销较高;
- 计算步骤更繁琐:相比
jaccard_similarity
(仅依赖集合操作),余弦相似度需处理词频权重,计算步骤更复杂。
进一步优化方向
尽管已取得显著改进,针对 cosine_similarity
的进一步优化仍可从以下方向考虑:
- 并行计算:对大规模文本的分块计算可采用多线程并行处理;
- 近似算法:对精度要求不高的场景,可引入 MinHash 等近似算法替代精确计算;
- 向量化优化:对超大文本,可考虑使用 NumPy 的向量化操作(虽会增加依赖,但可大幅提升性能);
- 缓存机制:对重复出现的文本片段,可引入缓存机制避免重复计算。
5.计算模块部分单元测试展示
import unittest
import tempfile
import os
from text_processor import process_txt_content
from similarity_calc import jaccard_similarity, cosine_similarity, calculate_final_repeat_rate
from main import read_txt_file, write_result_file
class TestPaperChecker(unittest.TestCase):
def test_text_preprocessing(self):
txt_content = "今天是星期天,天气晴!今天晚上我要去看电影。"
expected = ["星期天", "天气", "晴", "看", "电影"]
self.assertEqual(process_txt_content(txt_content), expected)
def test_jaccard_identical(self):
words1 = ["星期天", "晴", "看电影"]
words2 = ["星期天", "晴", "看电影"]
self.assertEqual(jaccard_similarity(words1, words2), 1.0)
def test_jaccard_different(self):
words1 = ["星期天", "晴", "看电影"]
words2 = ["星期一", "雨", "看书"]
self.assertEqual(jaccard_similarity(words1, words2), 0.0)
def test_cosine_partial(self):
words1 = ["星期天", "晴", "看电影"]
words2 = ["周天", "晴朗", "看电影"]
self.assertGreater(cosine_similarity(words1, words2), 0.2)
def test_final_repeat_rate(self):
words1 = ["星期天", "晴", "看电影"]
words2 = ["星期天", "晴", "看电影"]
self.assertEqual(calculate_final_repeat_rate(words1, words2), 1.0)
def test_empty_both_txt(self):
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f1, \
tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f2, \
tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as res:
cmd = f'python "{os.path.abspath("main.py")}" "{f1.name}" "{f2.name}" "{res.name}"'
os.system(cmd)
with open(res.name, 'r') as f:
self.assertEqual(f.read().strip(), "1.00")
def test_empty_one_txt(self):
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f1, \
tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f2, \
tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as res:
f2.write("今天是星期天,天气晴。")
f2.flush()
f2.close()
cmd = f'python "{os.path.abspath("main.py")}" "{f1.name}" "{f2.name}" "{res.name}"'
os.system(cmd)
with open(res.name, 'r') as f:
self.assertEqual(f.read().strip(), "0.00")
def test_non_txt_input(self):
with tempfile.NamedTemporaryFile(mode='w', suffix='.docx', delete=False) as f:
with self.assertRaises(ValueError):
read_txt_file(f.name)
def test_txt_wrong_encoding(self):
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', encoding='gbk', delete=False) as f:
f.write("今天天气晴。")
f.flush()
f.close()
with self.assertRaises(UnicodeDecodeError) as cm:
read_txt_file(f.name)
self.assertIn("编码错误", str(cm.exception))
def test_long_txt_similarity(self):
txt1 = "Python是解释型、面向对象的高级程序设计语言。1989年由Guido van Rossum发明,1991年发布首个版本,支持跨平台开发,语法简洁易读,适合快速开发。"
txt2 = "Python是面向对象的解释型高级程序设计语言,由Guido van Rossum于1989年创造,1991年推出第一个公开发行版,可跨平台运行,语法简洁易懂,适用于快速开发项目。"
words1 = process_txt_content(txt1, is_long_text=True)
words2 = process_txt_content(txt2, is_long_text=True)
rate = calculate_final_repeat_rate(words1, words2)
self.assertGreaterEqual(rate, 0.85, f"长文本相似度{rate}低于预期0.85")
def test_result_format(self):
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as res:
write_result_file(res.name, 0.856)
with open(res.name, 'r') as f:
self.assertEqual(f.read(), "0.86")
if __name__ == "__main__":
unittest.main()
覆盖率截图
单元测试设计
1. 测试的函数
jaccard_similarity
:基于词集合的相似度计算函数cosine_similarity
:基于词频向量的相似度计算函数calculate_final_repeat_rate
:融合两种算法并动态加权的最终重复率计算函数
2. 测试数据构造思路
边界值覆盖:
- 完全相同文本(预期相似度 1.0)
- 完全不同文本(预期相似度 0.0)
- 空文本(包括"两者都空"和"一者为空"两种子场景)
典型场景模拟:
- 部分重叠文本:构造有交集但不完全相同的词汇集合,验证中间值计算准确性
- 词频差异文本:通过重复词构造不同词频分布,验证余弦相似度对词频的敏感度
- 长/短文本区分:通过控制词数(>50 词为长文本)验证动态加权逻辑
可计算性验证:
- 所有测试数据的预期结果均可通过手工计算得出(如余弦相似度的点积和模长计算)
- 对浮点数结果使用
assertAlmostEqual
而非精确匹配,避免浮点误差影响
6.计算模块部分异常处理说明
1. 空文本处理
场景:输入的文本分词结果为空列表(可能是原始文本为空,或预处理后无有效词汇)。
处理逻辑:
- 若两个文本均为空 → 视为完全相同,返回相似度 1.0
- 若仅一个文本为空 → 视为完全不同,返回相似度 0.0
代码实现:
def test_empty_text_handling(self):
"""测试空文本场景的异常处理逻辑"""
# 场景1:两个文本均为空(预处理后无有效词汇)
self.assertEqual(jaccard_similarity([], []), 1.0)
self.assertEqual(cosine_similarity([], []), 1.0)
# 场景2:仅原文为空(用户误传空文件)
self.assertEqual(jaccard_similarity([], ["人工智能", "机器学习"]), 0.0)
self.assertEqual(cosine_similarity([], ["人工智能", "机器学习"]), 0.0)
# 场景3:仅抄袭文本为空(待查文本无内容)
self.assertEqual(
calculate_final_repeat_rate(["数据结构", "算法"], []),
0.0 # 最终重复率按“完全不同”处理
)
2. 除零异常防护
场景:计算相似度时可能出现分母为零的数学异常(如 Jaccard 相似度的并集为零,余弦相似度的模长为零)。
处理逻辑:
- 提前判断分母为零的条件,返回合理的默认值
- 避免直接进行除法运算导致程序崩溃
代码实现:
def test_division_by_zero_handling(self):
"""测试除零异常的防护逻辑"""
# 场景1:Jaccard相似度并集为零(两个空文本已在空文本异常中处理,此处验证极端非空场景)
# 注:非空文本的并集不可能为零,此处模拟异常边界
words1 = ["a"]
words2 = ["a"]
union_size = len(set(words1) | set(words2)) # 并集大小为1(非零)
self.assertEqual(jaccard_similarity(words1, words2), 1.0) # 正常计算
# 场景2:余弦相似度模长为零(单个文本无有效词汇)
# 向量模长为零 → 词频全为0 → 对应空文本,已在空文本异常中处理
# 此处验证非空文本的模长非零逻辑
words3 = ["a", "a", "b"]
norm1_sq = sum([2**2, 1**2]) # 词频向量[2,1]的模长平方为5(非零)
self.assertGreater(cosine_similarity(words3, words3), 0.999) # 正常计算
# 场景3:模拟模长为零的极端情况(通过空文本触发)
self.assertEqual(cosine_similarity([], ["a"]), 0.0) # 未触发除零错误
3. 长文本内存溢出防护
场景:处理超大型文本(如 10 万词以上)时,一次性加载可能导致内存溢出。
处理逻辑:
- 自动检测文本长度,对超长文本启用分块处理
- 控制单次处理的数据量,避免内存峰值过高
代码实现:
import time
def test_large_text_memory_handling(self):
"""测试超大文本分块处理,避免内存溢出"""
# 场景1:生成10万字超大文本(模拟长篇论文)
large_text1 = ["机器学习", "深度学习", "自然语言处理"] * 16667 # 共50001词
large_text2 = ["机器学习", "强化学习", "计算机视觉"] * 16667 # 共50001词
# 验证分块处理逻辑是否触发
start_time = time.time()
repeat_rate = calculate_final_repeat_rate(large_text1, large_text2)
end_time = time.time()
# 断言1:结果为合理的浮点值(未崩溃)
self.assertIsInstance(repeat_rate, float)
self.assertTrue(0.0 <= repeat_rate <= 1.0)
# 断言2:处理时间在可接受范围内(分块未导致性能急剧下降)
self.assertLess(end_time - start_time, 3.0) # 10万字文本处理时间<3秒
# 场景2:验证分块与不分块结果一致性
small_text1 = large_text1[:1000] # 取前1000词(不分块)
small_text2 = large_text2[:1000]
small_rate = calculate_final_repeat_rate(small_text1, small_text2)
self.assertAlmostEqual(repeat_rate, small_rate, delta=0.05) # 误差<5%
4. 非预期数据类型防护
场景:外部模块传入非列表类型的参数(如字符串、None 等)。
处理逻辑:
- 利用 Python 类型提示明确参数类型
- 在关键节点添加类型检查,抛出类型错误异常
代码实现:
def test_data_type_handling(self):
"""测试非法数据类型的拦截逻辑"""
# 场景1:传入字符串(而非列表)
with self.assertRaises(TypeError) as cm:
jaccard_similarity("人工智能", ["人工智能"])
self.assertIn("输入必须为字符串列表", str(cm.exception))
# 场景2:传入整数列表(而非字符串列表)
with self.assertRaises(TypeError) as cm:
cosine_similarity([1, 2, 3], [1, 2, 3])
self.assertIn("列表元素必须为字符串类型", str(cm.exception))
# 场景3:传入None(而非列表)
with self.assertRaises(TypeError) as cm:
calculate_final_repeat_rate(None, ["数据结构"])
self.assertIn("输入必须为字符串列表", str(cm.exception))
# 场景4:传入混合类型列表
with self.assertRaises(TypeError) as cm:
jaccard_similarity(["a", 123, True], ["a", "b"])
self.assertIn("列表元素必须为字符串类型", str(cm.exception))
5.异常处理效果验证
通过单元测试验证异常处理逻辑的有效性:
def test_exception_handling(self):
# 测试非列表输入
with self.assertRaises(TypeError):
jaccard_similarity("not a list", ["word"])
# 测试混合类型列表
with self.assertRaises(TypeError):
cosine_similarity([123, "word"], ["word"])
# 测试超大文本(验证分块逻辑不崩溃)
large_text = ["word"] * 100000
self.assertIsInstance(calculate_final_repeat_rate(large_text, large_text), float)