第一次编程作业

这个作业属于哪个课程 https://edu.cnblogs.com/campus/gdgy/Class34Grade23ComputerScience >
这个作业要求在哪里 https://edu.cnblogs.com/campus/gdgy/Class34Grade23ComputerScience/homework/13477>
这个作业的目标 <设计一个论文查重算法,并进行性能优化和单元测试设计,利用github进行代码管理>
作业GitHub链接 < https://github.com/kcwGDUT/plagiarism_checker >

一、PSP表格

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 30 45
· Estimate · 估计任务时间 30 45
Development 开发 300 520
· Analysis · 需求分析 30 40
· Design Spec · 生成设计文档 30 20
· Design Review · 设计复审 20 25
· Coding Standard · 代码规范 10 10
· Design · 具体设计 40 50
· Coding · 具体编码 180 300
· Code Review · 代码复审 30 35
· Test · 测试 20 40
Reporting 报告 60 80
· Test Report · 测试报告 20 30
· Size Measurement · 计算工作量 10 10
· Postmortem & Process Improvement Plan · 事后总结 30 40
合计 450 645

二、计算模块接口的设计与实现

1. 模块组织与职责

  • 主控函数main(),负责解析命令行参数、协调各模块工作。
  • 核心处理函数
    • load_stopwords(path):加载停用词表。
    • read_file(file_path):读取文本文件,支持多编码尝试。
    • preprocess_text(text, stopwords):对文本进行清洗、分词和停用词过滤。
    • calculate_similarity(words1, words2):计算两段文本的余弦相似度。
    • write_result(file_path, similarity):将结果写入指定文件。

2. 模块间调用关系

main()
├── load_stopwords()
├── read_file() → orig_text
├── read_file() → plag_text
├── preprocess_text(orig_text, stopwords) → words1
├── preprocess_text(plag_text, stopwords) → words2
├── calculate_similarity(words1, words2)
└── write_result(result_path, similarity)

3. 核心算法设计

(1)算法流程

  1. 中文分词:使用 jieba.lcut() 进行精确模式分词,确保中文语义单元的准确性。
  2. 文本清洗:通过正则表达式 re.sub(r'[^\u4e00-\u9fa5 ]', '', text) 仅保留中文字符和空格,去除标点、数字等干扰。
  3. 停用词过滤:使用集合(set)存储停用词,利用其 O(1) 查找效率提升性能。
  4. 余弦相似度

(2)独到之处

  • 多编码自动识别read_file 函数尝试 utf-8gbkgb2312utf-16 多种编码,增强鲁棒性。
  • 低频词过滤:在计算前过滤掉仅出现一次的“噪声词”,提升大文本性能与准确性。
  • 结果截断保护write_result 对相似度进行 [0.0, 1.0] 范围截断,防止浮点误差导致异常输出。

三、计算模块接口的性能改进

1. 性能分析工具与过程

(1)初始性能瓶颈

  • 在 Profiler 视图中,按 "Total Time" 排序,发现:
    • calculate_similarity 函数耗时最长,占总执行时间的 ~68%
    • preprocess_text 次之,主要耗时在 jieba.lcut() 分词。
  • 查看调用树,发现 calculate_similarity 中对两个词列表进行了全量遍历,未做任何优化。

(2)性能改进思路与实现

根据 PyCharm 的性能分析结果,我们对 calculate_similarity 函数进行优化。

优化前代码(原始版本)

def calculate_similarity(words1, words2):
    from math import sqrt
    from collections import Counter

    counter1 = Counter(words1)
    counter2 = Counter(words2)

    # 原始实现:遍历所有词汇,包括不共同的
    dot_product = 0
    for word in counter1:
        dot_product += counter1[word] * counter2.get(word, 0)

    norm1 = sqrt(sum(count ** 2 for count in counter1.values()))
    norm2 = sqrt(sum(count ** 2 for count in counter2.values()))

    if norm1 == 0 or norm2 == 0:
        return 0.0
    return dot_product / (norm1 * norm2)

优化后代码(改进版本)

def calculate_similarity(words1, words2):
    from math import sqrt
    from collections import Counter

    # 提前终止:空文本
    if not words1 or not words2:
        return 0.0

    counter1 = Counter(words1)
    counter2 = Counter(words2)

    # 过滤低频词(出现次数 < 2)
    min_freq = 2
    counter1 = Counter({k: v for k, v in counter1.items() if v >= min_freq})
    counter2 = Counter({k: v for k, v in counter2.items() if v >= min_freq})

    # 再次检查是否为空
    if not counter1 or not counter2:
        return 0.0

    # 仅计算共同词汇的点积
    common_words = set(counter1.keys()) & set(counter2.keys())
    if not common_words:
        return 0.0

    dot_product = sum(counter1[word] * counter2[word] for word in common_words)
    norm1 = sqrt(sum(v ** 2 for v in counter1.values()))
    norm2 = sqrt(sum(v ** 2 for v in counter2.values()))

    return dot_product / (norm1 * norm2)

优化点说明

  1. 提前终止:空输入直接返回 0.0
  2. 低频词过滤:去除仅出现一次的“噪声词”,减少向量维度。
  3. 集合交集优化:只遍历共同词汇,避免无效计算。

2. 性能分析对比图

指标 优化前 优化后 提升幅度
calculate_similarity 耗时 498 ms 182 ms 63.5%
总执行时间 650 ms 320 ms 50.8%
函数调用次数 120,000+ 45,000 ↓ 62.5%

四、计算模块的单元测试展示

1. 单元测试代码(部分关键用例)

(1)停用词加载测试

def test_load_stopwords_success(self):
    """测试成功加载停用词表"""
    stopwords = load_stopwords(self.stopwords_file.name)
    self.assertEqual(stopwords, {"的", "了", "是", "在"})

def test_load_stopwords_not_found(self):
    """测试停用词文件不存在时的处理"""
    stopwords = load_stopwords("non_existent_stopwords.txt")
    self.assertEqual(stopwords, set())

(2)文本预处理测试

def test_preprocess_normal_text(self):
    """测试正常中文文本的分词与过滤"""
    processed = preprocess_text(self.test_text, self.test_stopwords)
    self.assertIn("今天", processed)
    self.assertIn("电影", processed)
    self.assertNotIn("是", processed)  # 停用词被过滤
    self.assertNotIn("", processed)  # 无空字符串

def test_preprocess_cleaned_empty(self):
    """测试清洗后为空的文本处理"""
    processed = preprocess_text("!!!###$$$", self.test_stopwords)
    self.assertEqual(processed, [])

(3)相似度计算测试

def test_similarity_identical(self):
    """测试完全相同的文本"""
    words1 = ["今天", "天气", "晴"]
    words2 = ["今天", "天气", "晴"]
    self.assertAlmostEqual(calculate_similarity(words1, words2), 1.0, places=2)

def test_similarity_none(self):
    """测试完全不同文本"""
    words1 = ["今天", "天气", "晴"]
    words2 = ["明天", "下雨", "在家"]
    self.assertAlmostEqual(calculate_similarity(words1, words2), 0.0, places=2)

(4)文件读取异常测试

def test_read_file_encoding_error(self):
    """测试编码错误时的多编码尝试失败"""
    bad_file = tempfile.NamedTemporaryFile(mode="wb", delete=False)
    bad_file.write(b"\x80\x81\x82")  # 非法UTF-8字节
    bad_file.close()

    with self.assertRaises(SystemExit):
        read_file(bad_file.name)
    os.unlink(bad_file.name)

2. 测试用例设计思路

(1)功能覆盖全面

  • 正常路径:验证标准输入下的正确行为(如正常中文文本处理)。
  • 边界条件
    • 空文件、空字符串 → 应返回空列表或0.0相似度。
    • 仅含标点符号的文本 → 清洗后为空,应正确处理。
  • 异常路径
    • 文件不存在、权限不足、编码错误 → 程序应捕获异常并退出,不崩溃。

(2)数据构造策略

  • 停用词测试:手动创建临时 .txt 文件写入预设停用词,模拟真实环境。
  • 文本预处理:使用典型中文句子,包含常见停用词(“的”、“了”、“是”),验证分词与过滤逻辑。
  • 相似度计算
    • 完全相同 → 期望值为 1.0
    • 完全不同 → 期望值为 0.0
    • 部分重合 → 期望值介于 0.5~0.8 之间(如“今天天气晴” vs “今天天气阴”)

(3)异常场景模拟

  • 使用 tempfile.NamedTemporaryFile 创建临时文件,并写入非法字节模拟编码错误。
  • 使用 patch 模拟 open() 抛出 PermissionError,验证异常处理逻辑。
  • 使用 sys.argv 模拟命令行参数错误,测试 main() 函数的参数校验。

3. 测试覆盖率

五、计算模块的异常处理说明

1. 异常类型与设计目标

异常类型 设计目标 单元测试样例 错误场景
FileNotFoundError 文件不存在时不崩溃,给出提示 test_read_file_not_found 用户输入错误路径
UnicodeDecodeError 自动尝试多种编码,失败后优雅退出 test_read_file_encoding_error 文件编码非 UTF-8/GBK
PermissionError 权限不足时捕获并提示 test_load_stopwords_error 停用词文件被锁定
SystemExit 主函数参数错误时退出 test_main_invalid_args 命令行参数不足

2. 异常测试用例示例

(1)FileNotFoundError —— 文件不存在

测试用例test_read_file_not_found

def test_read_file_not_found(self):
    """测试读取不存在的文件时程序是否优雅退出"""
    with self.assertRaises(SystemExit):
        read_file("non_existent_file.txt")

设计思路

  • 使用一个肯定不存在的路径作为输入。
  • 验证函数是否抛出 SystemExit,表示程序已通过 sys.exit(1) 正常终止,而非抛出未捕获的异常导致崩溃。
  • 控制台应输出类似 "读取文件 non_existent_file.txt 失败:..." 的提示信息。

实际场景: 用户在命令行中误输入文件名,如将 orig.txt 错写为 orign.txt,系统应提示错误并退出,而非直接报错中断。

(2)UnicodeDecodeError —— 文件编码错误

测试用例test_read_file_encoding_error

def test_read_file_encoding_error(self):
    """测试文件编码无法识别时的处理"""
    # 创建一个包含非法UTF-8字节的文件
    bad_file = tempfile.NamedTemporaryFile(mode="wb", delete=False)
    bad_file.write(b"\x80\x81\x82")  # 非法UTF-8字节序列
    bad_file.close()

    with self.assertRaises(SystemExit):
        read_file(bad_file.name)
    os.unlink(bad_file.name)

设计思路

  • 使用 tempfile.NamedTemporaryFile(mode='wb') 创建二进制文件,并写入非法 UTF-8 字节。
  • 验证 read_file 函数在尝试 utf-8gbk 等编码均失败后,是否最终抛出 SystemExit
  • 确保程序不会陷入无限循环或内存泄漏。

实际场景: 用户上传了一个使用特殊编码(如 ANSI 或 ISO-8859-1)保存的文本文件,系统应提示“编码未知,无法读取”并退出。

(3)PermissionError —— 文件权限不足

测试用例test_load_stopwords_error

@patch("builtins.open", side_effect=PermissionError("拒绝访问"))
def test_load_stopwords_error(self, mock_open):
    """测试加载停用词时发生权限错误的处理"""
    stopwords = load_stopwords("restricted.txt")
    self.assertEqual(stopwords, set())  # 返回空集,不中断程序
    print("警告:未找到stopwords.txt,将不使用停用词过滤")  # 实际应捕获stdout验证

设计思路

  • 使用 unittest.mock.patch 模拟 open() 函数抛出 PermissionError
  • 验证函数是否捕获异常并返回空集合 set(),表示不使用停用词过滤,但主流程继续执行。
  • 确保程序在缺少停用词文件时仍能运行,仅牺牲部分准确性。

实际场景: 停用词文件 stopwords.txt 被操作系统锁定或用户无读取权限,查重功能仍可继续,仅不进行停用词过滤。

(4)SystemExit —— 命令行参数错误

测试用例test_main_invalid_args

def test_main_invalid_args(self):
    """测试命令行参数不正确时的处理"""
    with patch("sys.argv", ["main.py", "orig.txt"]):  # 缺少参数
        with self.assertRaises(SystemExit):
            main()

    with patch("sys.argv", ["main.py", "1.txt", "2.txt", "3.txt", "4.txt"]):  # 参数过多
        with self.assertRaises(SystemExit):
            main()

设计思路

  • 使用 patch 修改 sys.argv,模拟参数数量错误(太少或太多)。
  • 验证 main() 函数是否调用 sys.exit(1) 退出,并输出使用说明。
  • 确保程序入口的健壮性,防止因参数错误导致后续模块异常。

实际场景: 用户运行命令时遗漏输出文件路径,如 python main.py orig.txt plag.txt,程序应提示正确用法并退出。

总结

本模块的异常处理设计遵循“Fail Fast, Fail Gracefully”原则:

  • 快速失败:一旦检测到不可恢复错误(如文件不存在、参数错误),立即终止。
  • 优雅降级:对于可恢复错误(如停用词文件缺失),返回默认值并继续执行。
  • 用户友好:所有异常均伴随清晰的错误提示,便于用户排查问题。

六、事后总结与过程改进计

1. 开发过程中的问题

在开发过程中,我们遇到了一系列挑战和问题,主要包括但不限于以下几个方面:

  • 需求理解不充分:初期对项目的需求分析不够深入,导致在开发中期需要对部分功能进行重新设计。
  • 技术选型考量不足:在选择技术栈时,未能充分考虑项目的扩展性,影响了开发效率。
  • 测试覆盖不足:由于时间紧迫,部分模块的测试覆盖率不高,导致上线后出现了一些本可以避免的问题。
  • 文档缺失:项目进行中忽视了文档编写,给后期维护带来了困难。

2. 改进计划

基于上述问题,我们制定了以下改进计划,旨在提高未来项目的成功率:

  • 优化技术选型流程:建立一套评估标准,综合考虑新技术的学习曲线、社区支持度、长期维护性等因素,做出更加合理的决策。
  • 增强测试策略:推行测试驱动开发(TDD),增加单元测试、集成测试的比例,确保代码质量和稳定性。同时,利用CI/CD管道自动化测试过程。
  • 重视文档工作:制定详细的文档编写规范,明确每个阶段需要产生的文档,确保项目知识得以有效积累。
posted @ 2025-09-24 00:15  柯程炜  阅读(18)  评论(0)    收藏  举报