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

GitHub 仓库https://github.com/RicharTan-zlz/software-engineering-homework-Sec/tree/main/3124004068

学号:3124004068


一、PSP 表格

1.1 预估耗时

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

1.2 实际耗时

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

分析:实际耗时 450 分钟 > 预估 385 分钟,主要超支在编码(+30)与测试(+10)。原因是首次使用 jieba 分词库,需要熟悉 API 和调试。后续应预留更多 buffer。


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

2.1 需求分析

输入:3 个命令行参数——原文文件路径、抄袭版论文文件路径、答案文件路径。

输出:答案文件内写入一个浮点数,精确到小数点后两位,表示重复率。

关键约束

  • 5 秒内必须给出答案
  • 内存不超过 2048MB
  • 不能联网、不能读写其他文件
  • 支持 UTF-8 / GBK 编码的中文文本

2.2 算法选择

方案 优点 缺点 结论
编辑距离 直观 O(n²) 太慢
余弦相似度 准确,速度快 对语序不敏感
SimHash 极快,适合海量文本 精度略低
最长公共子串 适合短文本 长文本性能差
SimHash + 余弦融合 精度+速度兼顾 实现略复杂 ✓ 采用

最终方案相似度 = 0.5 × SimHash相似度 + 0.5 × 余弦相似度

2.3 模块设计

程序分为 5 层:

┌─────────────────────────────────┐
│  main()  命令行入口              │
├─────────────────────────────────┤
│  read_file / write_result  IO层  │
├─────────────────────────────────┤
│  preprocess  预处理层            │
├─────────────────────────────────┤
│  simhash / cosine  算法层        │
├─────────────────────────────────┤
│  FileError  异常层               │
└─────────────────────────────────┘

函数清单

函数 功能
read_file(path) 读取文件,UTF-8/GBK 兼容
write_result(path, rate) 写出结果,保留两位小数
preprocess(text) 清洗+分词
_hash64(token) 单 token 的 64 位哈希
compute_simhash(tokens) 计算 SimHash 指纹
hamming_distance(h1, h2) 汉明距离
cosine_similarity(t1, t2) 余弦相似度
calculate_similarity(orig, copy) 综合相似度
main() 命令行入口

2.4 关键流程图

calculate_similarity 流程

        [开始]
           │
           ▼
    读取两个文本
           │
           ▼
   ┌───────────────┐
   │ preprocess    │  清洗+分词
   └───────┬───────┘
           │
     ┌─────┴─────┐
     ▼           ▼
  SimHash      词频向量
     │           │
     ▼           ▼
  汉明距离     余弦相似度
     │           │
     └─────┬─────┘
           ▼
     加权融合(0.5/0.5)
           │
           ▼
        输出rate
           │
           ▼
        [结束]

2.5 算法的独到之处

  1. 混合策略:SimHash 对整体近似判断快,余弦对细节精确,两者互补,减少漏判误判。
  2. 中文友好:使用 jieba 分词 + HMM=False 关闭隐马尔可夫模型,避免把"今天天气"当作一个词,切分更细。
  3. 零网络依赖:jieba 词典本地加载,不联网,符合安全约束。
  4. 异常鲁棒:文件编码 UTF-8/GBK 双兼容,文件不存在、参数错误均有明确提示。

2.6 核心代码

def calculate_similarity(orig_text: str, copy_text: str) -> float:
    """
    综合相似度:
    0.5 × SimHash 相似度 + 0.5 × 余弦相似度
    """
    tokens1 = preprocess(orig_text)
    tokens2 = preprocess(copy_text)

    # 边界处理
    if not tokens1 and not tokens2:
        return 1.0
    if not tokens1 or not tokens2:
        return 0.0

    # SimHash 相似度
    h1 = compute_simhash(tokens1)
    h2 = compute_simhash(tokens2)
    dist = hamming_distance(h1, h2)
    simhash_sim = 1.0 - dist / 64.0

    # 余弦相似度
    cos_sim = cosine_similarity(tokens1, tokens2)

    # 加权融合
    final = 0.5 * simhash_sim + 0.5 * cos_sim
    return max(0.0, min(1.0, final))

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

3.1 性能分析工具

使用 cProfile + snakeviz 生成性能分析图。

命令

python -m cProfile -o profile_big.out main.py big_orig.txt big_copy.txt ans.txt
python -m snakeviz profile_big.out

3.2 性能分析图

测试条件:315 KB 中文文本,用时 1.023 秒

performance

分析结果

总耗时 1.02s
│
├─ main.py:161(main)                    0.586s (57%)
│  │
│  └─ main.py:132(calculate_similarity) 0.569s
│     │
│     └─ main.py:53(preprocess)         0.561s (55%)  ← 瓶颈
│        │
│        └─ jieba.cut                   0.490s (48%)
│           └─ __cut_DAG_NO_HMM         0.466s
│
├─ jieba.initialize()                   0.353s (34%)  一次性
│  └─ marshal.load                      0.349s
│
└─ 其他(导入等)                        0.084s

3.3 消耗最大的函数

函数 耗时占比 说明
jieba.cut 48% 分词是最大瓶颈
jieba.initialize 34% 词典加载,一次性
preprocess 55%(含子调用) 预处理整体
calculate_similarity 0.569s 算法核心
算法本身(SimHash+余弦) < 0.01s 可忽略

3.4 改进思路

优化点 优化前 优化后 效果
jieba 预加载 每次调用初始化 模块级 jieba.initialize() 避免重复加载
关闭 HMM cut(text) cut(text, HMM=False) 提速约 15%
词频缓存 遍历两次 Counter 一次 -5%

优化效果

文本大小 优化前 优化后
315 KB 1.3s 1.02s
10 MB 3.5s 2.1s

结论:满足 5s 限制,且远低于内存 2048MB 上限。


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

4.1 测试策略

  • 白盒:覆盖所有分支(空文本、相同文本、完全不同、无公共词)
  • 黑盒:等价类划分(正常、边界、异常)
  • 共设计 27 个测试用例

4.2 测试用例清单

编号 测试函数 构造思路 预期
1 test_preprocess_empty 空字符串 返回 []
2 test_preprocess_punctuation 含标点 标点被去除
3 test_preprocess_case 大小写 统一小写
4 test_simhash_identical 同文本 哈希相同
5 test_hamming_same 同一哈希 距离 0
6 test_hamming_symmetry 两个哈希 对称
7 test_cosine_identical 同词集 ≈1.0
8 test_cosine_no_common 无公共词 0.0
9 test_cosine_empty 空输入 0.0
10 test_similarity_identical 完全相同 >0.99
11 test_similarity_sample 样例 0.5~1.0
12 test_similarity_completely_different 完全不同 <0.5
13 test_read_missing_file 不存在文件 FileError
14 test_write_and_read 写读一致 "0.88"
15 test_read_empty_string_path 空路径 FileError
16 test_write_invalid_path 无效路径 FileError
17 test_read_gbk_file GBK 文件 自动回退
18 test_hamming_zero 0 距离 0
19 test_cosine_one_empty 一边为空 0.0
20 test_similarity_both_empty 两边为空 1.0
21 test_similarity_one_empty 一边为空 0.0
22 test_main_wrong_args 参数错误 退出码 1
23 test_main_file_not_exist 文件不存在 退出码 2
24 test_main_normal_run 正常调用 退出码 0
25 test_main_with_valid_files 直接调 main 写出文件
26 test_preprocess_with_whitespace 多空格 正确分词
27 test_read_directory_as_file 传目录 FileError

4.3 测试代码片段

class TestSimilarity(unittest.TestCase):
    """综合相似度测试"""

    def test_similarity_identical(self):
        """测试10: 完全相同文本相似度接近 1"""
        text = "今天是星期天,天气晴,今天晚上我要去看电影。"
        rate = calculate_similarity(text, text)
        self.assertGreater(rate, 0.99)

    def test_similarity_sample(self):
        """测试11: 样例抄袭文本相似度在 0.5~1.0 之间"""
        orig = "今天是星期天,天气晴,今天晚上我要去看电影。"
        copy = "今天是周天,天气晴朗,我晚上要去看电影。"
        rate = calculate_similarity(orig, copy)
        self.assertGreater(rate, 0.5)
        self.assertLess(rate, 1.0)


class TestFileIO(unittest.TestCase):
    """文件读写测试"""

    def test_read_missing_file(self):
        """测试13: 读取不存在的文件抛出 FileError"""
        with self.assertRaises(FileError):
            read_file("nonexistent_file_xyz_12345.txt")

    def test_read_gbk_file(self):
        """测试17: GBK 编码文件自动回退读取"""
        with tempfile.NamedTemporaryFile(
                mode='wb', suffix='.txt', delete=False) as f:
            f.write("中文测试内容".encode('gbk'))
            path = f.name
        try:
            content = read_file(path)
            self.assertIn("中文", content)
        finally:
            os.unlink(path)

4.4 测试结果

unittest

Ran 27 tests in 1.616s

OK

4.5 覆盖率报告

coverage

Name           Stmts   Miss  Cover   Missing
--------------------------------------------
main.py          110     13    88%   41-42, 88, 129, 164-165, 174-179, 183
test_main.py     138      1    99%   250
--------------------------------------------
TOTAL            248     14    94%

结论

  • main.py 覆盖率 88%,超过 85% 的目标
  • 总体覆盖率 94%
  • 未覆盖部分主要是极端分支(IO 错误、磁盘满、兜底异常等),不影响核心功能

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

5.1 异常设计总览

异常类型 设计目标 触发场景 处理方式
FileError 统一文件异常 文件不存在/不是文件/不可读 打印错误,exit(2)
UnicodeDecodeError 编码兼容 UTF-8 解码失败 回退 GBK 读取
IOError IO 错误 磁盘满、权限不足 包装为 FileError
参数错误 提示用法 argv ≠ 4 打印用法,exit(1)
未预期异常 兜底 任意其他异常 exit(3)

5.2 每种异常的单元测试

异常1:文件不存在

def test_read_missing_file(self):
    """对应场景:用户在命令行传入了错误的路径"""
    with self.assertRaises(FileError):
        read_file("nonexistent_file_xyz.txt")

异常2:编码回退(GBK)

def test_read_gbk_file(self):
    """对应场景:输入文件是 GBK 编码(Windows 记事本默认)"""
    with tempfile.NamedTemporaryFile(
            mode='wb', suffix='.txt', delete=False) as f:
        f.write("中文测试".encode('gbk'))
        path = f.name
    try:
        content = read_file(path)
        self.assertIn("中文", content)
    finally:
        os.unlink(path)

异常3:写文件失败

def test_write_invalid_path(self):
    """对应场景:输出路径指向不存在的目录"""
    with self.assertRaises(FileError):
        write_result("Z:/nonexistent_dir/ans.txt", 0.5)

异常4:空文本

def test_similarity_both_empty(self):
    """对应场景:输入文件为空"""
    self.assertEqual(calculate_similarity("", ""), 1.0)

def test_similarity_one_empty(self):
    self.assertEqual(calculate_similarity("", "abc"), 0.0)
    self.assertEqual(calculate_similarity("abc", ""), 0.0)

异常5:参数错误

def test_main_wrong_args(self):
    """对应场景:命令行只传了 0 个参数"""
    r = subprocess.run(
        [sys.executable, "main.py"],
        capture_output=True, text=True
    )
    self.assertEqual(r.returncode, 1)

5.3 异常测试覆盖

27 个单元测试中,有 8 个专门针对异常,覆盖文件、编码、IO、参数、空输入等关键场景,确保程序在任何情况下都不会崩溃或静默失败。


六、Git 提交记录

6 次有意义的 commit,符合"每完成一个功能就提交"的要求:

# commit 内容
1 8a1f738 feat: 完成基本文件读取与输出功能
2 c9b1366 feat: 加入 jieba 分词、SimHash 与余弦相似度综合算法
3 11b26d2 test: 添加单元测试,main.py 覆盖率 88%,总覆盖率 94%
4 1f30acb style: 修复 pylint 警告,代码评分达到 10/10
5 d7a3491 chore: 忽略性能分析文件 profile.out
6 d9b5a21 docs: 添加 README 项目说明文档

七、运行方式

# 安装依赖
pip install -r requirements.txt

# 运行
python main.py orig.txt orig_add.txt ans.txt

# 输出示例
重复率: 0.76

八、总结

本次作业从需求分析 → 算法设计 → 编码 → 测试 → 性能优化 → 文档,完整走了一遍个人软件开发流程。最大收获:

  1. 算法选择要看场景:SimHash + 余弦的融合,比单一算法既快又准。
  2. 性能瓶颈要靠工具定位:cProfile 明确指向 jieba,才能针对性优化。
  3. 异常处理是工程化关键:编码兼容、文件校验、参数检查一个不能少。
  4. Git 记录反映开发节奏:每次完成一个功能就 commit,方便回溯。

PSP 实际 vs 预估:总耗时 450 分钟 > 预估 385 分钟,主要超支在编码(+30)与测试(+10),说明初期对细节估计不足,后续应预留更多 buffer。


posted @ 2026-09-14 20:47  RICHAR212  阅读(10)  评论(0)    收藏  举报