软件工程第二次作业-第一次个人编程作业
软件工程第二次作业——第一次个人编程作业
这个作业属于哪个课程 | https://edu.cnblogs.com/campus/gdgy/Class12Grade23ComputerScience |
---|---|
这个作业要求在哪里 | https://edu.cnblogs.com/campus/gdgy/Class12Grade23ComputerScience/homework/13468 |
这个作业的目标 | 实现一个论文查重程序,规范软件开发流程,熟悉GitHub进行源代码管理和学习软件测试 |
github库地址: https://github.com/wodu-dreamy/3123004372
一、PSP表格
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | 20 | 20 |
· Estimate | · 估计这个任务需要多少时间 | 10 | 20 |
Development | 开发 | 500 | 600 |
· Analysis | · 需求分析(包括学习新技术) | 40 | 90 |
· Design Spec | · 生成设计文档 | 20 | 15 |
· Design Review | · 设计复审 | 10 | 15 |
· Coding Standard | · 代码规范(为目前的开发制定合适的规范) | 10 | 10 |
· Design | · 具体设计 | 30 | 30 |
· Coding | · 具体编码 | 170 | 180 |
· Code Review | · 代码复审 | 20 | 30 |
· Test | · 测试(自我测试,修改代码,提交修改) | 100 | 230 |
Reporting | 报告 | 50 | 60 |
· Test Report | · 测试报告 | 20 | 20 |
· Size Measurement | · 计算工作量 | 10 | 15 |
· Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 20 | 20 |
合计 | 555 | 680 |
二、计算模块接口的设计与实现过程
(1)代码组织
- 文件读取函数 read_file():
负责读取不同编码(UTF-8或GBK)的文件内容,自适应处理中文编码问题 - 文本预处理函数 preprocess():
完成文本清洗与分词处理,核心包含:
正则过滤保留有效字符(汉字、字母、数字)
结巴分词(精确模式)
停用词过滤(自定义中文停用词表) - 相似度计算函数 calculate_similarity():
实现核心查重算法,基于词频统计计算重复率 - 主控函数 main():
程序执行入口,负责:
命令行参数解析
文件读取调度
预处理流程控制
结果计算与输出
函数关系(流程图)
(2)算法关键实现
- 文本预处理算法
预处理流程分为三个关键阶段:
字符过滤:
使用正则表达式 [^\w\s\u4e00-\u9fff]
保留汉字(\u4e00-\u9fff)、字母、数字和空白符
移除所有标点符号和特殊字符
中文分词:
采用结巴分词精确模式(cut_all=False)
平衡分词效率与准确率
停用词过滤:
自定义中文停用词表(包含"的"、"了"、"和"等高频虚词)
过滤空字符串和空白字符 - 相似度计算算法
def calculate_similarity(orig_words, plag_words):
# 1. 词频统计
orig_counter = Counter(orig_words)
plag_counter = Counter(plag_words)
# 2. 计算共同词的最小频率和
common_total = 0
for word in orig_counter:
if word in plag_counter:
# 取两文本中该词出现次数的最小值
common_total += min(orig_counter[word], plag_counter[word])
# 3. 计算相似度
total_orig_words = len(orig_words)
return common_total / total_orig_words if total_orig_words else 0
(3)算法独到之处
创新性的相似度计算方法
- 最小频率和模型:计算每个共同词在原文和抄袭版中出现次数的最小值并求和
- 非对称计算:相似度 = 共同词最小频率和 / 原文总词数
- 优势:
更符合抄袭检测场景(抄袭版可能删减原文)
避免抄袭版添加无关内容稀释相似度
对部分抄袭的文本更敏感 - 中文处理优化
汉字保留策略:\u4e00-\u9fff 范围精确匹配CJK统一汉字
结巴分词优化:采用cut_all=False精确模式分词
动态停用词表:自定义中文停用词集合,可灵活扩展
三、计算模块接口部分的性能改进
改进前:
preprocess 函数性能优化方案(针对中文分词效率问题)
基于火焰图分析,preprocess 函数的核心耗时是 jieba.cut 分词(占比98%)。以下从 减少分词计算量、优化 jieba 配置、缓存复用结果 三个方向优化
import jieba
import re
from functools import lru_cache # 用于缓存分词结果(针对重复文本)
def preprocess(text, use_fast_mode=True, cache_size=128):
"""
优化版文本预处理函数:提升中文分词效率,减少重复计算
:param text: 原始文本字符串
:param use_fast_mode: 是否启用jieba快速分词模式(默认True,牺牲少量精度换速度)
:param cache_size: 缓存大小(默认128,缓存最近128个文本的分词结果)
:return: 过滤后的分词列表
"""
# ---------------------- 优化1:减少无效文本处理(提前过滤极短文本) ----------------------
if len(text.strip()) < 10: # 文本长度 <10字符时,无需分词(直接返回空列表,减少计算)
return []
# ---------------------- 优化2:正则表达式简化(减少字符替换开销) ----------------------
# 原正则:re.sub(r'[^\w\s\u4e00-\u9fff]', '', text)(匹配所有非中文/字母/数字/空白字符)
# 优化为预编译正则,减少重复编译开销(对于多次调用preprocess,效率提升明显)
# 预编译正则表达式(放在函数外更佳,但此处为了代码集中展示)
pattern = re.compile(r'[^\w\s\u4e00-\u9fff]') # 只保留中文、字母、数字、空白
text_clean = pattern.sub('', text) # 替换非法字符为空
# ---------------------- 优化3:jieba分词模式选择(速度/精度 trade-off) ----------------------
# 全模式(cut_all=True)比精确模式快30%+,适合对分词精度要求不高的场景
if use_fast_mode:
words = jieba.cut(text_clean, cut_all=True) # 快速模式(全模式)
else:
words = jieba.cut(text_clean, cut_all=False) # 精确模式(原模式)
# ---------------------- 优化4:停用词过滤优化(使用集合+生成器表达式) ----------------------
# 停用词表:使用集合存储(O(1)查询速度,比列表快10倍以上)
stopwords = {
'的', '了', '和', '是', '在', '我', '你', '他', '她', '它',
'我们', '你们', '他们', '这', '那', '就', '都', '也', '与', '或',
'要', '而', '及', '可以', '对', '但', '啊', '呀', '呢', '之', '乎',
'者', '也', '矣', '焉', '哉', '呜呼', '噫', '兮' # 扩展古汉语停用词(按需增删)
}
# 过滤逻辑:用生成器表达式替代列表推导式,减少内存占用(尤其大文本)
filtered_words = (word for word in words if word not in stopwords and word.strip() != '')
# ---------------------- 优化5:缓存重复文本的分词结果(避免重复计算) ----------------------
# 对短文本启用缓存(长文本缓存效果差,且占用内存大,故仅缓存长度<1000的文本)
if len(text_clean) < 1000:
# 使用lru_cache缓存,键为文本内容,值为分词结果(需将生成器转为元组才能缓存)
return _cached_tokenize(tuple(filtered_words))
else:
return list(filtered_words) # 长文本直接返回列表(不缓存)
# ---------------------- 辅助函数:用于缓存短文本的分词结果 ----------------------
@lru_cache(maxsize=cache_size) # cache_size由preprocess的参数控制
def _cached_tokenize(filtered_words_tuple):
"""将过滤后的分词结果(元组)转为列表返回,利用lru_cache缓存重复结果"""
return list(filtered_words_tuple)
# ---------------------- 初始化:提前加载jieba词典(减少首次分词耗时) ----------------------
def init_jieba():
"""程序启动时调用,提前初始化jieba,避免首次分词时加载词典的开销"""
import jieba
jieba.initialize() # 显式初始化词典(默认词典约2MB,加载耗时~0.3秒,仅需执行一次)
# 程序启动时自动初始化jieba(放在模块加载时执行)
init_jieba()
改进后:
通过对比优化前的火焰图(原 preprocess 耗时 0.516秒),可以清晰看到优化效果:preprocess 耗时从0.516秒降至0.0408秒,性能提升92%
四、计算模块部分单元测试展示
测试文件读取函数read_file
测试目标:验证read_file能否正确读取utf-8/gbk编码的文件,以及处理文件不存在、无权限、读取目录等异常情况。 测试数据思路: 模拟utf-8编码的文件内容(正常情况); 模拟gbk编码的文件内容(兼容情况,utf-8解码失败后回退gbk); 模拟文件不存在(异常情况); 模拟文件无读取权限(异常情况); 模拟读取目录(异常情况)。
import pytest
from unittest.mock import mock_open, patch
from main import read_file
def test_read_file_utf8_success():
"""测试读取utf-8编码文件(成功)"""
content = "测试文本"
with patch('builtins.open', mock_open(read_data=content)) as mock_file:
result = read_file('test_utf8.txt')
assert result == content
mock_file.assert_called_once_with('test_utf8.txt', 'r', encoding='utf-8')
def test_read_file_gbk_success():
"""测试读取gbk编码文件(成功,utf-8失败后回退)"""
expected = "测试文本"
with patch('builtins.open') as mock_file:
# 首次调用utf-8解码失败,二次调用gbk成功
mock_file.side_effect = [
UnicodeDecodeError('utf-8', b'', 0, 1, 'invalid'),
mock_open(read_data=expected).return_value
]
result = read_file('test_gbk.txt')
assert result == expected
assert mock_file.call_count == 2
mock_file.assert_any_call('test_gbk.txt', 'r', encoding='utf-8')
mock_file.assert_any_call('test_gbk.txt', 'r', encoding='gbk')
def test_read_file_not_exists():
"""测试读取不存在的文件(退出程序)"""
with patch('builtins.open', side_effect=FileNotFoundError):
with pytest.raises(SystemExit) as excinfo:
read_file('not_exists.txt')
assert excinfo.value.code == 1
def test_read_file_permission_error():
"""测试读取无权限的文件(退出程序)"""
with patch('builtins.open', side_effect=PermissionError):
with pytest.raises(SystemExit) as excinfo:
read_file('no_permission.txt')
assert excinfo.value.code == 1
def test_read_file_is_directory():
"""测试读取目录(退出程序)"""
with patch('builtins.open', side_effect=IsADirectoryError):
with pytest.raises(SystemExit) as excinfo:
read_file('/tmp')
assert excinfo.value.code == 1
测试文本预处理函数preprocess
测试目标:验证preprocess能否正确处理文本(短文本过滤、长文本处理)、停用词过滤、特殊字符清理,以及缓存逻辑(短文本缓存、长文本不缓存)。
测试数据思路:
- 短文本(len(text.strip()) < 10,返回空列表);
- 文本strip后长度刚好10(不返回空列表);
- 含停用词/特殊字符的正常文本(过滤正确);
- 全为停用词的文本(返回空列表);
- 使用精确分词模式(use_fast_mode=False,不拆分常用词);
- 长文本(len(text_clean) > 1000,不缓存);
- 文本clean后长度刚好1000(不缓存)。
from main import preprocess
def test_preprocess_short_text():
"""测试短文本(len(text.strip()) < 10,返回空列表)"""
text = "短文本" # len=3
result = preprocess(text)
assert result == []
def test_preprocess_text_strip_exact_10():
"""测试text.strip()长度刚好10(不返回空列表)"""
text = "a" * 10 # len=10
result = preprocess(text)
assert result == ["a"] * 10
def test_preprocess_normal_text():
"""测试正常文本(含停用词/特殊字符,过滤正确)"""
text = "我今天@#去了公园,看到了美丽的花!"
expected = ["今天", "去", "公园", "看到", "美丽", "花"]
result = preprocess(text)
assert result == expected
def test_preprocess_stopwords_only():
"""测试全为停用词的文本(返回空列表)"""
text = "的了和是在"
result = preprocess(text)
assert result == []
def test_preprocess_use_fast_mode_false():
"""测试use_fast_mode=False(精确分词,不拆分常用词)"""
text = "今天天气很好"
result = preprocess(text, use_fast_mode=False)
assert "今天" in result
assert "天气" in result
def test_preprocess_long_text():
"""测试长文本(len(text_clean) > 1000,不缓存)"""
text = "今天天气很好" * 500 # len=3500
expected = ["今天", "天气", "很好"] * 500
result = preprocess(text)
assert result == expected
def test_preprocess_text_clean_exact_1000():
"""测试text_clean长度刚好1000(不缓存)"""
text = "a" * 1000 # len=1000
result = preprocess(text)
assert len(result) == 1000
测试相似度计算函数calculate_similarity
测试目标:验证calculate_similarity能否正确计算两段文本的相似度,覆盖原文/抄袭版为空、无共同词、部分共同词、完全相同、重复词(取词频最小值)、抄袭版词频高于原文等场景。
测试数据思路:
- 原文为空(返回0);
- 抄袭版为空(返回0);
- 无共同词(返回0);
- 部分共同词(计算正确);
- 完全相同(返回1);
- 重复词(取词频最小值);
- 抄袭版词频高于原文(取原文词频)。
from main import calculate_similarity
def test_calculate_similarity_orig_empty():
"""测试原文为空(返回0)"""
assert calculate_similarity([], ["a", "b"]) == 0.0
def test_calculate_similarity_plag_empty():
"""测试抄袭版为空(返回0)"""
assert calculate_similarity(["a", "b"], []) == 0.0
def test_calculate_similarity_no_common():
"""测试无共同词(返回0)"""
assert calculate_similarity(["a", "b"], ["c", "d"]) == 0.0
def test_calculate_similarity_partial_common():
"""测试部分共同词(计算正确)"""
orig = ["今天", "天气", "很好"]
plag = ["今天", "天气", "不错"]
assert pytest.approx(calculate_similarity(orig, plag), 0.01) == 0.67
def test_calculate_similarity_fully_common():
"""测试完全相同(返回1)"""
orig = ["今天", "天气", "很好"]
plag = ["今天", "天气", "很好"]
assert calculate_similarity(orig, plag) == 1.0
def test_calculate_similarity_repeated_words():
"""测试重复词(计算词频最小值)"""
orig = ["苹果", "苹果", "香蕉"] # 苹果出现2次
plag = ["苹果", "苹果", "葡萄"] # 苹果出现2次
assert pytest.approx(calculate_similarity(orig, plag), 0.01) == 0.67
def test_calculate_similarity_plag_more_repeated():
"""测试抄袭版词频高于原文(取原文词频)"""
orig = ["苹果", "苹果", "香蕉"] # 苹果出现2次
plag = ["苹果", "苹果", "苹果"] # 苹果出现3次
assert pytest.approx(calculate_similarity(orig, plag), 0.01) == 0.67
测试缓存分词函数_cached_tokenize
测试目标:验证_cached_tokenize的缓存逻辑,覆盖缓存未命中、命中、不同参数未命中场景。
测试数据思路:
- 首次调用相同参数(缓存未命中);
- 二次调用相同参数(缓存命中);
- 调用不同参数(缓存未命中)
from main import _cached_tokenize
def test_cached_tokenize_miss():
"""测试缓存未命中(首次调用)"""
tuple_words = ("今天", "天气")
result = _cached_tokenize(tuple_words)
assert result == ["今天", "天气"]
assert _cached_tokenize.cache_info().misses == 1
assert _cached_tokenize.cache_info().hits == 0
def test_cached_tokenize_hit():
"""测试缓存命中(二次调用相同参数)"""
tuple_words = ("今天", "天气")
_cached_tokenize(tuple_words) # 首次调用(miss)
result = _cached_tokenize(tuple_words) # 二次调用(hit)
assert result == ["今天", "天气"]
assert _cached_tokenize.cache_info().hits == 1
def test_cached_tokenize_different_params():
"""测试不同参数(缓存未命中)"""
tuple1 = ("今天", "天气")
tuple2 = ("明天", "晴天")
_cached_tokenize(tuple1) # miss
result = _cached_tokenize(tuple2) # miss
assert result == ["明天", "晴天"]
assert _cached_tokenize.cache_info().misses == 2
覆盖报告图
五、计算模块部分异常处理说明
(一)文件读取模块:处理“文件不存在”与“无权限”异常
文件读取是项目的第一步,需应对用户输入错误(如路径错误)或系统限制(如权限不足)的场景。
1、异常类型:文件不存在(FileNotFoundError)
设计目标: 当用户输入的文件路径不存在时,程序应避免崩溃,提示明确错误信息(如“无法读取文件:路径不存在”),并正确退出(退出码1,表示错误)。
单元测试样例: 验证文件不存在时,程序是否捕获异常并退出
import pytest
from unittest.mock import patch
from main import read_file
def test_read_file_not_exists():
"""测试读取不存在的文件(应退出程序)"""
# 模拟`open`函数抛出`FileNotFoundError`
with patch('builtins.open', side_effect=FileNotFoundError):
# 验证程序是否退出(退出码1)
with pytest.raises(SystemExit) as excinfo:
read_file('not_exists.txt')
assert excinfo.value.code == 1
真实错误场景: 用户误将原文路径输入为D:/orig1.txt(实际文件为D:/orig.txt),此时程序应输出
错误:无法读取文件 not_exists.txt,原因:[Errno 2] No such file or directory: 'not_exists.txt'
2、异常类型:无权限读取文件(PermissionError)
设计目标: 当用户没有文件访问权限(如文件设为“只读”)时,程序应提示权限错误,停止执行,避免非法操作。
单元测试样例: 验证无权限时,程序是否正确处理
def test_read_file_permission_error():
"""测试读取无权限的文件(应退出程序)"""
with patch('builtins.open', side_effect=PermissionError):
with pytest.raises(SystemExit) as excinfo:
read_file('no_permission.txt')
assert excinfo.value.code == 1
真实错误场景: 文件D:/orig.txt被管理员设置为“只读”,用户尝试修改该文件时,程序应输出:
错误:无法读取文件 no_permission.txt,原因:[Errno 13] Permission denied: 'no_permission.txt'
(二)相似度计算模块:处理“原文为空”异常
相似度计算需避免“除以零”错误(如原文为空时,total_orig_words = 0)。
异常类型:原文为空(orig_words == [])
设计目标: 当原文为空时(如原文文件为空,或预处理后无有效词汇),相似度返回0,避免崩溃。
单元测试样例: 验证原文为空时,相似度计算返回0.0
def test_calculate_similarity_orig_empty():
"""测试原文为空(返回0)"""
orig_words = [] # 原文为空
plag_words = ["a", "b"] # 抄袭文本非空
assert calculate_similarity(orig_words, plag_words) == 0.0 # 验证返回0
真实错误场景: 原文文件D:/orig.txt为空,此时程序应返回重复率0.0,避免因total_orig_words = 0导致的ZeroDivisionError崩溃。
(三)文本预处理模块:处理“短文本”异常
文本预处理需过滤无意义的短文本(如“你好”),避免无价值的计算。
异常类型:短文本(len(text.strip()) < 10)
设计目标: 当输入文本过短时(如小于10个字符),跳过预处理(返回空列表),减少无效计算(如短文本的分词、缓存)。
单元测试样例: 验证短文本是否返回空列表
def test_preprocess_short_text():
"""测试短文本(len(text.strip()) < 10,返回空列表)"""
text = "短文本" # 长度3,小于10
result = preprocess(text)
assert result == [] # 验证返回空列表