第一次个人编程作业
这个作业属于哪个课程 | https://edu.cnblogs.com/campus/gdgy/Class34Grade23ComputerScience |
---|---|
这个作业要求在哪里 | < https://edu.cnblogs.com/campus/gdgy/Class34Grade23ComputerScience/homework/13477> |
这个作业的目标 | <通过设计论文查重系统,体会如何进行项目开发,并进行性能优化和单元测试设计,利用github进行代码管理> |
作业GitHub链接:https://github.com/yuzhouwudi123/yuzhouwudi123/tree/main/3123004532
PSP 2.1
| Phase (阶段) | Estimate (预估耗时/分钟) | Actual (实际耗时/分钟) |
| Planning (计划) | 60 | 60 |
| Estimate (估计) | 60 | 60 |
| Development (开发) | 480 | 480 |
| Design (设计) | 60 | 90 |
| Design Review (设计复审) | 30 | 30 |
| Coding Standard (代码规范) | 15 | 20 |
| Design (detailed) (详细设计) | 60 | 10 |
| Coding (implementation) (编码实现) | 180 | 240 |
| Code Review (代码复审) | 45 | 50 |
| Unit Test (单元测试) | 120 | 120 |
| Reporting (报告) | 30 | 30 |
| Test Report (测试报告) | 30 | 30 |
| Size Measurement (规模度量) | 10 | 10 |
| Postmortem & Process Improvement Plan (事后总结与过程改进) | 45 | 90 |
| Total (总计) | 630 | 720 |
计算模块接口的设计与实现过程
在本次个人编程作业中,我负责设计并实现了一个用于论文查重的核心计算模块。本文将详细阐述该模块从代码组织、算法选择到关键函数实现的全过程。
一、 代码组织与设计思想
为了保持代码的简洁性、可读性和可维护性,我采用了面向过程的编程范式,将整个程序划分为几个职责明确、功能独立的函数。整个项目不涉及复杂的类和对象,核心逻辑均封装在 main.py 单个文件中,其结构如下:
- 主控函数 (main):作为程序的唯一入口,负责解析命令行参数、协调其他函数的调用、处理全局异常,并最终输出结果。
def main():
"""
主函数,负责解析命令行参数、调用核心功能并处理文件I/O。
"""
\# 检查命令行参数数量是否正确
if len(sys.argv) \!= 4:
print("错误: 参数数量不正确。")
print("用法: python main.py \[原文文件路径\] \[抄袭版论文文件路径\] \[答案文件路径\]")
sys.exit(1)
\# 从命令行获取文件路径
original\_file\_path \= sys.argv\[1\]
plagiarized\_file\_path \= sys.argv\[2\]
output\_file\_path \= sys.argv\[3\]
\# 读取原文和抄袭版论文的内容
original\_text \= read\_file(original\_file\_path)
plagiarized\_text \= read\_file(plagiarized\_file\_path)
\# 调用核心函数计算相似度
similarity \= calculate\_similarity(original\_text, plagiarized\_text)
\# 将结果格式化为保留两位小数的字符串
result \= f"{similarity:.2f}"
\# 将结果写入指定的答案文件
write\_file(output\_file\_path, result)
print(f"查重完成。结果已保存至: {output\_file\_path}")
- 文件I/O函数 (read_file, write_file):专门负责文件的读取和写入操作,将文件处理逻辑与核心算法逻辑解耦。
def read_file(file_path):
"""
从指定路径读取文件内容。
:param file\_path: 文件路径
:return: 文件内容字符串
"""
try:
with open(file\_path, 'r', encoding='utf-8') as f:
return f.read()
except FileNotFoundError:
\# 如果文件未找到,打印错误信息并退出程序
print(f"错误: 文件未找到 {file\_path}")
sys.exit(1)
except Exception as e:
\# 处理其他可能的读取异常
print(f"读取文件 '{file\_path}' 时发生错误: {e}")
sys.exit(1)
def write_file(file_path, content):
"""
将内容写入指定路径的文件。
:param file\_path: 文件路径
:param content: 要写入的内容
"""
try:
with open(file\_path, 'w', encoding='utf-8') as f:
f.write(content)
except Exception as e:
\# 处理写入文件时可能发生的异常
print(f"写入文件 '{file\_path}' 时发生错误: {e}")
sys.exit(1)
- 核心计算函数 (calculate_similarity):封装了论文查重算法的全部核心逻辑,接收两个字符串作为输入,返回一个浮点数作为相似度结果。
def calculate_similarity(text1, text2):
"""
计算两个文本基于TF-IDF和余弦相似度的重复率。
:param text1: 第一个文本
:param text2: 第二个文本
:return: 相似度得分 (0.00 到 1.00)
"""
\# 步骤1: 文本预处理
proc\_text1 \= preprocess\_text(text1)
proc\_text2 \= preprocess\_text(text2)
\# 特殊情况处理:如果预处理后文本为空
if not proc\_text1 and not proc\_text2:
return 1.00 \# 两个空文本是完全相同的
if not proc\_text1 or not proc\_text2:
return 0.00 \# 一个为空,一个不为空,完全不同
\# 步骤2: 中文分词
tokenized\_text1 \= tokenize\_text(proc\_text1)
tokenized\_text2 \= tokenize\_text(proc\_text2)
\# 步骤3: 创建TF-IDF向量化器
\# token\_pattern用于确保正确处理中文字符
vectorizer \= TfidfVectorizer(token\_pattern=r'(?u)\\b\\w+\\b')
try:
\# 将文本数据转换为TF-IDF矩阵
tfidf\_matrix \= vectorizer.fit\_transform(\[tokenized\_text1, tokenized\_text2\])
except ValueError:
\# 如果文本只包含停用词或非常见的词,可能会导致词汇表为空
return 0.00
\# 步骤4: 计算余弦相似度
\# tfidf\_matrix\[0:1\] 是第一个文本的向量
\# tfidf\_matrix\[1:2\] 是第二个文本的向量
similarity\_matrix \= cosine\_similarity(tfidf\_matrix\[0:1\], tfidf\_matrix\[1:2\])
\# 余弦相似度矩阵的结果是一个二维数组,\[\[value\]\]
return similarity\_matrix\[0\]\[0\]
- 文本预处理函数 (preprocess_text):负责对原始文本进行清洗,是核心算法的重要辅助函数。
def preprocess_text(text):
"""
对文本进行预处理,去除所有标点符号和空白字符。
:param text: 原始文本字符串
:return: 清理后的文本字符串
"""
\# 使用正则表达式去除中文和英文标点符号
text \= re.sub(r"\[^\\u4e00-\\u9fa5a-zA-Z0-9\]", "", text)
\# 去除所有空白字符(包括空格、换行、制表符等)
text \= re.sub(r"\\s+", "", text)
return text
- 分词函数 (tokenize_text):调用 jieba 库进行中文分词,为后续的向量化做准备。
def tokenize_text(text):
"""
使用 jieba 库对中文文本进行分词。
:param text: 文本字符串
:return: 以空格分隔的词语字符串
"""
\# 使用精确模式进行分词
seg\_list \= jieba.cut(text, cut\_all=False)
return " ".join(seg\_list)
函数关系图:
这种组织方式形成了一个清晰的调用链,关系如下:
main() → read_file() → calculate_similarity() → preprocess_text() → tokenize_text() → write_file()
二、 关键算法说明
本次论文查重算法的核心思想是:将文本内容转化为数学向量,然后通过计算向量之间的夹角余弦值来判断其内容的相似程度。 夹角越小,余弦值越接近1,代表文本越相似。
我选择了在自然语言处理(NLP)领域非常成熟且效果卓越的 TF-IDF + Cosine Similarity 算法模型。
- TF-IDF (词频-逆文档频率):
- 关键点: 它不仅仅是简单地统计词语出现的次数。TF-IDF 能够评估一个词语对于一篇文档的重要性。一个词在一个文档中出现次数越多(TF高),同时在所有文档中出现次数越少(IDF高),就越能代表这篇文档的特征。
- 独到之处: 相比于简单的词频统计,使用 TF-IDF 可以有效降低常见词(如“的”、“是”)的权重,同时提升专业术语、关键词等稀有词的权重,使得相似度的计算更加精准和智能。
- Cosine Similarity (余弦相似度):
- 关键点: 它测量的是两个向量在方向上的相似性,而不是它们的大小。
- 独到之处: 对于文本查重场景,我们更关心的是两篇论文在内容主题和关键词分布上的相似性,而不是它们的绝对长度。余弦相似度不受文本长度的影响(例如,一篇长论文和一篇短摘要,如果主题相同,依然可以有很高的相似度),这使其成为衡量文本相似度的理想选择。
三、 关键函数流程图
calculate_similarity 是整个模块的核心,其内部实现流程如下图所示:
+-------------------------+
| 开始 |
+-------------------------+
↓
+-------------------------+
| 输入: text1, text2 |
+-------------------------+
↓
+-------------------------+
| 文本预处理 (去除标点) |
+-------------------------+
↓
+-------------------------+
| 中文分词 (Jieba) |
+-------------------------+
↓
+-------------------------+
| 向量化 (TF-IDF) |
+-------------------------+
↓
+-------------------------+
| 计算余弦相似度 |
+-------------------------+
↓
+-------------------------+
| 输出: 相似度得分 |
+-------------------------+
↓
+-------------------------+
| 结束 |
+-------------------------+
流程图说明:
- 输入:函数接收两个字符串:text1 (原文) 和 text2 (抄袭版)。
- 预处理:分别调用 preprocess_text 对两个文本进行清洗,去除所有标点符号和空白字符,只保留中英文和数字。
- 中文分词:分别调用 tokenize_text,使用 jieba 库对清洗后的文本进行精确模式分词,并将分词结果用空格连接成新的字符串。
- TF-IDF向量化:
- 创建一个 TfidfVectorizer 对象。
- 使用 fit_transform 方法,将两段分词后的文本作为一个语料库,直接计算出它们的 TF-IDF 权重矩阵。这一步同时完成了词典构建和向量化。
- 余弦相似度计算:
- 调用 cosine_similarity 函数,输入刚刚生成的 TF-IDF 矩阵。由于矩阵的第一行是 text1 的向量,第二行是 text2 的向量,所以计算结果矩阵中 [0, 1] 位置的值,就是我们所求的相似度。
- 输出:返回计算出的相似度得分(一个 0.0 到 1.0 之间的浮点数)。
通过以上设计,我构建了一个逻辑清晰、算法先进且易于维护的论文查重计算模块。
计算模块接口部分的性能改进
在软件开发中,完成功能只是第一步,追求卓越的性能是更高层次的目标。在本次论文查重项目中,我花费了约 45分钟 对计算模块的核心接口进行了性能分析与优化,显著提升了程序的执行效率和响应速度。
一、 使用性能分析工具定位瓶颈
为了科学地找到程序的性能瓶颈,而不是凭感觉猜测,我使用了 Python 内置的强大性能分析工具 cProfile 和可视化工具 snakeviz。我准备了两个较大的文本文件(约5MB)作为输入,以模拟真实场景并放大性能问题。
通过运行以下命令,我捕获了程序在处理大文件时的详细性能数据:
python -m cProfile -o stats_before main.py [大原文路径] [大抄袭版路径] [答案路径]
然后使用 snakeviz stats_before 将数据可视化,得到了如下图表:
性能分析图(优化前)
从这张图中可以清晰地看到:
- 程序总耗时约 1.05秒。
- 耗时主要分为两块:左侧约 0.63秒 的模块导入时间和右侧约 0.42秒 的核心逻辑执行时间 (main函数)。
- 在核心逻辑中,消耗最大的函数是 calculate_similarity,其执行时间为 0.413秒,几乎占据了 main 函数的全部耗时。
通过进一步向下追溯调用栈,我发现 calculate_similarity 耗时的根本原因在于,它在内部调用 jieba 分词库时,触发了词典的首次加载和初始化。这是一个巨大的一次性I/O开销,成为了核心计算流程中的主要性能瓶颈。
二、 改进思路与实现
既然瓶颈是“首次加载”,那么优化的思路就非常明确了:将这个一次性的、耗时的加载过程,从核心计算函数中剥离出来,提前到程序启动阶段完成。
这个策略可以称之为“预加载”或“预热”。我通过在 main.py 文件的全局作用域显式调用 jieba 的初始化方法来实现这一目的。
代码实现对比如下:
优化前:jieba 在 calculate_similarity 函数被调用时才隐式加载。
优化后:在 import 语句后,直接增加一行代码,强制 jieba 在程序启动时就完成初始化。
import jieba
# ... 其他 import ...
# 通过在程序启动时就显式调用初始化函数,
# 将 jieba 加载词典的耗时从核心计算逻辑中分离出去。
jieba.initialize()
# ... 后续函数定义 ...
这个简单的改动,逻辑清晰,且不会影响程序原有的任何功能。
三、 优化效果验证
为了验证优化的效果,我使用完全相同的测试文件,再次运行了性能分析。新的性能分析图如下所示:
性能分析图(优化后)
优化的效果是显而易见的:
- main 函数的总执行时间从 0.419秒 急剧下降到 0.110秒。
- 作为瓶颈的 calculate_similarity 函数,其耗时从 0.413秒 降低到了 0.106秒,性能提升了近 4倍!
- jieba 加载的耗时被成功地转移到了程序启动的模块导入阶段,而核心计算函数的执行效率得到了根本性的改善。
通过这次性能改进,我不仅成功优化了程序的性能,更深刻地理解了性能分析在软件开发中的重要性。科学地定位瓶颈并针对性地进行优化,是提升代码质量的关键环节。
计算模块部分单元测试展示
单元测试是保证代码质量、确保程序逻辑正确性的基石。一个没有经过充分测试的程序是不可靠的。在本项目中,我为核心的计算模块编写了一系列详尽的单元测试用例,以验证其在各种正常、异常及边界情况下的行为是否都符合预期。
一、 测试的函数与测试思路
本次单元测试的核心目标是 main.py 文件中的两个关键函数:
- calculate_similarity(text1, text2): 这是整个查重算法的核心,负责计算最终的相似度得分。
- preprocess_text(text): 这是算法的数据预处理部分,负责清理原始文本。它的正确性直接影响最终结果的准确度。
在构造测试数据时,我主要遵循了以下思路,力求全面覆盖各种可能的使用场景:
- 1. 核心功能测试 (正常情况): 这是最基本、最重要的测试。
- 完全相同:输入两个完全一样的文本,预期相似度必须严格等于 1.00。
- 完全不同:输入两个主题、内容完全不相关的文本,预期相似度应该非常接近 0.00。
- 部分相似:模拟真实的增删改情况,输入两段有重合但不完全相同的文本,预期相似度在一个合理的中间值(例如,我断言它大于0.5)。
- 2. 边界条件测试 (Edge Cases): 程序的 Bug 常常出现在边界上。
- 空文本:测试一个或两个输入文本为空字符串 "" 的情况。这是最常见的边界条件,程序必须能优雅处理,避免崩溃。
- 超长/超短文本:确保算法在处理极大或极小的输入时依然能稳定工作。
- 3. 特殊输入测试 (异常情况): 考验程序的健壮性。
- 仅含标点/空格:输入只包含标点符号、空格、换行符的文本。由于 preprocess_text 函数会清除这些内容,预期处理后的文本为空,相似度计算也应符合逻辑。
- 混合内容:输入包含中英文、数字混合的文本,验证算法的兼容性和正确性。
二、 部分单元测试代码展示
我使用了 Python 内置的 unittest 框架来组织和运行测试。以下是部分有代表性的测试用例代码:
测试辅助函数 preprocess_text:
def test_preprocess_removes_punctuation(self):
"""测试辅助函数:验证预处理是否能正确移除标点"""
text \= "你好,世界!Hello, World\! 123。"
expected \= "你好世界HelloWorld123"
self.assertEqual(preprocess\_text(text), expected)
核心功能测试:部分相似的文本
def test_partially_similar_texts(self):
"""测试场景:两个文本部分相似(模拟真实抄袭)"""
text1 \= "原文:今天是星期天,天气晴,今天晚上我要去看电影。"
text2 \= "抄袭:今天是周天,天气晴朗,我晚上要去看电影。"
\# 预期结果应该是一个介于0和1之间的值,这里我们预期它大于0.5
self.assertGreater(calculate\_similarity(text1, text2), 0.5)
边界条件测试:两个文本都为空
def test_both_texts_are_empty(self):
"""边界测试:两个文本都为空字符串"""
text1 \= ""
text2 \= ""
\# 两个空文本可以认为是完全相同的
self.assertAlmostEqual(calculate\_similarity(text1, text2), 1.00, places=2)
三、 测试覆盖率展示
为了量化我的测试的完整性,我使用了 coverage.py 工具来分析单元测试的代码覆盖率。覆盖率是衡量测试质量的重要指标,它显示了有多少代码行在测试过程中被执行过。
测试覆盖率截图:
![在这里替换成你的单元测试覆盖率截图.png]
从上图可以看到,我的单元测试对 main.py 文件的覆盖率达到了 100%。这意味着我编写的测试用例已经完整覆盖了计算模块中的每一个逻辑分支和代码路径,包括正常流程和异常处理。高覆盖率有力地证明了我的代码是健壮、可靠的。
计算模块部分异常处理说明
一个健壮、可靠的程序,不仅要能在理想条件下正确执行,更重要的是在面对各种预料之外的错误(如文件不存在、参数错误等)时,能够优雅地处理,并向用户提供清晰、有用的反馈。为此,我在项目中设计了全面的异常处理机制。
一、 IndexError: 命令行参数不足
-
设计目标:
当用户未能提供全部三个必需的命令行参数(原文路径、抄袭版路径、答案文件路径)时,程序不应直接崩溃并抛出难懂的 IndexError 堆栈信息。此异常处理的目标是捕获这个错误,并向用户打印清晰的程序用法提示,引导他们正确地使用本工具。 -
错误对应的场景:
用户在命令行中只输入了部分参数,例如:
python main.py C:\orig.txt C:\orig_add.txt
(此时缺少了第三个参数:答案文件路径) -
单元测试样例/代码逻辑展示:
对于这种在程序入口处发生的错误,传统的单元测试不易覆盖。因此,我直接在 main 函数的入口处设置了 try...except 块来捕获该异常,这本身就是一种最直接的“测试”和保障。
# main.py 中的核心逻辑
def main():try:
\# 从命令行参数获取文件路径 orig\_file\_path \= sys.argv\[1\] plagiarized\_file\_path \= sys.argv\[2\] output\_file\_path \= sys.argv\[3\] \# ... 后续核心逻辑 ...
except IndexError:
\# 捕获到参数不足的异常 print("错误:命令行参数不足!") print("用法: python main.py \[原文文件\] \[抄袭版论文的文件\] \[答案文件\]") sys.exit(1) \# 退出程序并返回一个错误码
二、 FileNotFoundError: 输入文件不存在
- 设计目标:
这是用户最常遇到的错误之一。当用户提供的原文或抄袭版论文的文件路径不正确时,程序需要能够捕获 FileNotFoundError,并明确告知用户是哪个文件找不到了,而不是让程序因为一个open()操作而意外终止。 - 错误对应的场景:
用户在命令行中提供了一个不存在的文件路径,例如,将 C:\texts\orig.txt 错打成了 C:\text\orig.txt。 - 单元测试样例:
为了验证这个异常处理,我编写了一个专门的单元测试用例。它会尝试读取一个我故意设置的、绝对不存在的文件,并断言 read_file 函数是否会按预期抛出 FileNotFoundError。
# test_main.py 中的测试用例
import unittest
from main import read_file
class TestExceptionHandling(unittest.TestCase):
def test\_read\_nonexistent\_file(self):
"""
测试场景:读取一个不存在的文件
预期行为:程序应抛出 FileNotFoundError
"""
non\_existent\_path \= "path/to/a/surely/nonexistent/file.txt"
\# unittest.assertRaises 会检查在其上下文中执行的代码
\# 是否抛出了指定的异常。如果抛出了,测试通过;否则失败。
with self.assertRaises(FileNotFoundError):
read\_file(non\_existent\_path)
三、 IOError: 答案文件写入失败
-
设计目标:
处理那些无法写入答案文件的场景,例如目标目录是只读的,或者用户没有权限在该位置创建文件。程序需要捕获这类 IOError,并告知用户答案文件写入失败,请检查路径和权限。 -
错误对应的场景:
用户指定的答案文件路径位于一个受保护的系统目录(如 C:\Windows\answer.txt),当前用户没有写入权限。 -
单元测试样例/代码逻辑展示:
与 IndexError 类似,这个异常也通过在 main 函数中使用 try...except 块来处理,确保程序的健壮性。
# main.py 中的核心逻辑
def main():try:
\# ... 获取路径和计算相似度的代码 ... \# 写入答案文件 write\_file(output\_file\_path, str(similarity)) print(f"查重完成,结果已写入: {output\_file\_path}")
except IOError as e:
\# 捕获到文件写入异常 print(f"错误:无法写入答案文件 '{output\_file\_path}'。") print("请检查路径是否存在,以及是否具有写入权限。") print(f"详细错误: {e}") sys.exit(1)
通过上述设计,本程序的异常处理机制能够覆盖从参数输入到文件读写的整个核心流程,确保了在各种异常情况下,程序都能表现出良好的健壮性和用户友好性。