软工第二周作业

软工第二周作业——论文查重

这个作业属于哪个课程 https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS
这个作业要求在哪里 https://www.cnblogs.com/dl-1-2/p/22946319
这个作业的目标 让我们熟悉软件开发的流程

GitHub 仓库地址:https://github.com/delang666/delang666


一、PSP2.1 表格

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

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

2.1 代码组织

程序按「单一职责」拆成三个模块:

文件 主要类 / 函数 职责
simhash.py SimHash 核心算法:分词、词频统计、64 位指纹生成、汉明距离、相似度
file_handler.py read_text / write_text 文件读写 + UTF-8/GBK 编码回退 + 异常定义
main.py parse_args / check_similarity / main 命令行参数解析、编排流程、写出答案

模块关系(单向依赖,无循环):

main.py ──调用──> file_handler.py(读写文件)
   └──────调用──> simhash.py(计算相似度)

2.2 关键流程

解析命令行参数(3个路径)
        │
        ▼
read_text(原文)  read_text(抄袭版)
        │
        ▼
SimHash(原文).similarity( SimHash(抄袭版) )
        │
        ▼
write_text(答案文件, "{:.2f}".format(相似度))

2.3 算法关键与独到之处

算法关键:SimHash 局部敏感哈希。

  1. 用 jieba 对文本分词,过滤纯标点/空白 token。
  2. 对每个词计算确定性字符串哈希(不用 Python 内置 hash(),因为它带随机种子、结果不可复现)。
  3. 按位加权累加:某位为 1 加「词频」、为 0 减「词频」。
  4. 累加向量 >=0 的位置记为 1,得到 64 位指纹。
  5. 用汉明距离换算相似度:1 - 汉明距离/64。

独到之处:

  1. 词频合并优化:先统计词频,相同词只做一次 64 位加权、权重为词频。把「逐词遍历」降为「逐唯一词遍历」,位运算次数从 64 × token 总数 降到 64 × 唯一词数。
  2. 确定性哈希:自定义 _string_hash,跨进程、跨平台结果一致,保证同一文件多次运行答案稳定。
  3. 编码自适应:优先 UTF-8,失败自动回退 GBK,兼容 Windows 中文环境(样例 orig.txt 即 UTF-8,运行时无需关心)。
  4. 对增删改天然鲁棒:SimHash 基于词集合的加权统计,局部增删改只影响少量位,汉明距离变化小,相似度结果稳定。

样例验证(test_data/ 提供):

样例对 原文 抄袭方式 输出重复率
orig.txt vs orig_add.txt 软件工程… 末尾追加 AI/云原生段落 0.97
orig.txt vs orig_del.txt 软件工程… 删除后半段 0.89
orig.txt vs orig_dis_1.txt 软件工程… 改 1 处(学科→科学) 0.97
orig.txt vs orig_dis_10.txt 软件工程… 改 10 处 0.94
orig.txt vs orig_dis_15.txt 软件工程… 改 15 处 0.88

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

改进耗时:约 40 分钟。

改进思路:

  • 初版 _compute 对每个 token(含重复词)都执行一次 64 位逐位累加,长文 + 大量重复词时位运算次数 = 64 × token 总数。
  • 改为先 dict 统计词频,再对每个「唯一词」加权累加,位运算次数降为 64 × 唯一词数。
  • 分词结果用 jieba.cut(惰性生成器)边切边过滤,避免构造完整中间列表。

性能分析图(使用 cProfile 剖析 check_similarity,按自身耗时 tottime 排序):

性能分析图

上图为 python -m cProfile 在样例数据(orig.txt vs orig_add.txt)上跑出的耗时柱状图,可由项目内置脚本生成:

python profiling.py test_data/orig.txt test_data/orig_add.txt profile_output.png

消耗最大的函数:marshal.load(jieba 词典的一次性反序列化加载,~0.73s,占总耗时约 98%);其次是 jieba.get_DAG 与 jieba.calc(实际分词逻辑),而我编写的 SimHash._compute 自身耗时仅 0.003s,几乎可忽略。


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

测试框架:Python 内置 unittest + coverage(分支覆盖率插件)。

测试用例设计思路(白盒为主,辅以边界值、等价类划分):

类别 测试点 用例数
正常路径 相同文本、高相似、改 1/10/15 处、增、删、主题完全不同 7
边界值 单字符、空文本、纯标点、空白 token 4
算法性质 自反性、汉明距离对称性 2
文件 IO 异常 UTF-8/GBK 编码、空文件、文件不存在、读写回环、读写 OSError 8
主流程分支 参数错误退出码 1、文件缺失退出码 2、空文件写 0.00、成功写相似度、未知异常退出码 4、端到端 6
合计 25

部分测试代码(覆盖算法核心 + 异常分支):

def test_similar_text_high_similarity(self):
    """仅少量改写的文本相似度应较高(> 0.6)。"""
    a = SimHash(BASE_TEXT)
    b = SimHash(SIMILAR_TEXT)
    self.assertGreater(a.similarity(b), 0.6)

def test_hamming_distance_symmetric(self):
    """汉明距离具有对称性。"""
    a = SimHash(BASE_TEXT)
    b = SimHash(SIMILAR_TEXT)
    self.assertEqual(a.hamming_distance(b), b.hamming_distance(a))

def test_read_gbk_fallback_oserror(self):
    """UTF-8 解码失败且 GBK 回退也失败时应抛 FileReadError。"""
    path = _write_temp()
    def fake_open(*_args, **kwargs):
        if kwargs.get("encoding") == "utf-8":
            raise UnicodeDecodeError("utf-8", b"", 0, 1, "invalid")
        raise OSError("permission denied")
    try:
        with mock.patch("builtins.open", side_effect=fake_open):
            with self.assertRaises(FileReadError):
                read_text(path)
    finally:
        os.remove(path)

运行与覆盖率:

# 运行 25 个单元测试
python -m unittest test_main -v

# 生成分支覆盖率报告(终端 + HTML)
coverage run --branch -m unittest test_main
coverage report -m
coverage html      # 产物在 htmlcov/index.html

实际跑出的覆盖率(项目源码,不含测试文件):

Name              Stmts   Miss Branch BrPart  Cover
---------------------------------------------------
file_handler.py      23      0      2      0   100%
main.py              37      2      8      2    91%
simhash.py           46      1     20      1    97%
---------------------------------------------------
TOTAL               106      3     30      3    96%

未覆盖行均为标准防御代码:默认参数分支(main.py:71 argv is None)、if __name__ == "__main__" 入口(main.py:97)、空字符串哈希兜底(simhash.py:54)。

覆盖率报告


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

异常 设计目标 触发场景 对应单元测试
ArgumentError(main.py 自定义) 参数个数非法时尽早失败并给出用法提示,避免索引越界崩溃 命令行参数不足/多余 3 个 test_wrong_arg_count_exits_1
FileNotFoundError(内置) 输入文件不存在时给出明确错误码退出,不写垃圾答案 路径错误/文件被删 test_missing_file_exits_2 / test_read_not_exists
FileReadError(file_handler 自定义) 区分「读不到/解码失败/写不进」这类文件级错误 编码失败、OSError、目录不存在 test_read_oserror_raises_file_read_error / test_read_gbk_fallback_oserror / test_write_error_raises_file_read_error
EmptyFileError(file_handler 自定义) 空文件无法比对,main 捕获后降级输出 0.00,保证评测不崩溃 输入文件内容为空 test_empty_file_writes_zero / test_empty_file_error
兜底 Exception(main.py) 任何未预期的运行时异常都以退出码 4 优雅退出 第三方库异常、磁盘满等 test_unexpected_error_exits_4

以 EmptyFileError 为例的单元测试(空文件场景 → 答案文件写 0.00 不崩溃):

def test_main_empty_file_writes_zero(self):
    """空文件场景:main 捕获 EmptyFileError,降级输出 0.00,进程不崩溃。"""
    empty = _write_temp()  # 空文件
    ans = os.path.join(tempfile.gettempdir(), "ans_direct_empty.txt")
    try:
        with contextlib.redirect_stdout(io.StringIO()), \
                contextlib.redirect_stderr(io.StringIO()):
            main([empty, empty, ans])
        self.assertEqual(read_text(ans).strip(), "0.00")
    finally:
        os.remove(empty)
        if os.path.exists(ans):
            os.remove(ans)

下表把每种异常对应的测试代码片段与实测运行结果(... ok 表示通过)一并列出,全部 8 个异常相关测试实测一次跑通:

5.1 ArgumentError —— 参数个数非法

def test_wrong_arg_count_exits_1(self):
    with self.assertRaises(SystemExit) as cm:
        with contextlib.redirect_stdout(io.StringIO()), \
                contextlib.redirect_stderr(io.StringIO()):
            main(["a.txt"])  # 只给 1 个参数
    self.assertEqual(cm.exception.code, 1)

实测:test_wrong_arg_count_exits_1 (test_main.TestMainFunction) ... ok

5.2 FileNotFoundError —— 输入文件不存在

def test_missing_file_exits_2(self):
    with self.assertRaises(SystemExit) as cm:
        with contextlib.redirect_stdout(io.StringIO()), \
                contextlib.redirect_stderr(io.StringIO()):
            main(["no_such.txt", "no_such2.txt", "out.txt"])
    self.assertEqual(cm.exception.code, 2)

实测:test_missing_file_exits_2 (test_main.TestMainFunction) ... ok

5.3 FileReadError —— 文件读不到 / 编码失败 / 写不进

file_handler.py 自定义异常,封装三类文件级错误。下例展示 OSError 被翻译为 FileReadError:

def test_read_oserror_raises_file_read_error(self):
    """文件存在但打开失败(OSError)时应抛 FileReadError。"""
    path = _write_temp()
    try:
        with mock.patch(
            "builtins.open", side_effect=OSError("permission denied")
        ):
            with self.assertRaises(FileReadError):
                read_text(path)
    finally:
        os.remove(path)

实测 3 个相关用例:

test_read_oserror_raises_file_read_error ... ok
test_read_gbk_fallback_oserror ... ok
test_write_error_raises_file_read_error ... ok

5.4 EmptyFileError —— 空文件 → 降级输出 0.00

file_handler.py 自定义异常;main() 捕获后不崩溃,写 0.00 到答案文件。

实测:

test_empty_file_writes_zero (test_main.TestMainFunction) ... ok
test_empty_file_error (test_main.TestFileHandler) ... ok

5.5 兜底 Exception —— 未知异常退出码 4

def test_unexpected_error_exits_4(self):
    with mock.patch("main.read_text", side_effect=RuntimeError("boom")):
        with self.assertRaises(SystemExit) as cm:
            with contextlib.redirect_stdout(io.StringIO()), \
                    contextlib.redirect_stderr(io.StringIO()):
                main(["a.txt", "b.txt", "c.txt"])
        self.assertEqual(cm.exception.code, 4)

实测:test_unexpected_error_exits_4 (test_main.TestMainFunction) ... ok

5.6 8 个异常测试一次跑完的输出

$ python -m unittest test_main.TestMainFunction.test_wrong_arg_count_exits_1 \
    test_main.TestMainFunction.test_missing_file_exits_2 \
    test_main.TestMainFunction.test_empty_file_writes_zero \
    test_main.TestMainFunction.test_unexpected_error_exits_4 \
    test_main.TestFileHandler.test_read_oserror_raises_file_read_error \
    test_main.TestFileHandler.test_empty_file_error \
    test_main.TestFileHandler.test_read_gbk_fallback_oserror \
    test_main.TestFileHandler.test_write_error_raises_file_read_error -v
test_wrong_arg_count_exits_1 (test_main.TestMainFunction) ... ok
test_missing_file_exits_2 (test_main.TestMainFunction) ... ok
test_empty_file_writes_zero (test_main.TestMainFunction) ... ok
test_unexpected_error_exits_4 (test_main.TestMainFunction) ... ok
test_read_oserror_raises_file_read_error (test_main.TestFileHandler)
文件存在但打开失败(OSError)时应抛 FileReadError。 ... ok
test_empty_file_error (test_main.TestFileHandler) ... ok
test_read_gbk_fallback_oserror (test_main.TestFileHandler)
UTF-8 解码失败且 GBK 回退也失败时应抛 FileReadError。 ... ok
test_write_error_raises_file_read_error (test_main.TestFileHandler)
写入不存在的目录时应抛 FileReadError。 ... ok
----------------------------------------------------------------------
Ran 8 tests in 0.012s

OK

image
image

posted @ 2026-09-12 12:28  dl12  阅读(37)  评论(0)    收藏  举报