第一次个人编程作业
论文查重项目报告
一、 作业信息:
这个作业属于哪个课程 | https://edu.cnblogs.com/campus/gdgy/Class34Grade23ComputerScience/ |
---|---|
这个作业要求在哪里 | https://edu.cnblogs.com/campus/gdgy/Class34Grade23ComputerScience/homework/13477 |
这个作业的目标 | 实现一个论文查重程序,并且熟悉项目开发的流程 |
github仓库链接 | https://github.com/yjq-yes/3123004811 |
二、 PSP表格:
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | 20 | 25 |
· Estimate | · 估计这个任务需要多少时间 | 10 | 10 |
Development | 开发 | 180 | 190 |
· Analysis | · 需求分析 (包括学习新技术) | 30 | 40 |
· Design Spec | · 生成设计文档 | 40 | 50 |
· Design Review | · 设计复审 | 25 | 30 |
· Coding Standard | · 代码规范 (为目前的开发制定合适的规范) | 20 | 23 |
· Design | · 具体设计 | 35 | 47 |
· Coding | · 具体编码 | 150 | 180 |
· Code Review | · 代码复审 | 35 | 43 |
· Test | · 测试(自我测试,修改代码,提交修改) | 60 | 66 |
Reporting | 报告 | 120 | 150 |
· Test Report | · 测试报告 | 35 | 45 |
· Size Measurement | · 计算工作量 | 30 | 33 |
· Postmortem & Process Improvement Plan | · 事后总结, 并提出过程改进计划 | 50 | 57 |
合计 | - | 840 | 989 |
三、题目需求
题目:论文查重
描述如下:
设计一个论文查重算法,给出一个原文文件和一个在这份原文上经过了增删改的抄袭版论文的文件,在答案文件中输出其重复率。
- 原文示例:今天是星期天,天气晴,今天晚上我要去看电影。
- 抄袭版示例:今天是周天,天气晴朗,我晚上要去看电影。
要求输入输出采用文件输入输出,规范如下:
- 从命令行参数给出:论文原文的文件的绝对路径。
- 从命令行参数给出:抄袭版论文的文件的绝对路径。
- 从命令行参数给出:输出的答案文件的绝对路径。
我们提供一份样例,课堂上下发,上传到班级群,使用方法是:orig.txt是原文,其他orig_add.txt等均为抄袭版论文。
注意:答案文件中输出的答案为浮点型,精确到小数点后两位
测试需求:
在进行代码测试的时候,以Windows环境为例(但并不意味着程序一定在Windows环境下进行测试),我们是按照传递命令行参数的方式提供文件的位置,您的提交的作业程序需要从指定的位置读取文件,并向指定的文件输出答案:
- Python: python main.py [原文文件] [抄袭版论文的文件] [答案文件]
- C: main.exe [原文文件] [抄袭版论文的文件] [答案文件]
- Java: java -jar main.jar [原文文件] [抄袭版论文的文件] [答案文件]
保证每个参数以空格隔开,文件路径中不含有空格,例如:
- java -jar main.jar C:\tests\org.txt C:\tests\org_add.txt C:\tests\ans.txt
——至于如何在程序里接受命令行参数可以去搜索一下
Python需要将入口文件名设置成main.py,C/C++需要提供可执行文件main.exe,Java需要提供编译好的jar包main.jar
一共有18个测试点(不含样例),测试作为评判功能是否正常的依据,全过就没问题,不过要扣分。
——复制别人的可执行程序进行提交骗过测试的行为同样是抄袭,我们会验证你的代码是否可以正常编译、以及是否可以编译出(与你提交的可执行文件)逻辑相同的可执行文件
如遇到下列情况之一,当前测试点将不能通过,对于每个不能通过的测试点从程序评分中扣2分:
- 程序内存泄漏严重
- 5秒内未给出答案
- 占用的内存超过2048MB
- 发生异常退出
凡提交的可执行文件、出现下列情况之一者,作业以0分计:
- 尝试连接网络
- 尝试读写其他文件
- 尝试妨碍评测——例如: system("shutdown")
效果展示:同时相当容易使用:
四、论文查重程序完整实现流程:
模块框架:
模块文件 | 核心函数/类 | 功能描述 |
---|---|---|
src/io_manager.py | FileManager (类) | 统一管理文件I/O操作 |
read_from_file(path) | 读取文件内容,处理编码和文件不存在错误 | |
write_to_file(path, data) | 将数据写入结果文件,保持两位小数精度 | |
src/text_cleaner.py | TextProcessor (类) | 统一管理文本预处理操作 |
clean_and_normalize(text) | 去除标点、特殊字符,转换为小写 | |
tokenize_and_filter(text) | 使用jieba分词并过滤停用词和单字符 | |
process(text) | 整合清洗、分词、过滤,返回处理后的词语列表 | |
src/core_algorithm.py | calculate_similarity(text_a, text_b) | 整合TF-IDF向量化与余弦相似度计算 |
main.py | main() | 解析命令行参数,串联各模块执行查重流程 |
setup_arg_parser() | 配置命令行参数解析器 | |
execute_analysis(args) | 执行核心分析流程(被main()调用) |
整个流程由 main.py
文件作为总指挥,依次调用其他模块完成特定任务。
步骤 1:启动与参数解析 ( main.py
)
- 您在终端执行命令
python main.py [原文路径] [抄袭版路径] [输出路径]
。 - 程序入口
if __name__ == "__main__":
启动main()
函数。 main()
函数首先调用setup_arg_parser()
,它会使用argparse
模块解析您在命令行输入三个文件路径以及可选的--profile
和--visualize
参数。
步骤 2:读取文件内容 ( main.py
-> io_manager.py
)
- 主流程进入
execute_analysis()
函数。 - 该函数创建
FileManager
类的一个实例(来自io_manager.py
)。 - 程序调用
file_handler.read_from_file()
方法两次,分别读取原文和抄袭版论文的完整文本内容。 - 在
io_manager.py
中,read_from_file
方法会:- 使用
pathlib
检查文件是否存在且确实是一个文件。 - 以
utf-8
编码格式读取文件所有内容,并将其作为字符串返回。
- 使用
步骤 3:文本预处理 ( main.py
-> text_cleaner.py
)
-
execute_analysis()
函数创建TextProcessor
类的一个实例(来自text_cleaner.py
)。 -
在
text_cleaner.py
中,TextProcessor
在初始化 (__init__
) 时,会自动尝试加载paper_dict.txt
(自定义词典) 和stopwords.txt
(停用词表)。 -
程序调用
text_processor.process()
方法两次,对原文和抄袭版的字符串进行处理。这个process
方法内部执行了两个关键步骤: 清洗和规范化 (
clean_and_normalize
): 遍历文本,移除所有非中文、字母、数字的字符,并将所有英文字母转为小写。 分词和过滤 (
tokenize_and_filter
): 使用jieba
库对清洗后的字符串进行精确分词,然后移除掉停用词表里的词以及大部分单个字。 -
process()
方法最终返回一个处理干净的词语列表(list of strings
)。
步骤 4:核心算法计算相似度 ( main.py
-> core_algorithm.py
)
- 回到
main.py
,程序将上一步得到的词语列表用空格拼接成一个长字符串,这是为了满足TF-IDF向量化工具的输入要求。 - 程序调用
calculate_similarity()
函数(来自core_algorithm.py
),传入两个处理后的文本字符串。 - 在
core_algorithm.py
中,calculate_cosine_similarity
函数会:- 创建一个
TfidfVectorizer
实例,它可以将文本转换成基于TF-IDF权重的数学向量。 - 使用
fit_transform()
方法将两段文本字符串转换成一个[2 x N]
的向量矩阵,N是两篇文章总的不重复词语数。 - 最后,调用
cosine_similarity()
函数计算这两个向量之间的余弦相似度,得到一个0
到1
之间的浮点数。
- 创建一个
步骤 5:输出结果 ( main.py
-> io_manager.py
)
execute_analysis()
函数拿到计算出的相似度得分。- 它最后调用
file_handler.write_to_file()
方法,将得分写入您指定的输出文件。 - 在
io_manager.py
中,write_to_file
方法会将得分格式化为两位小数的字符串(例如0.81
),然后写入文件。 - 程序在屏幕上打印 "分析完成!" 并正常退出。
test_suite.py
的角色
需要注意的是,test_suite.py
文件不参与上述的正常执行流程。它是一个独立的测试脚本,其作用是调用 text_cleaner.py
和 core_algorithm.py
中的函数,并自动验证这些函数的功能是否都符合预期,以此来保证代码的质量和正确性。
总流程图:
项目设计的独到之处:
模块文件 | 独到之处与核心价值 |
---|---|
main.py | 流程控制与扩展性中心: |
1. 统一入口:作为整个程序的唯一启动点,清晰地定义了查重分析的完整执行流程。 | |
2. 参数化配置:通过argparse,允许用户灵活指定输入、输出文件。 | |
3. 集成性能分析与可视化:通过--profile和--visualize参数,提供了一键式性能分析和图形化展示的功能,极大地增强了工具的实用性和可调试性,这是常规CLI工具不常见的。 | |
4. 健壮的错误处理:集成了全局的异常捕获机制,保证了程序在遇到文件、数据或运行问题时的优雅退出和错误提示。 | |
5. 松散耦合:仅通过调用其他模块的公共接口来串联流程,避免了模块间的硬编码依赖,使得主程序仅负责调度,便于后期维护和功能扩展。 | |
src/io_manager.py | 健壮的文件I/O抽象: |
1. 集中式错误处理:将文件读取、写入可能出现的FileNotFoundError、UnicodeDecodeError等I/O相关错误集中封装处理,提高了程序的鲁棒性。 | |
2. 文件路径验证:在执行I/O操作前进行路径存在性及有效性检查,防止不必要的资源消耗和程序崩溃。 | |
3. 结果标准化输出:对输出结果(相似度得分)进行统一的格式化处理(例如保留两位小数),保证了输出结果的一致性和美观性。 | |
4. 严格遵循单一职责原则:严格限定其只负责I/O操作,使得该模块功能清晰、独立,易于单独测试和在其他项目中复用。 | |
src/text_cleaner.py | 智能与可配置的文本预处理器: |
1. 领域特定优化:通过加载自定义词典(paper_dict.txt),显著提升了jieba分词器对论文领域(如“余弦相似度”、“TF-IDF”)专业词汇的识别准确性,解决了通用分词器在特定领域表现不佳的问题。 | |
2. 灵活的停用词管理:采用外部停用词表(stopwords.txt)进行过滤,用户可以根据不同的语料库或需求轻松调整和优化停用词,而无需修改代码。 | |
3. 全面的文本规范化:不仅去除标点、特殊字符,还进行了大小写统一、全半角转换等操作,确保文本数据的一致性,为后续的向量化算法提供高质量输入。 | |
4. 集成化处理接口:process方法将清洗、分词、过滤等多个步骤整合为一个简洁的统一接口,降低了主程序调用的复杂性。 | |
5. 多语言处理潜力:清洗和规范化逻辑(如统一小写、去除特殊符号)具有通用性,为未来扩展到其他语言提供了基础。 | |
src/core_algorithm.py | 高效与准确的相似度核心: |
1. 智能权重分配(TF-IDF):通过TfidfVectorizer,算法不仅判断词语是否存在,更会根据词语在当前文档中的频率(TF)和在所有文档中的逆文档频率(IDF)为其分配权重,使得相似度计算更具语义深度和区分度,突出关键术语。 | |
2. 稳健的相似度度量(余弦相似度):选用余弦相似度作为核心度量标准,其优点在于不受文本长度影响,仅关注文档向量方向的相似性,能够准确反映文档主题的接近程度,是文本相似度任务的经典且高效算法。 | |
3. 高度模块化与独立性:该模块仅负责算法核心计算,不依赖任何I/O或文本预处理细节,仅接收处理后的文本字符串,使其专注于核心功能,易于进行算法的替换、升级或在其他项目中复用,体现了高内聚低耦合的设计原则。 |
五、模块的进一步改进:
使用main.py 可选择的profile + visual参数 进行分析:在 snakeviz
中,会看到一张如下所示的火焰图 (Flame Graph) 或旭日图 (Sunburst Chart)。图的宽度代表了函数占用的总时间。越宽的条目,表示其消耗的CPU时间越长,是我们需要重点关注的性能瓶颈。
让我们来解读这张图:
- 总耗时: 程序的核心逻辑
execute_analysis
总共花费了0.452
秒。 - 最大的消耗来源: 在
execute_analysis
之下,绝大部分宽度(时间)被左侧的蓝色和橙色长条占据。这部分调用链的起点是text_cleaner.py
中的__init__
方法(即TextProcessor
类的构造函数),它花费了0.350
秒。 - 追根溯源: 这个
__init__
的耗时几乎完全来自于_load_custom_dict
->jieba.load_userdict
-> ... ->jieba.initialize
->marshal.load
。marshal.load
是一个内置方法,用于从磁盘读取并加载jieba
预编译的词典缓存文件 (dict.txt.big.txt.bz2
的缓存)。 - 文本处理耗时: 图右侧相对窄得多的绿色部分是
text_cleaner.py
中的process
方法,它代表了实际的文本清洗、分词等操作,总共只花费了0.0848
秒。 - 被忽略的部分: 核心算法
calculate_similarity
耗时极短,以至于在这张图中几乎不可见。这说明对于您本次运行的输入文件来说,TF-IDF和余弦相似度的计算成本远低于jieba
的初始化成本。
程序当前最主要的性能瓶颈是 jieba
分词库的初始化过程,它发生在 TextProcessor
对象被创建时。
这 0.350
秒的消耗是一次性的 I/O(磁盘读取)和反序列化操作。之所以它在总耗时中占比极高(0.350s / 0.452s ≈ 77%
),很可能是因为用于测试的两个 .txt
文件非常小。如果输入文件有几百兆字节,那么process
方法和calculate_similarity
方法的耗时会显著增加,届时瓶颈才可能会转移到它们身上。
针对当前瓶颈的改进思路:
既然我们已经确定了真正的瓶颈,那么优化思路也应随之改变。
- 问题: 当前的设计是在
execute_analysis
函数内部创建TextProcessor
实例 (text_processor = TextProcessor()
)。这意味着,如果我们的程序设计需要多次调用execute_analysis
,那么每次调用都会触发一次昂贵的jieba
初始化。 - 改进思路: 将
TextProcessor
对象的创建提升到更高层级,确保在程序的整个生命周期中只初始化一次。 这样,0.350
秒的启动成本就只会在程序开始时支付一次,后续的分析调用将不再有此开销。
修改前的 main.py
结构:
Python
# main.py
def execute_analysis(args):
"""执行文本相似度分析的核心流程"""
file_handler = FileManager()
# 每次调用本函数都会创建一个新的实例,导致jieba重复初始化
text_processor = TextProcessor()
# ... 后续处理
original_tokens = text_processor.process(original_content)
plagiarized_tokens = text_processor.process(plagiarized_content)
# ...
def main():
# ...
execute_analysis(args)
修改后的 main.py
结构:
Python
# main.py
def execute_analysis(args, file_handler, text_processor):
"""
执行文本相似度分析的核心流程。
现在接收预先初始化好的对象作为参数。
"""
print("步骤 1/4: 读取文件...")
original_content = file_handler.read_from_file(args.original_file)
# ...
print("步骤 2/4: 预处理文本...")
original_tokens = text_processor.process(original_content)
# ...
def main():
"""程序主入口"""
parser = setup_arg_parser()
args = parser.parse_args()
# --- 优化点 ---
# 在程序主入口处创建实例,确保只执行一次
print("正在初始化文件管理器和文本处理器 (一次性操作)...")
file_handler = FileManager()
text_processor = TextProcessor()
print("初始化完成。")
# 将创建好的实例传入核心函数
execute_analysis(args, file_handler, text_processor)
if __name__ == "__main__":
main()
修改后的效果:
最新性能分析:
- 总耗时显著降低:核心函数
execute_analysis
的执行时间已经从之前的0.452
秒大幅降低到了现在的0.101
秒。这是一个巨大的性能提升。 - 瓶颈成功转移:从图中可以看到,之前占据了
77%
时间的text_cleaner
的__init__
初始化过程已经完全消失了。这证明将TextProcessor
对象的创建移出核心函数是完全正确的优化。 - 新的性能瓶颈:现在,几乎全部的执行时间(
0.0950
秒,约占总时间的 94%)都集中在text_cleaner.py
的process
方法上。- 再往下追溯,
process
方法的耗时又几乎完全来自于tokenize_and_filter
函数。 - 最终,性能消耗的根源指向了
jieba
库的lcut
函数及其内部的一系列分词算法(如_cut_DAG
,viterbi
等)。
- 再往下追溯,
结论:
这已经是一个是一个非常理想和“健康”的性能剖面图了。它表明程序的时间都花在了刀刃上,即执行必要的文本处理任务,而不是浪费在重复的准备工作上。
对于当前以 jieba
分词为核心的瓶颈,通常来说:
- 这是预期结果:对于任何NLP(自然语言处理)任务,文本分词本身就是一个计算密集型操作,它成为瓶颈是正常的。
- 优化空间有限:
jieba
已经是性能相当高的Python分词库了。要在此基础上进一步大幅优化,通常需要采用更底层的方法,例如:- 使用C++等语言重写分词模块。
- 探索是否有支持多线程或GPU加速的特殊分词库。
本次优化成功。
六、测试用例:
本节采用pytest与pytest-cov库:其中:pytest库是用于测试的常用包,pytest-cov是为了方便使用浏览器前端展示调试命中率
本节针对三个py文件的核心代码的测试
-
首先是 对于io_manager文件的测试:我的io_manager考虑了非常多的异常情况。为了确认我的io_manager的鲁棒性,我采用了非常多的测试用例,考虑了各种情况。
def test_write_and_read_file(tmp_path): """ 测试 'happy path':成功写入和读取文件 """ # 1. 准备 file_manager = FileManager() test_file = tmp_path / "output.txt" # 在临时文件夹中定义一个文件路径 score_to_write = 0.95 # 2. 执行写入操作 file_manager.write_to_file(str(test_file), score_to_write) # 3. 执行读取操作并断言 content_read = file_manager.read_from_file(str(test_file)) # 断言写入的内容和读取的内容是否一致 # 注意:你的write_to_file会格式化为两位小数 assert content_read == "0.95" def test_read_non_existent_file(): """ 测试 'error path':读取一个不存在的文件应该抛出 FileNotFoundError """ file_manager = FileManager() # 使用 pytest.raises 来断言特定的异常是否被抛出 with pytest.raises(FileNotFoundError): file_manager.read_from_file("a_file_that_does_not_exist.txt") def test_read_from_directory_raises_error(tmp_path): """ 测试当路径是文件夹时,read_from_file应抛出ValueError """ file_manager = FileManager() # tmp_path本身就是一个文件夹路径 with pytest.raises(ValueError): file_manager.read_from_file(str(tmp_path)) def test_write_to_file_permission_error(tmp_path, monkeypatch): """ 测试当没有写入权限时,write_to_file应抛出PermissionError """ file_manager = FileManager() test_file = tmp_path / "protected.txt" # 使用monkeypatch模拟os.path.exists的行为,使其总是返回True def mock_write_text(*args, **kwargs): raise PermissionError("Permission denied") # 让 Path.write_text 的行为临时变成我们模拟的函数 monkeypatch.setattr(Path, "write_text", mock_write_text) with pytest.raises(PermissionError): file_manager.write_to_file(str(test_file), 0.5) def test_read_file_permission_error(tmp_path, monkeypatch): """ 测试当没有读取权限时,read_from_file应抛出PermissionError """ file_manager = FileManager() test_file = tmp_path / "protected_read.txt" test_file.touch() # 创建一个空文件 # 模拟 Path.read_text 方法,让它直接抛出 PermissionError def mock_read_text(*args, **kwargs): raise PermissionError("os-level permission denied on read") monkeypatch.setattr(Path, "read_text", mock_read_text) with pytest.raises(PermissionError): file_manager.read_from_file(str(test_file)) def test_read_file_unicode_decode_error(tmp_path): """ 测试当文件编码不是UTF-8时,read_from_file应抛出UnicodeDecodeError """ file_manager = FileManager() test_file = tmp_path / "bad_encoding.txt" # 使用GBK编码写入一段中文文本,UTF-8解码器将会失败 text_gbk = "你好世界".encode('gbk') test_file.write_bytes(text_gbk) with pytest.raises(UnicodeDecodeError): file_manager.read_from_file(str(test_file)) def test_write_to_file_generic_exception(tmp_path, monkeypatch): """ 测试当发生未知错误时,write_to_file应抛出IOError """ file_manager = FileManager() test_file = tmp_path / "generic_error_file.txt" # 模拟一个预料之外的错误,例如磁盘满了 (OSError) def mock_write_text_generic_error(*args, **kwargs): raise OSError("Disk is full") monkeypatch.setattr(Path, "write_text", mock_write_text_generic_error) # 你的代码应该捕获这个OSError并将其包装为IOError with pytest.raises(IOError): file_manager.write_to_file(str(test_file), 0.5) def test_read_file_generic_exception(tmp_path, monkeypatch): """ 测试当发生未知读取错误时,read_from_file应抛出IOError """ file_manager = FileManager() test_file = tmp_path / "generic_read_error.txt" test_file.touch() def mock_read_text_error(*args, **kwargs): raise OSError("A generic OS error occurred") monkeypatch.setattr(Path, "read_text", mock_read_text_error) with pytest.raises(IOError): file_manager.read_from_file(str(test_file))
-
test_write_and_read_file
-
测试目的: 验证最核心、最正常的流程,即“成功路径”(Happy Path)。
-
测试思路:
- 准备 (Arrange): 创建一个
FileManager
实例,并利用pytest
的tmp_path
fixture(一个临时的目录)来生成一个安全、隔离的文件路径。 - 执行 (Act): 调用
file_manager.write_to_file()
方法,将一个浮点数0.95
写入这个临时文件。 - 断言 (Assert): 接着,调用
file_manager.read_from_file()
方法从同一个文件中读取内容。最后,使用assert
语句验证读取到的内容是否与预期完全一致。这里特别注意到了write_to_file
可能会将数字格式化为两位小数的字符串"0.95"
,这是一个很好的细节。
- 准备 (Arrange): 创建一个
-
-
test_read_non_existent_file
- 测试目的: 验证一个典型的“错误路径”(Error Path),即当尝试读取一个根本不存在的文件时,程序是否会按预期失败。
- 测试思路:
- 准备: 创建
FileManager
实例。 - 执行与断言: 使用
pytest.raises(FileNotFoundError)
上下文管理器。这个上下文管理器会“捕获”代码块内抛出的异常。测试代码尝试去读取一个硬编码的不存在的文件名。 - 验证: 如果
read_from_file
方法在执行时正确地抛出了FileNotFoundError
异常,那么测试通过。如果它没有抛出任何异常,或者抛出了其他类型的异常,测试将失败。
- 准备: 创建
-
test_read_from_directory_raises_error
- 测试目的: 验证另一个错误场景:当提供给
read_from_file
的路径是一个文件夹而不是文件时,程序的行为是否正确。 - 测试思路:
- 准备: 创建
FileManager
实例,并再次使用tmp_path
。tmp_path
本身就是一个目录路径。 - 执行与断言: 同样使用
pytest.raises(ValueError)
上下文管理器,包裹对file_manager.read_from_file(str(tmp_path))
的调用。 - 验证: 测试预期
read_from_file
方法内部会检查路径类型,并在发现是目录时抛出ValueError
(或类似的逻辑错误异常)。
- 准备: 创建
- 测试目的: 验证另一个错误场景:当提供给
-
test_write_to_file_permission_error
- 测试目的: 模拟一个更底层的系统错误:当程序没有权限向某个文件写入时,是否能正确处理并向上层报告错误。
- 测试思路:
- 准备: 准备
FileManager
和一个临时文件路径。 - 模拟 (Mocking): 这是测试的关键。它使用了
monkeypatch
fixture 来动态地、临时地替换掉底层文件操作的行为。这里,它将pathlib.Path
对象的write_text
方法替换成了一个自定义的mock_write_text
函数。这个模拟函数不做任何实际的写入,而是直接raise PermissionError
。 - 执行与断言: 在
pytest.raises(PermissionError)
的保护下,调用file_manager.write_to_file
。由于底层的写入操作已经被替换,这次调用必然会触发模拟的PermissionError
。 - 验证: 如果异常被成功捕获,测试通过。这证明
write_to_file
方法没有“吞掉”这个权限错误,而是正确地将它传递了出去。
- 准备: 准备
-
test_read_file_permission_error
- 测试目的: 与上一个类似,但这次是测试在没有读取权限的情况下,
read_from_file
的行为。 - 测试思路:
- 准备: 创建
FileManager
和一个临时文件(使用.touch()
创建一个空文件以确保它存在)。 - 模拟: 再次使用
monkeypatch
,这次是替换Path.read_text
方法,使其直接抛出PermissionError
。 - 执行与断言: 在
pytest.raises(PermissionError)
上下文中调用read_from_file
。 - 验证: 确保
read_from_file
在底层读取权限被拒绝时,能将PermissionError
传递出来。
- 准备: 创建
- 测试目的: 与上一个类似,但这次是测试在没有读取权限的情况下,
-
test_read_file_unicode_decode_error
- 测试目的: 测试文件内容的编码与程序预期的编码(通常是UTF-8)不匹配时的情况。这是一个常见的现实世界问题。
- 测试思路:
- 准备: 创建一个临时文件。
- 制造问题数据: 将一段中文字符
"你好世界"
使用gbk
编码转换为字节流,并直接将这些字节写入文件。这样,文件里存储的就是GBK编码的数据。 - 执行与断言:
file_manager.read_from_file
方法内部很可能会使用默认的UTF-8
编码来尝试解码文件内容。当它遇到GBK编码的字节时,解码会失败。测试使用pytest.raises(UnicodeDecodeError)
来捕获这个预期的解码错误。 - 验证: 如果成功捕获到
UnicodeDecodeError
,说明read_from_file
正在尝试解码,并且在编码不匹配时会按预期失败。
-
test_write_to_file_generic_exception
- 测试目的: 测试健壮性。当发生一些除了权限问题之外的、更通用的或意想不到的I/O错误(如磁盘已满、文件系统错误等)时,程序是否能将其捕获并包装成一个更统一的异常类型。
- 测试思路:
- 准备: 准备
FileManager
和文件路径。 - 模拟: 使用
monkeypatch
替换Path.write_text
,让它抛出一个通用的OSError
,并附带一条模拟“磁盘已满”的消息。 - 执行与断言: 这次测试期望捕获的不是底层的
OSError
,而是IOError
。这暗示FileManager
的代码设计中,应该有一个try...except OSError
块,并将捕获到的OSError
重新包装成IOError
再抛出。 - 验证:
pytest.raises(IOError)
确保了这个异常包装逻辑是存在的且工作正常。
- 准备: 准备
-
test_read_file_generic_exception
- 测试目的: 与上一个类似,但针对的是读取文件时发生的通用操作系统错误。
- 测试思路:
- 准备: 准备
FileManager
和一个已存在的空文件。 - 模拟: 使用
monkeypatch
替换Path.read_text
,让它直接抛出一个OSError
。 - 执行与断言: 同样,期望捕获的是被包装后的
IOError
,而不是原始的OSError
。 - 验证:
pytest.raises(IOError)
验证了read_from_file
方法也具有同样的健壮错误处理和异常包装逻辑。
- 准备: 准备
-
-
再就是对于 text_cleaner.py 的测试:
# --- 测试文本预处理模块 (text_cleaner.py) --- @pytest.mark.parametrize("input_text, expected_output", [ # 聚焦于混合内容和特殊字符 ("Chapter1第一章内容", "chapter1第一章内容"), # 测试中英文混合无空格 ("你好 world", "你好 world"), # 测试已存在空格的情况 ("AI和123", "ai和123"), # 测试全角字符的转换 # 核心边界样例 ("", ""), # 保留:空字符串是必须测试的边界 (" \t\n", ""), # 保留:纯空白字符也是重要边界 ]) def test_clean_and_normalize(input_text, expected_output): """测试文本清洗和规范化功能""" processor = TextProcessor() assert processor.clean_and_normalize(input_text) == expected_output @pytest.mark.parametrize("input_text, expected_tokens", [ # 测试单字符过滤 ("a and b", ["a"]), # 测试单个英文字母'a'被保留,而'b'被过滤 # 核心功能样例 ("余弦相似度是一种算法", ["余弦相似度", "算法"]), # 保留:验证自定义词典的核心功能 # 核心边界样例 ("", []), # 保留:空字符串边界 ("的 是 在", []), # 保留:纯停用词边界 ]) def test_tokenize_and_filter(input_text, expected_tokens): """测试分词和停用词过滤功能""" processor = TextProcessor() # 为测试用例设置一个固定的、可预测的停用词表 processor.stopwords = {"的", "是", "一种", "在", "and", "b"} assert processor.tokenize_and_filter(input_text) == expected_tokens
-
test_clean_and_normalize
-
测试目的: 这个函数的核心目标是验证
TextProcessor
类中clean_and_normalize
方法的功能。从函数名和测试用例来看,这个方法主要负责文本的初步“清洗”和“规范化”,比如转换大小写、处理特殊字符和去除多余空白。 -
测试思路分析:
- 参数化: 测试函数被
@pytest.mark.parametrize
装饰,定义了input_text
(输入)和expected_output
(期望的输出)两个参数。下面每一行元组( ... , ... )
就是一组独立的测试数据。 - 测试逻辑: 在函数体内,它首先创建一个
TextProcessor
的实例,然后调用clean_and_normalize
方法处理输入文本,最后用assert
语句判断实际输出是否与期望输出完全相等。
- 参数化: 测试函数被
-
具体测试用例解读:
("Chapter1第一章内容", "chapter1第一章内容")
- 意图: 测试对混合了中英文的字符串的处理能力,特别是验证英文字母
C
是否被正确地转换为了小写c
,同时中文字符保持不变。
- 意图: 测试对混合了中英文的字符串的处理能力,特别是验证英文字母
("你好 world", "你好 world")
- 意图: 这是一个“不变性”测试。它验证当输入文本已经符合规范时(比如中英文之间已有空格),该方法不会错误地修改它。
("AI和123", "ai和123")
- 意图: 测试全角字符到半角字符的转换。这是一个非常关键的规范化步骤。
AI
(全角) 被转换成了ai
(半角),123
(全角) 被转换成了123
(半角)。
- 意图: 测试全角字符到半角字符的转换。这是一个非常关键的规范化步骤。
("", "")
- 意图: 边界测试。输入一个空字符串,这是最基本的边界情况。程序应该能优雅地处理,并返回一个空字符串,而不是抛出异常或返回
None
。
- 意图: 边界测试。输入一个空字符串,这是最基本的边界情况。程序应该能优雅地处理,并返回一个空字符串,而不是抛出异常或返回
(" \t\n", "")
- 意图: 另一个重要的边界测试。输入一个只包含各种空白字符(空格、制表符
\t
、换行符\n
)的字符串。预期结果是这些空白被完全清除,返回一个空字符串。
- 意图: 另一个重要的边界测试。输入一个只包含各种空白字符(空格、制表符
-
-
test_tokenize_and_filter
- 测试目的: 这个函数用于验证
TextProcessor
类中的tokenize_and_filter
方法。这个方法负责两个核心任务:1. 将文本字符串分词成一个词语列表 (tokens);2. 从这个列表中过滤掉预设的“停用词” (stopwords)。 - 测试思路分析:
- 参数化: 同样使用了参数化,定义了
input_text
(输入)和expected_tokens
(期望输出的词语列表)。 - 固定的测试环境: 测试代码中有一行非常关键:
processor.stopwords = {"的", "是", "一种", "在", "and", "b"}
。这行代码的目的是为了确保测试环境的稳定和可预测性。它覆盖了TextProcessor
可能存在的默认停用词表,使得测试结果只与这个临时的、固定的停用词表有关,排除了外部因素的干扰。 - 测试逻辑: 创建处理器实例,设置固定的停用词表,调用
tokenize_and_filter
方法,然后断言返回的词语列表与期望的列表完全一致。
- 参数化: 同样使用了参数化,定义了
- 具体测试用例解读:
("a and b", ["a"])
- 意图: 测试对简单英文文本的分词和过滤。在这个例子中,"a", "and", "b" 会被分开。根据设置的停用词表,
"and"
和"b"
都是停用词,会被过滤掉,只有"a"
被保留下来。这个用例也巧妙地测试了对单个字母的处理,即单个字母'a'不会被当成停用词过滤掉,而'b'会。
- 意图: 测试对简单英文文本的分词和过滤。在这个例子中,"a", "and", "b" 会被分开。根据设置的停用词表,
("余弦相似度是一种算法", ["余弦相似度", "算法"])
- 意图: 验证中文分词和过滤的核心功能。假设分词器能正确地切分出
["余弦相似度", "是", "一种", "算法"]
。然后,根据停用词表,"是"
和"一种"
会被移除,最终剩下["余弦相似度", "算法"]
。这很可能是在验证自定义词典或特定分词算法的有效性。
- 意图: 验证中文分词和过滤的核心功能。假设分词器能正确地切分出
("", [])
- 意图: 边界测试。当输入为空字符串时,分词和过滤的结果应该是一个空的列表
[]
。
- 意图: 边界测试。当输入为空字符串时,分词和过滤的结果应该是一个空的列表
("的 是 在", [])
- 意图: 边界测试。当输入字符串完全由停用词组成时,经过分词和过滤后,结果也应该是一个空的列表。这确保了过滤逻辑是彻底的。
- 测试目的: 这个函数用于验证
-
-
最后是对于__core_algorithm.py的测试:__
# --- 测试相似度计算模块 (core_algorithm.py) --- @pytest.mark.parametrize("text_a, text_b, expected_score", [ # 聚焦于算法特性 ("我 爱 你", "你 爱 我", 1.0), # 证明算法与语序无关 ("苹果 香蕉 苹果", "香蕉 苹果 香蕉", 0.80), # 证明词频会影响结果 ("苹果 香蕉", "苹果 香蕉 橙子", 0.71), # 测试子集关系 # 核心边界样例 ("", "", 1.0), # 保留:双空文本边界 ("苹果 香蕉", "", 0.0), # 保留:单空文本边界 ]) def test_calculate_similarity(text_a, text_b, expected_score): """测试余弦相似度计算""" score = calculate_similarity(text_a, text_b) # 使用 pytest.approx 来精确比较浮点数 assert score == pytest.approx(expected_score, abs=0.01) def test_calculate_similarity_with_only_stopwords(): """ 测试当输入只包含停用词时,应能处理ValueError并返回0.0 """ # TfidfVectorizer默认会移除这些英文停用词 text_a = "the of and" text_b = "is a to" # 向量化后词汇表为空,触发ValueError,函数应返回0.0 score = calculate_similarity(text_a, text_b) assert score == 0.0 def test_text_processor_init_with_non_existent_files(capsys): """ 测试当词典和停用词文件不存在时,能够正常初始化并打印提示 """ # 传入不存在的路径来触发FileNotFoundError分支 processor = TextProcessor(stopwords_path="no/such/stopwords.txt", user_dict_path="no/such/dict.txt") # 验证TextProcessor仍然创建成功 assert isinstance(processor.stopwords, set) # 使用capsys捕获print输出,验证提示信息是否正确 captured = capsys.readouterr() assert "自定义词典 'no/such/dict.txt' 未找到" in captured.out assert "停用词文件 'no/such/stopwords.txt' 未找到" in captured.out def test_process_method(): """ 测试 process() 集成方法是否能正确执行完整流程 """ processor = TextProcessor() # 为测试设置一个可预测的停用词表 processor.stopwords = {"的", "一个"} raw_text = "这是一个 完整的 流程" expected_tokens = ["完整", "流程"] # 直接调用 process 方法 tokens = processor.process(raw_text) assert tokens == expected_tokens
-
test_calculate_similarity
-
测试目的: 这个函数的核心是验证
calculate_similarity
函数的计算准确性,特别是它所实现的算法(很可能是余弦相似度)的关键数学特性。 -
测试思路分析:
- 参数化: 再次使用
@pytest.mark.parametrize
传入三元组(text_a, text_b, expected_score)
,清晰地定义了多组输入和预期的相似度分数。 - 浮点数比较: 测试代码使用了
pytest.approx(expected_score, abs=0.01)
。这是测试浮点数时的最佳实践。因为计算机在进行浮点数运算时会有微小的精度误差,直接用==
比较可能会意外失败。pytest.approx
允许在一定容忍度(这里是绝对误差0.01
)内进行比较,使测试更加稳定可靠。
- 参数化: 再次使用
-
具体测试用例解读:
("我 爱 你", "你 爱 我", 1.0)
- 意图: 验证算法的语序无关性。在像余弦相似度这样的“词袋模型”中,只要词语的种类和频率相同,不管它们的顺序如何,最终的向量表示都是一样的,因此相似度应为
1.0
。这是一个非常核心的算法特性测试。
- 意图: 验证算法的语序无关性。在像余弦相似度这样的“词袋模型”中,只要词语的种类和频率相同,不管它们的顺序如何,最终的向量表示都是一样的,因此相似度应为
("苹果 香蕉 苹果", "香蕉 苹果 香蕉", 0.80)
- 意图: 验证词频(Term Frequency)对结果的影响。两个文本都包含“苹果”和“香蕉”,但它们的出现次数不同。这会导致它们的向量方向不完全相同,因此相似度会很高,但不会是
1.0
。
- 意图: 验证词频(Term Frequency)对结果的影响。两个文本都包含“苹果”和“香蕉”,但它们的出现次数不同。这会导致它们的向量方向不完全相同,因此相似度会很高,但不会是
("苹果 香蕉", "苹果 香蕉 橙子", 0.71)
- 意图: 测试子集关系。文本B包含了文本A的所有词,并增加了一个新词“橙子”。这会导致它们的向量维度可能不同(如果“橙子”是新词)或在同一维度空间中的方向有偏差,相似度会降低。
("", "", 1.0)
- 意图: 边界测试。两个空字符串在概念上是完全相同的,所以它们的相似度应该是
1.0
。测试确保算法能正确处理这种情况。
- 意图: 边界测试。两个空字符串在概念上是完全相同的,所以它们的相似度应该是
("苹果 香蕉", "", 0.0)
- 意图: 边界测试。任何非空文本与一个空文本进行比较,它们之间没有任何共同的词语,所以相似度应该是
0.0
。这可以防止出现除以零等数学错误。
- 意图: 边界测试。任何非空文本与一个空文本进行比较,它们之间没有任何共同的词语,所以相似度应该是
-
-
test_calculate_similarity_with_only_stopwords
-
测试目的: 这是一个非常重要的健壮性测试或错误路径测试。它专门用来验证当所有输入文本都被“停用词过滤器”清除后,算法是否会崩溃。
-
测试思路分析:
- 制造问题数据: 输入的
text_a
和text_b
都只包含常见的英文停用词。 - 预判内部逻辑: 测试的编写者预判到,在
calculate_similarity
内部,文本会被向量化(例如使用 scikit-learn 的TfidfVectorizer
)。这个向量化工具默认会移除停用词,导致处理完这两个输入后,有效的词汇表为空,无法生成有效的向量。 - 验证错误处理: 当词汇表为空时,尝试计算向量或相似度通常会引发一个
ValueError
(例如,向量的模为零,导致除零错误)。这个测试的核心是验证calculate_similarity
函数内部是否有一个try...except ValueError
块,能够捕获这个预期的异常,并优雅地返回一个合理的值0.0
,而不是让整个程序崩溃。
- 制造问题数据: 输入的
-
-
test_text_processor_init_with_non_existent_files`
-
测试目的: 测试
TextProcessor
类的构造函数(__init__
)在依赖文件(如自定义词典、停用词表)缺失时的容错能力。 -
测试思路分析:
- 模拟失败场景: 在初始化
TextProcessor
时,故意传入两个不存在的文件路径。 - 验证程序不崩溃: 第一个断言
assert isinstance(processor.stopwords, set)
验证即使文件加载失败,processor
对象本身依然被成功创建,并且其内部状态(如stopwords
属性)被正确地初始化为一个默认的空集合。这证明了构造函数具有良好的容错性。 - 验证用户提示: 使用
pytest
的capsys
fixture。这是一个非常有用的工具,它可以捕获所有打印到标准输出(stdout
)和标准错误(stderr
)的内容。测试代码通过捕获输出,然后断言捕获到的内容中包含了预期的警告信息,来验证程序是否在文件不存在时给了用户清晰的提示。
- 模拟失败场景: 在初始化
-
-
test_process_method
-
测试目的: 这是一个集成测试。它不再关注单个的、孤立的方法,而是测试
process()
这个更高层次的方法,该方法很可能内部调用了多个其他方法(如clean_and_normalize
,tokenize_and_filter
等)。 -
测试思路分析:
- 端到端验证: 测试提供了一个原始的、未处理的字符串
raw_text
。 - 调用集成方法: 它直接调用
processor.process()
,模拟用户最终使用这个类的场景。 - 验证最终结果: 它断言最终返回的
tokens
列表是否与期望的结果完全一致。expected_tokens
是经过了清洗、分词、过滤等一系列步骤后应该得到的最终产物。 - 环境隔离: 和之前的测试一样,通过
processor.stopwords = {"的", "一个"}
确保了测试环境的可预测性。
- 端到端验证: 测试提供了一个原始的、未处理的字符串
terminal运行:pytest --cov=src --cov-report=html
使用浏览器打开根目录下的htmlcov.html文件,打开其中的class_index.html文件,覆盖率见下图:
-
-
七、异常检测与抛出:
本段的异常和上述测试样例基本上都是一一对应的,且样例、异常抛出的思路都已经在上方展示,下面给出 raise各种ERROR的对应源代码。
-
core_algorthm.py
# 将两段文本放入列表中,进行TF-IDF向量化 # fit_transform 会学习词汇表并返回一个稀疏矩阵 try: tfidf_matrix = vectorizer.fit_transform([text_a, text_b]) except ValueError: # 如果文本内容太少或都是停用词,向量化可能会失败 return 0.0
如果文本内容太少或都是停用词,向量化可能会失败
-
io_manager.py
def read_from_file(self, file_path_str: str) -> str: """ 从指定路径读取文本内容。 使用pathlib处理路径,并提供清晰的错误提示。 """ path = Path(file_path_str) if not path.exists(): raise FileNotFoundError(f"输入文件未找到: {path}") if not path.is_file(): raise ValueError(f"提供的路径不是一个文件: {path}") try: return path.read_text(encoding='utf-8') except PermissionError: raise PermissionError(f"权限不足,无法读取文件: {path}") except UnicodeDecodeError: raise UnicodeDecodeError("utf-8", b'', 0, 0, f"文件编码非UTF-8,读取失败: {path}") except Exception as e: raise IOError(f"读取文件时发生未知错误: {e}") def write_to_file(self, file_path_str: str, score: float): """ 将相似度得分写入指定文件,格式化为两位小数。 """ path = Path(file_path_str) try: # 确保父目录存在 path.parent.mkdir(parents=True, exist_ok=True) # 格式化并写入 content_to_write = f"{score:.2f}" path.write_text(content_to_write, encoding='utf-8') except PermissionError: raise PermissionError(f"权限不足,无法写入文件: {path}") except Exception as e: raise IOError(f"写入结果时发生未知错误: {e}")
-
text_cleaner.py
def _load_custom_dict(self, path): """加载自定义词典""" try: jieba.load_userdict(path) except FileNotFoundError: print(f"提示: 自定义词典 '{path}' 未找到,将使用默认分词。") def _load_stopwords(self, path): """加载停用词表,若失败则使用一个最小化的内置集合""" try: with open(path, 'r', encoding='utf-8') as f: return {line.strip() for line in f if line.strip()} except FileNotFoundError: print(f"提示: 停用词文件 '{path}' 未找到,将使用内置默认停用词。") return {'的', '是', '了', '在', '也', '和', '就', '摘要', '关键词', '引言', '结论'}
八、写在最后:
项目文件树状图:
(DL) hey-mike@hey-mike-ASUS-TUF-Gaming-F15-FX507ZV4-FX507ZV4:/media/hey-mike/软件/3123004811$ tree
.
├── htmlcov
│ ├── class_index.html
│ ├── coverage_html_cb_6fb7b396.js
│ ├── favicon_32_cb_58284776.png
│ ├── function_index.html
│ ├── index.html
│ ├── keybd_closed_cb_ce680311.png
│ ├── status.json
│ ├── style_cb_6b508a39.css
│ ├── z_145eef247bfb46b6_core_algorithm_py.html
│ ├── z_145eef247bfb46b6_io_manager_py.html
│ └── z_145eef247bfb46b6_text_cleaner_py.html
├── main.py
├── paper_dict.txt
├── profile_data.prof
├── pytest.ini
├── README.md
├── requirements.txt
├── result
│ ├── add
│ ├── ans_del.txt
│ ├── del
│ ├── del.txt
│ ├── dis_1
│ ├── dis_10
│ └── dis_15
├── src
│ ├── core_algorithm.py
│ ├── io_manager.py
│ ├── pycache
│ │ ├── core_algorithm.cpython-311.pyc
│ │ ├── io_manager.cpython-311.pyc
│ │ └── text_cleaner.cpython-311.pyc
│ └── text_cleaner.py
└── tests
├── orig_0.8_add.txt
├── orig_0.8_del.txt
├── orig_0.8_dis_10.txt
├── orig_0.8_dis_15.txt
├── orig_0.8_dis_1.txt
├── orig.txt
├── pycache
│ ├── suite.cpython-311-pytest-8.4.2.pyc
│ └── test_suite.cpython-311-pytest-8.4.2.pyc
└── test_suite.py
7 directories, 39 files
开发环境是python 3.11.13 Ubuntu 24.04(LST版本)