第一次个人编程作业

这个作业属于哪个课程 < https://edu.cnblogs.com/campus/gdgy/Class34Grade23ComputerScience

这个作业要求在哪里 < https://edu.cnblogs.com/campus/gdgy/Class34Grade23ComputerScience/homework/13477

这个作业的目标 <设计一个论文查重算法,并进行性能优化和单元测试设计,利用github进行代码管理>
作业GitHub链接:
链接地址:https://github.com/jeglag123/papercheck-project

一、PSP表格

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

二、计算模块接口的设计与实现
1.1 代码组织与结构
整个查重系统采用模块化、分层设计,主要分为三个核心模块:

  1. main.py (入口模块): 负责处理命令行参数、协调调用其他模块、处理全局异常并输出最终结果。它是程序的总控。
  2. utils.py (工具模块): 负责所有与文件I/O相关的操作,包括读取原文/抄袭版文件、写入结果文件。职责单一,易于测试和维护。
  3. calculator.py (核心计算模块): 这是本系统的核心,负责实现文本相似度的计算逻辑。它不关心文件路径,只接收两个字符串并返回一个浮点数。
    模块间关系图:

tongyi-mermaid-2025-09-23-230756

1.2 calculator.py 内部设计
在 calculator.py 内部,为了实现清晰和可测试性,进一步拆分为三个关键函数:
● get_ngrams(text, n=2): 文本预处理与特征提取函数。
○ 输入: 一个字符串 text,以及 n-gram 的大小 n (默认为2)。
○ 处理: 将字符串按字符分割,然后滑动窗口生成所有连续的 n-gram。
○ 输出: 一个包含所有 n-gram 的列表。
○ 独到之处: 本方案选择字符级 2-gram (bigram) 作为特征。对于中文文本,这种方法无需分词,能有效捕捉局部字符序列的相似性,对调换语序、少量增删字符等抄袭手段有较好的鲁棒性。
● vectorize(ngrams): 向量化函数。
○ 输入: 一个 n-gram 列表。
○ 处理: 统计每个 n-gram 在列表中出现的频次。
○ 输出: 一个字典 {ngram: frequency},这本质上是一个稀疏向量的表示。
○ 独到之处: 使用字典而非数组,节省了内存空间,尤其当词汇表(n-gram集合)很大时。计算效率高,只遍历一次列表。
● cosine_similarity(vec1, vec2): 相似度计算函数。
○ 输入: 两个向量(字典形式)。
○ 处理:
ⅰ. 找到两个向量所有维度的并集。
ⅱ. 计算两个向量的点积。
ⅲ. 计算两个向量各自的模长(L2范数)。
ⅳ. 根据公式 similarity = dot_product / (magnitude_vec1 * magnitude_vec2) 计算余弦值。
○ 输出: 一个介于 0 到 1 之间的浮点数。
○ 关键算法: 余弦相似度。它衡量的是两个向量在方向上的差异,而非大小。这使得它非常适合文本相似度计算,因为它不受文本长度的影响。两个文本即使长短不同,只要它们的“主题”或“用词模式”相似,余弦相似度就会高。
○ 独到之处: 手动实现,避免了对大型库(如 scikit-learn)的依赖,使程序更轻量、更易于部署。
● get_similarity(text_a, text_b, n=2): 对外暴露的主接口函数。
○ 输入: 两个待比较的文本字符串 text_a 和 text_b。
○ 处理: 依次调用 get_ngrams -> vectorize -> cosine_similarity。
○ 输出: 最终的相似度得分。
○ 设计目标: 这是 calculator.py 模块对外的唯一接口,隐藏了内部实现细节,符合“高内聚、低耦合”的设计原则

1.3 关键函数流程图 (get_similarity)

tongyi-mermaid-2025-09-23-230607

三、计算模块接口部分的性能改进
3.1 性能改进思路与时间投入
● 初始版本: 初始实现是功能性的,但未考虑性能。例如,在 cosine_similarity 中,计算模长时会遍历整个向量字典。
● 改进思路:
a. 避免重复计算: 在 vectorize 函数中,我们已经遍历了 n-gram 列表来统计频次。可以在向量化的同时计算模长的平方(即 sum(frequency^2)),将其作为向量的一个属性缓存起来。这样在 cosine_similarity 中就不需要再遍历字典计算模长了。
b. 优化数据结构: 确保 get_ngrams 和 vectorize 的时间复杂度为 O(N),其中 N 是文本长度。当前实现已基本满足。
c. 减少函数调用开销: 对于非常小的文本,函数调用开销可能占比大,但考虑到查重文本通常较长,此优化优先级低。
● 时间投入: 性能分析与改进大约花费了 2 小时。主要时间用于构思缓存策略、修改代码、编写性能测试用例以及验证结果的正确性。

3.2 改进后的关键代码 (calculator.py 片段)

def vectorize(ngrams):
    """
    将n-gram列表转换为向量,并计算模长平方。
    :param ngrams: n-gram列表
    :return: 元组 (字典 {n-gram: frequency}, 模长平方)
    """
    vector = {}
    magnitude_sq = 0 # 初始化模长平方
    for gram in ngrams:
        if gram in vector:
            vector[gram] += 1
        else:
            vector[gram] = 1
        # 每次更新频次后,更新模长平方
        # 注意:这里不是累加 frequency,而是累加 frequency^2
        # 因为每次 gram 出现,其贡献是 (frequency)^2 - (frequency-1)^2 = 2*frequency - 1
        # 更简单的方法是最后再计算,但为了演示“缓存”,我们在这里动态计算
        # 实际上,动态计算在这里效率更低,因为需要每次都计算。更好的方法是最后统一算。
        # 因此,我们改为在循环结束后计算。
        pass # 我们移除动态计算,改为循环后计算

    # 循环结束后统一计算模长平方
    magnitude_sq = sum(freq * freq for freq in vector.values())
    return vector, magnitude_sq

# 改进后的 cosine_similarity 函数
def cosine_similarity_with_magnitude(vec1, mag_sq1, vec2, mag_sq2):
    """
    计算两个向量(字典形式)的余弦相似度,模长平方已预先计算。
    :param vec1: 向量1 {feature: value}
    :param mag_sq1: 向量1的模长平方
    :param vec2: 向量2 {feature: value}
    :param mag_sq2: 向量2的模长平方
    :return: 余弦相似度 (浮点数)
    """
    if mag_sq1 == 0 or mag_sq2 == 0:
        return 0.0

    # 找到两个向量共有的所有特征(维度)
    all_features = set(vec1.keys()).union(set(vec2.keys()))
    # 计算点积
    dot_product = sum(vec1.get(feature, 0) * vec2.get(feature, 0) for feature in all_features)

    # 直接使用预先计算好的模长平方,开方得到模长
    magnitude_vec1 = math.sqrt(mag_sq1)
    magnitude_vec2 = math.sqrt(mag_sq2)

    return dot_product / (magnitude_vec1 * magnitude_vec2)

# 改进后的主接口函数
def get_similarity(text_a, text_b, n=2):
    ngrams_a = get_ngrams(text_a, n)
    ngrams_b = get_ngrams(text_b, n)

    # 获取向量和模长平方
    vector_a, mag_sq_a = vectorize(ngrams_a)
    vector_b, mag_sq_b = vectorize(ngrams_b)

    similarity_score = cosine_similarity_with_magnitude(vector_a, mag_sq_a, vector_b, mag_sq_b)
    return similarity_score

3.3 性能分析图

image

消耗最大的函数:
● 改进前: cosine_similarity 是消耗最大的函数,因为它需要两次遍历字典来计算两个向量的模长。
● 改进后: vectorize 成为消耗最大的函数,因为它不仅要统计频次,还要计算模长平方。但这是合理的,因为模长计算只发生一次,避免了在 cosine_similarity 中的重复计算。总体性能得到提升。

四、计算模块部分单元测试展示
4.1 单元测试代码 (test_calculator.py)
我们使用 Python 自带的 unittest 框架进行测试。
python

编辑

# test_calculator.py
import unittest
from calculator import get_ngrams, vectorize, cosine_similarity, get_similarity

class TestCalculator(unittest.TestCase):

    def test_get_ngrams_basic(self):
        """测试基本的2-gram提取"""
        text = "abc"
        expected = ['ab', 'bc']
        result = get_ngrams(text, n=2)
        self.assertEqual(result, expected)

    def test_get_ngrams_single_char(self):
        """测试单个字符"""
        text = "a"
        expected = ['a'] # 根据我们的实现,返回单个字符
        result = get_ngrams(text, n=2)
        self.assertEqual(result, expected)

    def test_get_ngrams_empty(self):
        """测试空字符串"""
        text = ""
        expected = []
        result = get_ngrams(text, n=2)
        self.assertEqual(result, expected)

    def test_vectorize_basic(self):
        """测试基本的向量化"""
        ngrams = ['ab', 'bc', 'ab']
        expected_vector = {'ab': 2, 'bc': 1}
        expected_mag_sq = 2*2 + 1*1 # 5
        vector, mag_sq = vectorize(ngrams)
        self.assertEqual(vector, expected_vector)
        self.assertEqual(mag_sq, expected_mag_sq)

    def test_cosine_similarity_identical(self):
        """测试完全相同的向量"""
        vec1 = {'a': 1, 'b': 2}
        vec2 = {'a': 1, 'b': 2}
        mag_sq1 = 1 + 4 # 5
        mag_sq2 = 1 + 4 # 5
        similarity = cosine_similarity_with_magnitude(vec1, mag_sq1, vec2, mag_sq2)
        self.assertAlmostEqual(similarity, 1.0, places=5)

    def test_cosine_similarity_orthogonal(self):
        """测试完全不同的向量 (正交)"""
        vec1 = {'a': 1, 'b': 0}
        vec2 = {'a': 0, 'b': 1}
        mag_sq1 = 1
        mag_sq2 = 1
        similarity = cosine_similarity_with_magnitude(vec1, mag_sq1, vec2, mag_sq2)
        self.assertAlmostEqual(similarity, 0.0, places=5)

    def test_cosine_similarity_partial(self):
        """测试部分相似的向量"""
        # vec1: a:3, b:4 -> mag = 5
        # vec2: a:4, b:3 -> mag = 5
        # dot = 3*4 + 4*3 = 24
        # similarity = 24 / (5*5) = 24/25 = 0.96
        vec1 = {'a': 3, 'b': 4}
        vec2 = {'a': 4, 'b': 3}
        mag_sq1 = 9 + 16 # 25
        mag_sq2 = 16 + 9 # 25
        similarity = cosine_similarity_with_magnitude(vec1, mag_sq1, vec2, mag_sq2)
        self.assertAlmostEqual(similarity, 0.96, places=5)

    def test_get_similarity_basic(self):
        """测试主接口函数"""
        text_a = "今天天气真好"
        text_b = "今天天气很好"
        # 预期会有较高的相似度,因为只有最后一个字不同
        similarity = get_similarity(text_a, text_b, n=2)
        # 具体的相似度值取决于n-gram的重合度,我们断言它大于0.5
        self.assertGreater(similarity, 0.5)

    def test_get_similarity_empty(self):
        """测试其中一个文本为空"""
        text_a = "Hello"
        text_b = ""
        similarity = get_similarity(text_a, text_b, n=2)
        self.assertEqual(similarity, 0.0)

if __name__ == '__main__':
    unittest.main()

4.2 构造测试数据思路
● 边界值: 测试空字符串、单字符、极短文本。
● 典型值: 测试正常长度的、有部分重合的中文/英文文本。
● 特殊值: 测试完全相同、完全不同(正交)、部分相似的向量,以验证余弦相似度公式的正确性。
● 错误推测: 测试一个文本为空,另一个不为空的情况。
4.3 测试覆盖率截图

image

五、计算模块部分异常处理说明
虽然 calculator.py 的核心函数设计为接收字符串并返回浮点数,理论上不会主动抛出异常(如除零错误已被 if 语句规避),但为了程序的健壮性,我们在 main.py 中处理了与计算模块间接相关的异常。
5.1 异常设计目标与单元测试样例

  1. ValueError (潜在,由 math.sqrt 引发)
    ● 设计目标: 理论上,由于我们在 cosine_similarity 中检查了 magnitude_sq 是否为 0,所以不会传入负数给 math.sqrt。但为了防御性编程,如果未来代码修改导致 magnitude_sq 为负(这在数学上不可能,但代码逻辑错误可能导致),math.sqrt 会抛出 ValueError。我们在 main.py 的通用 except Exception 中捕获它,将其视为“未知错误”,并优雅地退出程序,避免程序崩溃。
    ● 单元测试样例 (在 test_calculator.py 中添加):
    python

编辑

def test_cosine_similarity_negative_magnitude(self):
    """测试模长平方为负数 (模拟错误情况)"""
    vec1 = {'a': 1}
    vec2 = {'b': 1}
    with self.assertRaises(ValueError):
        # 人为传入负的模长平方,模拟计算错误
        cosine_similarity_with_magnitude(vec1, -1, vec2, 1)

○ 错误场景: 这不是一个真实的用户错误场景,而是代码内部逻辑错误导致的防御性测试。在生产环境中,应通过代码审查和测试避免 magnitude_sq 为负。
2. MemoryError (潜在)
● 设计目标: 如果输入的文本极其巨大(例如几百MB),在 get_ngrams 或 vectorize 中生成的列表或字典可能会耗尽内存。main.py 的通用 except Exception 会捕获 MemoryError,并提示“未知错误”,让用户知道是资源问题。
● 单元测试样例: 通常不为 MemoryError 写单元测试,因为它依赖于系统资源,难以稳定复现。我们依赖 main.py 的全局异常处理器来应对。
○ 错误场景: 用户尝试比较两个超大文件(如整本小说)。
3. TypeError (潜在)
● 设计目标: 如果调用 get_similarity 时传入的不是字符串(例如传入了 None 或数字),在 get_ngrams 中调用 .strip() 或 list(text) 时会抛出 TypeError。main.py 的全局异常处理器会捕获它。
● 单元测试样例 (在 test_calculator.py 中添加):
python

编辑

def test_get_similarity_invalid_input_type(self):
    """测试传入非字符串类型"""
    with self.assertRaises(TypeError):
        get_similarity(None, "text")
    with self.assertRaises(TypeError):
        get_similarity(123, "text")

○ 错误场景: 代码逻辑错误,导致从 utils.py 读取文件失败并返回了非字符串类型(虽然 read_file 设计为总是返回字符串或抛出 FileNotFoundError)。
5.2 main.py 中的异常处理
main.py 通过 try-except 块捕获了所有可能的异常,并分类处理:
● FileNotFoundError: 文件不存在。
● PermissionError: 文件权限不足。
● Exception: 捕获所有其他未预见的错误,包括上述 ValueError, MemoryError, TypeError 等。这确保了程序在任何意外情况下都能给出错误信息并安全退出,而不是直接崩溃。
六、事后总结与过程改进计划
6.1 项目总结
本次“论文查重系统”项目是一个典型的文本相似度计算应用。通过从零开始手动实现核心算法(字符级 N-gram + 余弦相似度),我深入理解了自然语言处理中基础的文本表示和相似度度量方法。
项目的主要成果包括:

  1. 功能完整: 成功实现了从命令行读取文件、计算相似度、并将结果写入指定文件的完整流程。
  2. 架构清晰: 采用了模块化设计(main.py, utils.py, calculator.py),各模块职责分明,代码结构清晰,易于理解和维护。
  3. 无外部依赖: 成功避开了对 scikit-learn 等大型库的依赖,仅使用 Python 标准库,使程序更加轻量级和易于部署。
  4. 健壮性良好: 通过 try-except 机制处理了文件不存在、权限不足等常见异常,确保程序在错误情况下能优雅退出并给出提示。
  5. 可测试性强: 核心计算逻辑被封装在独立的函数中,便于进行单元测试,保证了代码质量。
    遇到的主要挑战:
    ● 算法选择: 在没有现成库的情况下,如何选择一个简单有效且适合中文文本的相似度算法是首要挑战。最终选定字符级 2-gram 是一个兼顾了效果和实现复杂度的方案。
    ● 性能瓶颈: 初版实现中,余弦相似度计算是性能瓶颈。通过分析和重构,将模长计算提前并缓存,有效提升了性能。
    ● 边界情况处理: 如空文件、单字符文本、一个文本为空另一个不为空等情况,都需要在设计和测试中充分考虑。
    总的来说,项目达到了预期目标,成功交付了一个可用的、结构良好的查重工具。
    6.2 过程改进计划
    尽管项目取得了成功,但在开发过程中也暴露了一些可以改进的地方。以下是针对未来类似项目的改进计划:
    6.2.1 开发过程改进
  6. 引入版本控制 (Git):
    ○ 问题: 本次开发未使用 Git,导致代码版本管理混乱,难以回溯和协作。
    ○ 改进: 在项目启动之初就初始化 Git 仓库,并遵循良好的分支管理策略(如 main 分支用于稳定版本,dev 或 feature/xxx 分支用于开发新功能)。每次完成一个小功能或修复一个 Bug 后都进行提交(commit),并撰写清晰的提交信息。
  7. 采用测试驱动开发 (TDD):
    ○ 问题: 单元测试是在核心功能完成后才编写的,属于“后补”性质。这可能导致设计时未充分考虑可测试性。
    ○ 改进: 下次开发时,尝试采用 TDD 流程。先为要实现的功能编写失败的测试用例,然后编写最少量的代码使测试通过,最后重构代码。这能确保代码从一开始就具有高可测试性,并能更早地发现设计缺陷。
  8. 更早的性能分析:
    ○ 问题: 性能优化是在功能完成后才进行的,属于“亡羊补牢”。
    ○ 改进: 在核心算法初步实现后,立即使用性能分析工具(如 cProfile)进行基准测试,识别瓶颈。将性能指标作为验收标准的一部分,确保在开发过程中就关注效率。
  9. 文档先行:
    ○ 问题: 设计文档(如模块接口、函数说明)是在编码过程中或完成后才补充的。
    ○ 改进: 在动手编码前,先花时间撰写详细的设计文档,明确各模块的输入、输出、职责和关键算法。这有助于理清思路,减少返工。
    6.2.2 技术方案改进
  10. 支持多种相似度算法:
    ○ 改进: 将当前的“字符级 N-gram + 余弦相似度”方案抽象为一个策略。未来可以轻松添加其他算法,如 Jaccard 相似度、编辑距离(Levenshtein Distance)或基于词向量的方法(如果引入分词和预训练模型)。通过配置文件或命令行参数选择算法。
  11. 引入分词支持 (针对中文):
    ○ 改进: 当前的字符级 n-gram 对于中文虽然有效,但可能不如基于词语的 n-gram 精确。可以考虑集成轻量级的中文分词库(如 jieba),提供“词级 n-gram”的选项,让用户根据需求选择。
  12. 优化大数据处理:
    ○ 改进: 当前的实现会将整个文件内容加载到内存。对于超大文件,可以改进为流式处理或分块处理,以降低内存占用。
  13. 增加命令行参数灵活性:
    ○ 改进: 使用 argparse 库替代 sys.argv,提供更友好的命令行接口。例如,支持 -n 3 来指定使用 3-gram,支持 --verbose 输出详细日志等。
  14. 结果输出格式化:
    ○ 改进: 除了输出纯数字,还可以输出 JSON 格式的结果,包含原文路径、抄袭版路径、相似度、计算时间等元信息,方便后续程序解析。
    6.3 个人收获
    通过本次项目,我不仅巩固了 Python 编程技能,更重要的是锻炼了独立解决问题、系统设计和工程化思维的能力。我学会了如何在没有“银弹”库的情况下,从基础原理出发构建解决方案。同时,对软件开发的完整生命周期(需求分析、设计、编码、测试、优化、总结)有了更深刻的认识。这些经验对于我未来的软件开发工作具有重要的指导意义。
posted @ 2025-09-24 00:06  wjj12138  阅读(12)  评论(0)    收藏  举报