• 博客园logo
  • 会员
  • 周边
  • 新闻
  • 博问
  • 闪存
  • 赞助商
  • Chat2DB
    • 搜索
      所有博客
    • 搜索
      当前博客
  • 写随笔 我的博客 短消息 简洁模式
    用户头像
    我的博客 我的园子 账号设置 会员中心 简洁模式 ... 退出登录
    注册 登录
玖斯琳
博客园    首页    新随笔    联系   管理    订阅  订阅

第一次个人编程作业

第一次个人编程作业——论文查重

项目 内容
这个作业属于哪个课程 计科24级56班
这个作业要求在哪里 第一次个人编程作业
这个作业的目标 完成一个论文查重程序,实践需求分析、设计、编码、测试、性能分析与代码质量检查等软件开发流程
学号 3124007214
GitHub 仓库 https://github.com/DexJocelyn/3124007214

一、PSP 表格

在开始编码前,我对项目各阶段所需时间进行了预估;项目完成后,再根据实际开发过程补充实际耗时。记录如下:

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 20 15
· Estimate · 估计这个任务需要多少时间 20 15
Development 开发 300 245
· Analysis · 需求分析(包括学习新技术) 30 40
· Design Spec · 生成设计文档 15 10
· Design Review · 设计复审 10 5
· Coding Standard · 制定代码规范 10 5
· Design · 具体设计 30 25
· Coding · 具体编码 120 100
· Code Review · 代码复审 30 25
· Test · 测试、修改代码并提交 55 35
Reporting 报告 35 25
· Test Report · 测试报告 15 10
· Size Measurement · 计算工作量 5 5
· Postmortem & Process Improvement Plan · 事后总结并提出改进计划 15 10
合计 355 285

从结果来看,实际总耗时比预估少 70 分钟。编码和测试阶段比预想顺利,但需求分析实际使用了 40 分钟,比预估多 10 分钟。这说明我在开始项目前低估了理解输入输出要求、选择相似度算法以及设计异常处理所需的时间。今后进行类似任务时,我会为需求分析阶段预留更多时间,并在编码前先确定测试范围和性能指标。

二、项目需求分析

本项目需要实现一个能够计算两篇文本重复率的命令行程序。程序接收三个命令行参数:

  1. 原文文件的绝对路径;
  2. 抄袭版文件的绝对路径;
  3. 答案文件的绝对路径。

程序读取两个文本文件,计算它们的相似度,并将结果以保留两位小数的形式写入答案文件。例如:

python main.py "D:\\test\\orig.txt" "D:\\test\\copy.txt" "D:\\test\\ans.txt"

若计算结果为 0.614918...,则答案文件中写入:

0.61

除基本功能外,程序还需要考虑参数数量错误、输入文件不存在、文件编码不同、文本为空以及答案文件无法写入等情况。

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

3.1 项目结构

项目采用简洁的模块化结构,核心程序和测试代码相互分离:

3124007214/
├── main.py
├── requirements.txt
├── PSP.md
├── testdata/
│   ├── orig.txt
│   └── copy.txt
├── teacher_samples/
│   ├── orig.txt
│   └── orig_0.8_*.txt
├── tests/
│   ├── conftest.py
│   ├── requirements-dev.txt
│   └── test_main.py
└── profiling/
    ├── before.prof
    ├── after.prof
    ├── before_snakeviz.png
    └── after_snakeviz.png

程序运行部分只使用 Python 标准库,包括 sys、re、math 和 collections,评测环境不需要额外安装第三方运行依赖。pytest、pytest-cov 和 ruff 只用于开发阶段的测试与代码质量检查。

3.2 模块接口

函数 功能
read_text(path) 读取文本文件,优先使用 UTF-8,解码失败时尝试 GBK
clean_text(text) 使用正则表达式去除标点、空格等非文本字符
split_grams(text, n=2) 使用滑动窗口将文本划分为字符 n-gram,默认使用 2-gram
count_grams(text, n=2) 使用 Counter 统计各个二元组的出现次数
cosine_similarity(freq_a, freq_b) 根据两个词频向量计算余弦相似度
calc_similarity(text_a, text_b) 组织文本处理与相似度计算流程
write_answer(path, similarity) 将相似度以两位小数写入答案文件
main() 解析命令行参数并调用各个模块

模块之间的调用关系如下:

main()
 ├── read_text() × 2
 ├── calc_similarity()
 │    ├── count_grams() × 2
 │    │    ├── clean_text()
 │    │    └── split_grams()
 │    └── cosine_similarity()
 └── write_answer()

3.3 核心算法

本项目采用“字符二元组词频 + 余弦相似度”的方法计算重复率,主要分为以下四步。

1. 文本清洗

首先使用正则表达式去除标点符号、空格和换行等干扰字符:

def clean_text(text):
    return re.sub(r"[^\w]+", "", text)

例如,今天,天气!很好。 清洗后得到 今天天气很好。

2. 构造字符二元组

对清洗后的字符串使用长度为 2 的滑动窗口进行切分:

def split_grams(text, n=2):
    return [text[i : i + n] for i in range(len(text) - n + 1)]

例如,字符串 ABCD 会被划分为 AB、BC、CD。使用字符二元组不依赖中文分词库,能够保留相邻字符信息,也避免了不同分词工具带来的结果差异。

3. 构造词频向量

程序使用 Counter 统计每个二元组出现的次数。两个文本中出现过的二元组共同构成向量空间,每个二元组的出现次数就是对应维度的值。

4. 计算余弦相似度

设两个文本的字符二元组词频向量分别为 A 和 B,程序使用余弦相似度衡量两个向量的接近程度,其计算公式为:

similarity(A, B) = (A · B) / (‖A‖ × ‖B‖)

其中,A · B 表示两个向量的点积,‖A‖ 和 ‖B‖ 分别表示两个向量的模。

核心实现如下:

def cosine_similarity(freq_a, freq_b):
    dot = 0
    for k, v in freq_a.items():
        dot += v * freq_b.get(k, 0)

    sum_sq_orig = 0
    for v in freq_a.values():
        sum_sq_orig += v * v
    norm_orig = math.sqrt(sum_sq_orig)

    sum_sq_copy = 0
    for v in freq_b.values():
        sum_sq_copy += v * v
    norm_copy = math.sqrt(sum_sq_copy)

    if norm_orig == 0 or norm_copy == 0:
        return 0.0

    return dot / (norm_orig * norm_copy)

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

4.1 性能分析方法

我先使用 Python 自带的 cProfile 生成性能数据,再使用 SnakeViz 对调用关系和各函数耗时进行可视化。性能分析使用教师样例中的较长文本,以便让程序热点更加明显。

优化前的分析命令示例:

python -m cProfile -o profiling/before.prof main.py teacher_samples/orig.txt teacher_samples/orig_0.8_add.txt profiling/ans_before.txt
snakeviz profiling/before.prof

优化前的 SnakeViz 性能分析图

优化前共发生 110028 次函数调用,总耗时约为 0.0376s。其中,split_grams() 的累计耗时约为 0.0242s,是最明显的性能热点。进一步观察调用结果可以发现,原实现先创建空列表,再在循环中反复调用 append():

def split_grams(text, n=2):
    grams = []
    for i in range(len(text) - n + 1):
        grams.append(text[i : i + n])
    return grams

4.2 优化方法

针对上述热点,我将循环和 append() 改为列表推导式:

def split_grams(text, n=2):
    return [text[i : i + n] for i in range(len(text) - n + 1)]

该修改没有改变算法逻辑和输出结果,但减少了 Python 层的循环及方法调用开销。优化前后生成的答案文件内容一致,可以说明本次优化没有影响程序正确性。

4.3 优化结果

优化后的 SnakeViz 性能分析图

指标 优化前 优化后 变化
总运行时间 0.0376s 0.0212s 下降约 43.7%
函数调用次数 110028 5851 下降约 94.7%
split_grams() 累计耗时 0.0242s 0.0082s 下降约 66.0%

从结果可以看出,优化后总运行时间明显缩短,函数调用次数也大幅减少。split_grams() 仍然是文本处理过程中的主要耗时函数之一,但其累计耗时已从约 0.0242s 降至 0.0082s。这次分析让我认识到,性能优化不能只依靠主观判断,而应先通过分析工具定位热点,再进行有针对性的修改,并验证优化前后结果是否一致。

五、计算模块部分的单元测试

5.1 测试方法

本项目使用 pytest 编写单元测试,并使用 pytest-cov 统计语句覆盖率和分支覆盖率。测试命令如下:

python -m pytest -v
python -m pytest --cov=main --cov-branch --cov-report=term-missing

测试文件共设计了 19 个测试用例,覆盖正常情况、边界情况和异常情况。主要测试内容如下:

测试对象 测试内容
calc_similarity() 相同文本、相似文本、空文本、单侧空文本、标点差异、完全不同文本
clean_text() 验证标点和空格能否被正确清除
split_grams() 验证普通字符串和长度不足 2 的字符串
count_grams() 验证字符二元组及其词频统计结果
cosine_similarity() 验证空向量不会引发除零错误
read_text() 验证 UTF-8、GBK、文件不存在和第二次打开失败等路径
write_answer() 验证保留两位小数以及无效输出路径
main() 验证正常命令行流程、参数不足和参数过多

部分核心测试代码如下:

@pytest.mark.parametrize(
    ("text_a", "text_b", "expected"),
    [
        (ORIG, ORIG, 1.0),
        (ORIG, COPY, 0.6149186938124421),
        ("", "", 0.0),
        ("", "任意内容", 0.0),
        ("今天,天气!", "今天天气", 1.0),
        ("abc", "xyz", 0.0),
    ],
)
def test_calc_similarity(text_a, text_b, expected):
    result = main.calc_similarity(text_a, text_b)
    assert result == pytest.approx(expected)

这里使用参数化测试以同一套逻辑覆盖多组输入,既减少了重复代码,也更直观地展示了相似度函数在不同场景下的预期行为。

5.2 覆盖率结果

测试覆盖了 main.py 中的正常分支、边界分支和异常分支,最终覆盖率达到 100%。入口保护语句 if __name__ == "__main__" 使用 # pragma: no cover 标记,因为其作用只是区分脚本运行和模块导入,具体的 main() 逻辑已经通过直接调用完成测试。

Coverage 总体覆盖率报告

main.py 代码覆盖详情
达到 100% 覆盖率并不意味着程序一定不存在缺陷,但它能够说明当前代码中的语句和主要分支均被测试执行。相比只检查一个正常样例,本项目还专门测试了空文本、缺失文件、GBK 编码、非法输出路径和错误参数数量等场景,从而提高了测试的有效性。

六、异常处理说明

6.1 命令行参数数量错误

程序要求恰好接收三个参数。当参数不足或过多时,打印正确用法并结束本次运行:

用法: python main.py <原文路径> <抄袭版路径> <答案输出路径>

对应测试分别模拟零个业务参数和多余参数,确认程序不会在参数错误时继续访问不存在的列表元素。

6.2 输入文件不存在或无法读取

read_text() 使用 try-except 捕获 OSError。如果文件不存在、路径错误或没有读取权限,程序会输出警告并返回空字符串,随后相似度计算结果为 0.00,不会直接崩溃。

except OSError as error:
    print("警告:读取文件失败", path, error)
    return ""

6.3 文件编码不一致

程序优先按 UTF-8 读取文件。若发生 UnicodeDecodeError,则尝试使用 GBK 重新读取,以兼容常见的中文文本编码。单元测试通过写入一个 GBK 编码的临时文件验证了该分支。

6.4 空文本与零向量

如果文本为空,或清洗后长度不足以构造字符二元组,得到的词频向量为空。此时向量模长为 0,程序直接返回 0.0,避免出现除零异常。

6.5 答案文件无法写入

当答案文件的父目录不存在或目标路径不可写时,write_answer() 捕获 OSError,输出“写入答案文件失败”的警告并返回 False。main() 只有在写入成功后才输出“答案已写入文件”,避免给出错误的成功提示。

七、代码质量分析

我使用 Ruff 对项目进行静态代码检查。Ruff 能够发现未使用的导入、不规范的代码格式、潜在错误以及部分不符合 Python 代码规范的问题。在质量分析阶段,我根据检查结果调整了切片表达式的空格格式,并补充了源码和测试文件末尾的换行,同时将 Ruff 加入开发依赖。相关修改被单独记录在 Git 提交中,使代码质量检查过程可追踪。

检查命令如下:

python -m ruff check .

ba8ac749f5d77c15176d963d817ca473

通过静态检查可以在程序运行前发现一部分代码问题,但 Ruff 不能代替单元测试。因此本项目同时使用 Ruff 检查代码规范、使用 pytest 验证功能、使用 Coverage 检查测试覆盖范围,并使用 cProfile 与 SnakeViz 分析运行性能。

八、运行结果

使用项目自带的简单样例进行测试:

原文:

今天是星期天,天气晴,今天晚上我要去看电影。

抄袭版:

今天是周天,天气晴朗,我晚上要去看电影。

程序计算得到相似度约为 0.614918...,控制台显示重复率,并在答案文件中写入:

0.61

8.1 教师样例测试

本项目使用老师提供的测试文本进行功能测试和性能分析。其中,orig.txt 与 orig_0.8_add.txt 为普通 UTF-8 文本,程序计算得到的重复率为 0.90。另外几份样例包含 HTML 页面包装内容,直接作为纯文本参与计算会引入大量无关标签,影响相似度结果。这也说明当前程序对输入文件格式较为敏感,后续可以增加 HTML 正文提取功能,提高程序的鲁棒性。

整个程序无需第三方运行依赖,能够直接通过 Python 命令行执行。

九、Git 版本管理

本项目使用 Git 记录开发过程,主要提交阶段包括:

  1. 初始化项目和依赖说明;
  2. 完成“2-gram 词频 + 余弦相似度”核心功能;
  3. 添加测试数据;
  4. 完善单元测试并达到 100% 覆盖率;
  5. 使用 Ruff 进行代码质量分析;
  6. 添加优化前的性能分析结果;
  7. 优化 n-gram 生成过程并添加优化后的性能结果。

将功能实现、测试、质量检查和性能优化分别提交,能够使版本历史更加清晰,也便于在出现问题时定位改动范围。

十、总结与改进计划

通过本次个人编程作业,我完成了从需求分析、算法设计、代码实现到单元测试、代码质量检查和性能优化的完整流程。过去完成程序时,我更关注代码能否运行;这次作业让我认识到,一个较完整的软件项目还需要考虑异常输入、编码兼容、测试覆盖率、性能数据和版本管理。

在算法方面,我使用字符二元组和余弦相似度完成文本查重。该方法结构简单、无需第三方分词库,对局部增删和少量文字修改能够给出相似度结果。但它主要依据相邻字符是否相同,对大段落调换顺序、同义词替换以及语义相近但表述不同的文本识别能力有限。

后续可以从以下方面继续改进:

  1. 比较不同 n-gram 长度对准确率和运行时间的影响;
  2. 增加停用词处理或 TF-IDF 权重,降低高频但信息量较低的字符组合影响;
  3. 增加更多长文本、段落重排和同义改写测试,提高测试数据的代表性;
  4. 将文件读取失败与合法空文本区分开,使用更明确的返回值或自定义异常;
  5. 在继续优化前先建立稳定的性能基线,并多次运行取平均值,减少单次测量误差。

本次作业中,PSP 表格帮助我比较预估耗时和实际耗时,Coverage 帮助我发现未覆盖路径,Ruff 帮助我检查代码质量,SnakeViz 则让我能够根据真实调用耗时定位性能热点。通过这些工具,我对“可运行、可测试、可维护、可分析”的软件开发过程有了更加具体的认识。

posted on 2026-09-15 19:50  玖斯琳  阅读(15)  评论(0)    收藏  举报
刷新页面返回顶部
博客园  ©  2004-2026
浙公网安备 33010602011771号 浙ICP备2021040463号-3