第一次个人编程作业

第一次个人编程作业

这个作业属于哪个课程 计科24级78班-软件工程
这个作业要求在哪里 个人项目
这个作业的目标 掌握个人软件开发PSP流程,独立完成论文查重小程序开发;熟悉文本相似度算法原理与工程落地;掌握模块化开发、异常处理、单元测试、性能优化;熟练使用Github进行版本迭代管理,规范完成软件工程个人项目文档撰写与成果发布。

1. 作业Github仓库链接

https://github.com/zhenglebing/zhenglebing/tree/main/3224004234

2. PSP预估耗时表(开发前预估)

本次预估为编码开发开始前的预估时间,用于规范个人开发流程,后续开发完成后将补充实际耗时。

PSP2.1 Personal Software Process Stages 预估耗时(分钟)
Planning 计划 25
·Estimate ·估计任务整体耗时 25
Development 开发 350
·Analysis ·需求分析、算法学习、环境搭建 50
·Design Spec ·撰写设计思路、模块划分文档 30
·Design Review ·设计方案自查、逻辑校验 20
·Coding Standard ·制定代码命名、注释、格式规范 15
·Design ·算法设计、函数接口设计、流程设计 45
·Coding ·核心代码编写、功能实现 90
·Code Review ·代码自查、消除冗余、修正不规范写法 30
·Test ·自测、造测试用例、修复bug 30
Reporting 报告总结 55
·Test Report ·整理单元测试结果、覆盖率数据 20
·Size Measurement ·统计代码量、工作量 10
·Postmortem ·项目复盘、改进方案撰写 25
合计 430

3. 计算模块接口的设计与实现过程

3.1 命令行接口规范

程序采用命令行方式接收输入输出文件路径,入口文件为main.py,运行格式:

python main.py <原文路径> <待比对文本路径> <输出结果路径>

示例:

python main.py samples/orig.txt samples/orig_0.8_add.txt samples/ans_add.txt

实测输出:
相似度:0.90,结果已写入 samples/ans_add.txt

3.2 项目文件架构

3224004234/
├─ main.py               # 主程序,包含全部业务函数
├─ test_main.py          # 单元测试代码
├─ requirements.txt      # 项目依赖声明(coverage、snakeviz)
├─ .gitignore            # git忽略配置
└─ samples/              # 存放测试文本样例
   ├─ orig.txt
   ├─ orig_0.8_add.txt
   ├─ orig_0.8_del.txt
   ├─ orig_0.8_dis_1.txt
   ├─ orig_0.8_dis_10.txt
   └─ orig_0.8_dis_15.txt

3.3 代码组织与函数调用关系

本项目采用函数式组织代码,main.py 共 5 个独立函数:

  1. read_file(path):以 UTF-8 读取文件内容,文件不存在时抛出 FileNotFoundError。
  2. normalize(text):清洗文本,去除标点、空格、换行,英文字母转小写。
  3. ngram_counter(text, n):对文本滑动截取连续字符片段,统计频次;n<=0 抛出 ValueError。
  4. cosine_similarity(text_a, text_b, n=2):核心函数,调用预处理与分词,计算余弦相似度,返回 0~1 的浮点数。
  5. main():入口函数,校验命令行参数,读取文件、计算相似度并写入结果文件。

调用关系:main() → read_file() → cosine_similarity() → normalize() →ngram_counter()。模块仅通过参数与返回值传递数据,无全局可变状态。

3.4 算法关键与独到之处

采用字符 2-gram + 余弦相似度。先清洗文本,再用长度 2 的滑窗截取片段构建词频向量,最后用向量点积除以模长乘积得到相似度,数值越接近 1 代表文本相似度越高。

独到之处:

  1. 预处理只保留中文、数字、英文字母,消除换行、标点带来的无效特征。
  2. 使用频次统计而非简单集合,能识别多次重复片段,对增删、局部改写识别效果更好。
  3. 内置数学保护:清洗后为空直接返回 0,规避模长为 0 的除零错误;文本短于 n 时自动缩小 n,保证极短文本也能比较。
  4. 预编译正则表达式,并在 n=2 时用 zip 一次配对,减少临时字符串创建,提升运行效率。

3.5 函数流程图

image

4. 计算模块性能改进

4.1 性能分析方法

安装 snakeviz 后,通过 cProfile 采样并生成 profile.stats:

python -m pip install snakeviz
python -m snakeviz profile.stats

4.2 性能分析结果

在将样例文本放大到约 2.1 亿字符的压力场景下,优化前 profile 结果:

函数 累计耗时 占比
ngram_counter 89.56 s 主要瓶颈
re.Pattern.sub(normalize) 47.89 s 次瓶颈

结论:ngram_counter 是消耗最大的函数。
5e1f06b032d99c3451aaad0f175b42ab

4.3 改进思路与优化效果

优化项 说明
预编译正则 将 re.sub 的正则提升为模块级 re.compile,避免重复编译
n=2 特殊路径 用 Counter(zip(text, text[1:])) 替代循环切片,避免生成大量临时字符串
极短文本处理 n = min(n, len(a), len(b)),避免无效计算与除零
提前判空 清洗后为空直接返回 0.00,跳过后续计算

优化前后对比(同一 2.1 亿字符压力数据):

指标 优化前 优化后 提升
总耗时 139.52 s 78.79 s 约 1.77 倍
ngram_counter 耗时 89.56 s 63.15 s 约 1.42 倍
计算结果 0.8966 0.8966 保持一致

5. 计算模块单元测试展示

5.1 测试设计思路

采用白盒测试为主,边界值、等价类测试为辅,覆盖:正常文本、纯标点、空文本、空白字符、极短文本、完全相同、部分相似、完全无关、非法 n、无效文件路径,以及命令行入口的正常 / 异常路径。共 17 个测试用例(作业要求至少 10 个)。

5.2 测试函数一览

编号 测试函数 验证内容
1 test_identical_text 完全相同文本相似度为 1
2 test_slight_change 略有改动相似度介于 0~1
3 test_empty_original 原文为空返回 0
4 test_empty_copy 抄袭文为空返回 0
5 test_diff_text 完全无关文本返回 0
6 test_punct_only 纯标点清洗后返回 0
7 test_upper_lower 英文大小写不敏感
8 test_short_text 极短文本相同为 1
9 test_file_not_exist 文件不存在抛 FileNotFoundError
10 test_invalid_n 非法 n 抛 ValueError
11 test_whitespace 空格换行干扰被消除
12 test_long_text_sample 长文本集成测试,0.7~0.99
13 test_read_file_success 正常读取文件内容
14 test_normalize 预处理结果正确
15 test_main_missing_args 参数不足仅打印用法
16 test_main_normal_run 命令行正常运行,输出 1.00
17 test_main_file_not_exist 输入文件缺失输出 0.00 不崩溃

核心单元测试代码展示:

def test_identical_text(self):
    # 测试1:完全相同文本,相似度应为 1.0
    score = cosine_similarity("今天是星期天", "今天是星期天")
    self.assertAlmostEqual(score, 1.0)

def test_empty_original(self):
    # 测试3:原文为空,相似度应为 0.0
    self.assertEqual(cosine_similarity("", "任意文本"), 0.0)

def test_invalid_n(self):
    # 测试10:非法 n 值应抛出 ValueError
    with self.assertRaises(ValueError):
        ngram_counter("abc", 0)

def test_long_text_sample(self):
    # 测试12:长文本集成测试,相似度应在 0.7~0.99 之间
    original_text = read_file("samples/orig.txt")
    copied_text = read_file("samples/orig_0.8_add.txt")
    score = cosine_similarity(original_text, copied_text, n=2)
    self.assertGreater(score, 0.7)
    self.assertLess(score, 0.99)

其余测试用例:

def test_punct_only(self):
    # 测试6:纯标点,清洗后为空,应为 0.0
    self.assertEqual(cosine_similarity("!,。?", "!!!"), 0.0)

def test_short_text(self):
    # 测试8:极短文本,n 自动缩小后相同应为 1.0
    self.assertAlmostEqual(cosine_similarity("a", "a"), 1.0)

def test_file_not_exist(self):
    # 测试9:文件不存在应抛出 FileNotFoundError
    with self.assertRaises(FileNotFoundError):
        read_file("notexist.txt")

def test_normalize(self):
    # 测试14:文本预处理,去标点、去空格、英文转小写
    self.assertEqual(normalize("Hello, 世界! 123"), "hello世界123")

命令行入口测试,模拟参数+临时文件验证输出:

def test_main_normal_run(self):
    # 测试16:命令行正常运行,结果写入文件且格式为两位小数
    fd, orig = tempfile.mkstemp(suffix=".txt"); os.close(fd)
    fd, copy = tempfile.mkstemp(suffix=".txt"); os.close(fd)
    fd, out = tempfile.mkstemp(suffix=".txt"); os.close(fd)
    try:
        with open(orig, "w", encoding="utf-8") as f:
            f.write("今天是星期天,天气晴")
        with open(copy, "w", encoding="utf-8") as f:
            f.write("今天是星期天,天气晴")
        with mock.patch.object(sys, "argv", ["main.py", orig, copy, out]):
            main()
        with open(out, "r", encoding="utf-8") as f:
            self.assertEqual(f.read(), "1.00")
    finally:
        for p in (orig, copy, out):
            os.remove(p)

5.3 测试运行结果

Ran 17 tests in 0.021s

OK

5.4 代码覆盖率

使用 coverage 生成 HTML 报告:

python -m coverage run -m unittest test_main.py
python -m coverage report -m
python -m coverage html

结果:

Name           Stmts   Miss  Cover   Missing
--------------------------------------------
main.py           67      3    96%   32, 68, 96
test_main.py      92      1    99%   136
TOTAL            159      4    97%

main.py 覆盖率 96%,整体覆盖率 97%。未覆盖的 3 行为:极短文本的预置空分支、理论不可达的除零保护、main 入口守卫。

d3adafb9027712aae453453d23fb6f93

5.5 文本测试样例比对表

样例文件 参考预期值 程序实测值 说明
orig_0.8_add.txt 0.90 0.90 正常,真实《活着》正文文本
orig_0.8_del.txt 0.90 0.02 文件实际为HTML网页(百度网盘下载页),并非论文正文
orig_0.8_dis_1.txt 0.97 0.02 文件实际为HTML网页(百度网盘下载页),并非论文正文
orig_0.8_dis_10.txt 0.91 0.02 文件实际为HTML网页(百度网盘下载页),并非论文正文
orig_0.8_dis_15.txt 0.75 0.01 文件实际为HTML网页(百度网盘下载页),并非论文正文

原因说明:
只有 orig.txt 与 orig_0.8_add.txt 是原始正文文本,其余几个样例文件,是页面的HTML代码,计算得到的相似度极低,属于输入文件本身问题,不是代码缺陷。

我做了另一个版本:

将 4 个 HTML 文件中的正文提取出来

样例文件 提取单元格正文后查重
orig_0.8_add.txt 0.90
orig_0.8_del.txt 0.90
orig_0.8_dis_1.txt 0.97
orig_0.8_dis_10.txt 0.91
orig_0.8_dis_15.txt 0.75

6. 异常处理机制设计

6.1 命令行参数异常

设计目标:解决参数数量错误导致的崩溃。
场景:未传入三个路径参数。
方案:main() 校验 len(sys.argv) != 4,打印用法并优雅退出。
测试样例:test_main_missing_args。

6.2 文件不存在异常

设计目标:区分文件读取失败,同时保证评测不异常退出。
场景:传入无效路径。
方案:read_file() 主动抛出 FileNotFoundError(供单元测试校验);main() 捕获 OSError,打印提示并输出 0.00,保证程序正常结束。
测试样例:test_file_not_exist、test_main_file_not_exist。

6.3 空文本 / 极短文本处理

设计目标:规避模长为 0 的除零异常。
场景:文本为空、纯标点、长度小于 n。
方案:清洗后为空直接返回 0.00;文本短于 n 时自动把 n 缩小到可切出片段。
测试样例:test_empty_original、test_empty_copy、test_punct_only、test_short_text。

6.4 非法 n 值异常

设计目标:防止非法的分词粒度导致计算错误。
场景:ngram_counter 收到 n<=0。
方案:抛出 ValueError("n必须大于0")。
测试样例:test_invalid_n。

异常处理核心代码

def read_file(path: str) -> str:
    """读取文件,utf-8编码;文件不存在时抛出FileNotFoundError"""
    try:
        with open(path, "r", encoding="utf-8") as f:
            return f.read()
    except FileNotFoundError:
        raise FileNotFoundError(f"文件不存在:{path}")
def ngram_counter(text: str, n: int) -> Counter:
    if n <= 0:
        raise ValueError("n必须大于0")
    ...
# cosine_similarity 空文本提前返回,规避除零
clean_a = normalize(text_a)
clean_b = normalize(text_b)
if not clean_a or not clean_b:
    return 0.0
# main函数捕获文件读取异常
try:
    text1 = read_file(orig_path)
    text2 = read_file(copy_path)
    score = cosine_similarity(text1, text2, n=2)
except OSError as e:
    print(f"读取文件失败:{e}")
    score = 0.0

异常对应的测试代码示例

# 文件不存在,期望抛出 FileNotFoundError
def test_file_not_exist(self):
    with self.assertRaises(FileNotFoundError):
        read_file("notexist.txt")

# 抄袭文为空,期望返回 0.0,不抛除零异常
def test_empty_copy(self):
    self.assertEqual(cosine_similarity("任意文本", ""), 0.0)

# 非法 n,期望抛出 ValueError
def test_invalid_n(self):
    with self.assertRaises(ValueError):
        ngram_counter("abc", 0)

# 输入文件缺失时 main() 应输出 0.00 且不崩溃
def test_main_file_not_exist(self):
    fd, out = tempfile.mkstemp(suffix=".txt"); os.close(fd)
    try:
        with mock.patch.object(sys, "argv", ["main.py", "no_a.txt", "no_b.txt", out]):
            main()
        with open(out, "r", encoding="utf-8") as f:
            self.assertEqual(f.read(), "0.00")
    finally:
        os.remove(out)

7. PSP 实际耗时表

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 25 30
·Estimate ・估计任务整体耗时 25 30
Development 开发 350 410
·Analysis ・需求分析、算法学习、环境搭建 50 60
·Design Spec ・撰写设计思路、模块划分文档 30 35
·Design Review ・设计方案自查、逻辑校验 20 25
·Coding Standard ・制定代码命名、注释、格式规范 15 15
·Design ・算法设计、函数接口设计、流程设计 45 50
·Coding ・核心代码编写、功能实现 90 110
·Code Review ・代码自查、消除冗余、修正不规范写法 30 40
·Test ・自测、造测试用例、修复 bug 30 75
Reporting 报告总结 55 70
·Test Report ・整理单元测试结果、覆盖率数据 20 30
·Size Measurement ・统计代码量、工作量 10 10
·Postmortem ・项目复盘、改进方案撰写 25 30
合计 430 510

8. 项目总结

基于 Python 实现 2-gram + 余弦相似度论文查重,完成编码、17 个单元测试、覆盖率检测与性能分析。实际总耗时 510 分钟,高于预估 430 分钟,主要消耗在调试文本预处理、完善测试覆盖和性能优化上。优化中定位 ngram_counter 为性能热点,通过预编译正则、zip 配对替代切片,将压力场景耗时从 139.5 s 降到 78.8 s(约 1.77 倍),计算结果保持一致。最终 main.py 覆盖率 96%。后续开发时会提前规划测试用例,减少后期调试时间,缩小预估工时与实际工时的差距。

posted @ 2026-09-15 21:54  zhenglebing  阅读(8)  评论(0)    收藏  举报