第一次个人编程作业

软件工程第一次个人编程作业

项目 详情
这个作业属于哪个课程 计科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 核心算法设计

  1. 分词优化(tokenize
    • 仅过滤纯标点(如!、,)和空字符,保留中文、英文、数字等有效语义单元,避免过度处理导致词数偏差;
    • 采用jieba.lcut(..., cut_all=False)精确模式,平衡分词精度与速度,适配中文文本的语义完整性。
  2. 加权 Levenshtein 编辑距离(levenshtein_distance
    • 长文本分块优化:当文本长度≥1000 词时,按block_size=300分块,仅匹配相邻 ±2 块(减少计算量),避免全量计算的 O (n²) 复杂度;
    • 显式浮点类型:全流程使用np.float32,彻底消除类型转换警告,确保计算稳定性;
    • 加权成本:基于 TF-IDF 权重计算替换成本,重要词(高权重)的替换惩罚更高,提升差异识别精度。
  3. 动态 TF-IDF 余弦相似度(get_tfidf_weights
    • 特征数自适应:短文本(总词数 < 50)保留 80 个特征,长文本(总词数≥50)保留 500 个特征,避免短文本关键词被过滤、长文本特征冗余;
    • 1-gram 特征:禁用stop_words,保留 “今天”“AI” 等短文本核心词,同时避免多 gram 带来的语义冗余。
  4. 相似度融合(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 算法独到之处

  1. 长短文本适配性:通过分块 Levenshtein、动态 TF-IDF 特征数,解决传统算法 “短文本精度低、长文本速度慢” 的痛点;
  2. 类型安全:全流程强制浮点类型,消除numpy隐式类型转换耗时与警告,提升代码稳定性;
  3. 工程化细节:加入耗时统计、中间结果打印,便于调试与性能分析;同时限制分块数≤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.1slevenshtein_distance耗时占比降至 65%(分块优化效果),get_tfidf_weights占比 30%(动态特征数虽增加计算量,但整体仍远低于基础版),其余函数占比 5%。

优化后性能图

4.2.2 消耗最大的函数

项目中 levenshtein_distance 是消耗最大的函数:

  • 短文本场景(n<1000):占总耗时 60%~70%,因需遍历词语序列计算编辑距离;

  • 长文本场景(n≥1000):占总耗时 65%~80%,虽分块优化,但块内编辑距离计算仍为计算密集型操作。

该函数是性能优化的核心突破点,也是后续进一步优化(如 GPU 加速、近似算法)的关键方向。

五、计算模块单元测试展示

5.1 单元测试设计思路

遵循 “场景全覆盖、边界必验证” 原则,设计五大类测试用例,确保计算模块的正确性与鲁棒性:

  1. 基础功能验证:分词结果格式、Levenshtein 距离数值类型、TF-IDF 权重字典有效性;
  2. 正常场景覆盖:完全相同文本、高相似文本(改 1 个词)、低相似文本(主题无关);
  3. 边界场景验证:空文本(单个 / 两个)、单字符文本、中英混合文本、长文本(触发分块);
  4. 参数适配验证:动态max_features(短 / 长文本)、动态加权 α(短 / 长文本);
  5. 异常兼容验证:纯标点文本、特殊字符文本,确保函数不崩溃且返回合理结果。

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.pycoverage 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.txtempty_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)
posted @ 2025-09-23 20:09  folger  阅读(21)  评论(0)    收藏  举报