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

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

这个作业属于哪个课程 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()
    程序执行入口,负责:
    命令行参数解析
    文件读取调度
    预处理流程控制
    结果计算与输出

函数关系(流程图)

微信图片_20250922214218

(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精确模式分词
    动态停用词表:自定义中文停用词集合,可灵活扩展

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

改进前:
232c2665ca9a63952815a916ff293b4f

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()

改进后:
2b29358d8c22c0aa84126ec1b0aa6654

通过对比优化前的火焰图(原 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

覆盖报告图

微信图片_20250923204019

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

(一)文件读取模块:处理“文件不存在”与“无权限”异常

文件读取是项目的第一步,需应对用户输入错误(如路径错误)或系统限制(如权限不足)的场景。

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 == []  # 验证返回空列表

真实错误场景: 用户输入“你好”作为原文,此时程序应跳过预处理,直接返回空列表,避免对“你好”进行分词(无意义)。

posted @ 2025-09-23 20:53  wodu  阅读(19)  评论(0)    收藏  举报