第一次个人编程作业

第一次个人编程作业

这个作业属于哪个课程 <计科23级12班>
这个作业的要求在哪里 <个人项目>
这个作业的目标 <个人完成论文查重算法的设计与实现,结合 GitHub 进行项目管理与提交,使用 PSP 表跟踪用时,并对程序开展性能分析优化与单元测试>

1.GitHub 链接:

https://github.com/Echooooe/Echooooe/tree/main/3223004210

2.PSP 表格

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划
· Estimate · 估计这个任务需要多少时间 30 35
Development 开发
· Analysis · 需求分析 (包括学习新技术) 120 150
· Design Spec · 生成设计文档 20 30
· Design Review · 设计复审 30 25
· Coding Standard · 代码规范 (为目前的开发制定合适的规范) 30 40
· Design · 具体设计 90 100
· Coding · 具体编码 180 210
· Code Review · 代码复审 30 40
· Test · 测试(自我测试,修改代码,提交修改) 120 160
Reporting 报告
· Test Repor · 测试报告 30 40
· Size Measurement · 计算工作量 20 25
· Postmortem & Process Improvement Plan · 事后总结, 并提出过程改进计划 30 35
· 合计 670 855

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

目录结构:

3223004210/
├─ main.py                     # 命令行入口:读取原文/疑似文本,调用相似度函数,写入结果
├─ requirements.txt            # 运行依赖
├─ requirements-dev.txt        # 开发/测试/质量工具依赖(引用 requirements.txt)
├─ pytest.ini                  # pytest 配置
├─ .coveragerc                 # coverage 配置
├─ .pylintrc                   # Pylint 配置
├─ pyproject.toml              # Ruff/Black/isort 统一配置
├─ .gitignore                  # 项目级忽略(报告产物、缓存、.vs 等)
├─ data/
│  ├─ orig.txt                 # 示例:原文
│  ├─ org_0.8_add.txt          # 示例:疑似文本
│  ├─ org_0.8_del.txt          # 示例:疑似文本
│  ├─ org_0.8_dis_1.txt        # 示例:疑似文本
│  ├─ org_0.8_dis_10.txt       # 示例:疑似文本
│  ├─ org_0.8_dis_15.txt       # 示例:疑似文本
│  └─ ans.txt                  # 示例:输出结果
├─ src/
│  ├─ __init__.py
│  ├─ io_utils.py              # 读/写文本、路径/编码处理
│  ├─ text_norm.py             # 文本清洗/规范化(大小写、空白、标点等)
│  └─ sim.py                   # 相似度核心算法(n-gram 切片、集合/签名运算等)
├─ tests/                      #单元测试
│  ├─ test_io_utils.py
│  ├─ test_text_norm.py
│  ├─ test_sim.py
│  └─ test_main.py 
├─ reports/
│  ├─ tests/
│  │  └─ coverage_branch_*.png     #分支覆盖率截图
│  └─ perf/                        # 性能分析
│     ├─ before/                   # 优化前
│     │  ├─ baseline_top.txt       # cProfile Top(累计耗时)文本
│     │  └─ VS-baseline.png        # VS 性能分析器截图(CPU/热路径)
│     └─ after/                    # 优化后
│        ├─ optimized_top.txt
│        └─ VS-optimized.png         
└─ bench/
   └─ sample_profile.py        # 性能剖析脚本

3.1 模块组织与关系

main.py               # CLI/总控:解析参数 [-n N] 与 3 个路径参数;调用 src;
src/
├─ io_utils.py        # I/O:读取/写入 UTF-8 文本
├─ text_norm.py       # 规范化与特征:normalize / char_ngrams / counts
└─ sim.py             # 相似度核心:_dot / _norm / _jaccard_chars / similarity_ratio
  • main.py(总控/CLI)
    解析 python main.py <org> <org_add> <ans> [-n N];调用 io_utils.read_text_file 读入、sim.similarity_ratio 计算、io_utils.write_text_file

  • io_utils.py
    read_text_file(path) -> strwrite_text_file(path, content) -> None

  • text_norm.py
    normalize(s) -> str:大小写统一、空白规整、按需清理标点;
    char_ngrams(s, n) -> list[str]:生成字符 n-gram(默认 2);
    counts(ngrams) -> dict[str,int]:n-gram 计数字典

  • sim.py
    _dot(dict, dict) -> int:频次向量点积;

    _norm(dict) -> float:L2 范数;
    _jaccard_chars(a: str, b: str) -> float:字符集合 Jaccard;
    similarity_ratio(orig, copy, n=2) -> float主接口,规范化 → n-gram;若任一侧无 n-gram 则退化为 Jaccard,否则以计数字典计算余弦相似度并返回 [0,1]

    模块关系图:

3.2 输入输出方式

  • 命令行输入
    python main.py <orig_path> <copy_path> <ans_path> [-n N]

    • -n/--ngram:扩展功能,n-gram 窗口(默认 2);非法取值(如 <1)视为输入错误,stderr 提示并退出码 2;这里的[-n N]可选,若不输入参数,则默认使用2-gram。
  • 输出<ans_path> 写入一行相似度,四舍五入两位小数

  • 退出码0 正常;2 已知异常

运行环境见 GitHub仓库 README 文件

3.3 关键算法与独到之处

3.3.1 算法:基于字符 n-gram 的向量空间模型(VSM)+ 余弦相似度,对极短文本退化为字符集合 Jaccard

3.3.2 核心思想:

  1. 把文本先做规范化(大小写/空白/标点统一):A = normalize(orig), B = normalize(copy)

  2. 把连续的 n 个字符当作一个“片段”(n-gram):toksA = char_ngrams(A,n), toksB = ...,如 n=2 时 "今天是星期天"今天、天是、是星、星期、期天

  3. 统计每个片段出现的频次,得到两段文本各自的稀疏向量cA = counts(toksA), cB = counts(toksB)

  4. 余弦相似度比较两个向量的“夹角”:

    \(cos(\theta)=\frac{\sum_i cA_i\cdot cB_i}{\sqrt{\sum_i cA_i^2}\cdot \sqrt{\sum_i cB_i^2}}\)

    越接近,说明片段分布越相似;

    若任一文本没有 n-gram(太短),则退化用Jaccard比较字符集合的交并比例:

    \(\text{Jaccard} = \frac{|\,\text{set}(A)\cap \text{set}(B)\,|}{|\,\text{set}(A)\cup \text{set}(B)\,|}\)

3.3.3 设计亮点

  1. 规范化:去 BOM、压缩空白,避免格式差异影响结果;
  2. 短文本退化:当文本过短无 n-gram 时,自动切换为字符集合 Jaccard,保证鲁棒性;
  3. 稀疏向量点积优化:只遍历较小的字典,减少哈希查找次数,提高常数效率;
  4. 边界定义清晰
    • 两者都空 → 1.0
    • 一边空 → 0.0
    • 分母为 0 → 防御性返回 0.0
  5. 模块解耦:I/O 与计算分离,计算过程可单独测试与扩展;
  6. 可调 n:允许用 -n/--ngram 调参以控制敏感度(n 越小越敏感,n 越大越严格)。

核心算法流程图:

flowchart TD S["similarity_ratio(orig, copy, n) // 默认 n=2"] --> N["A = normalize(orig)<br/>B = normalize(copy)"] N --> E1{"A 与 B 都为空?"} E1 -- 是 --> R1["return 1.0"] E1 -- 否 --> E2{"是否一边为空?"} E2 -- 是 --> R0["return 0.0"] E2 -- 否 --> G["toksA = char_ngrams(A, n)<br/>toksB = char_ngrams(B, n)"] G --> C{"toksA 或 toksB 为空?"} C -- 是 --> J["return _jaccard_chars(A, B) // 短文本退化路径"] C -- 否 --> V["cA = counts(toksA)<br/>cB = counts(toksB)"] V --> COS["num = _dot(cA, cB)<br/>den = _norm(cA) * _norm(cB)"] COS --> D{"den == 0 ?"} D -- 是 --> RZ["return 0.0 // 防御性处理"] D -- 否 --> R["return num / den // 余弦相似度 ∈ [0,1]"]

3.4 复杂度

设输入长度为 L

  • 规范化 O(L);n-gram 生成 O(L);词频统计 O(L)
  • 余弦/交并集计算 O(|A|+|B|);总体 时间复杂度 ~ O(L)空间 ~ O(L)

4. 计算模块接口部分的性能改进

4.1 优化前问题:

  • VS 性能分析器:
  • cProfile(前15个热点函数)
    reports/perf/baseline_top.txt
  • 总调用:1,835,052

  • 总耗时:1.850 s

  • 主要消耗函数:counts 1.204 schar_ngrams 0.488 snormalize 0.080 s

  • 热点集中在纯 Python 循环与字典累加
    text_norm.countschar_ngrams 占用最多 CPU;dict.get 被调用 1,803,943 次

4.2 改进思路与做法

  1. 计数用 C 实现
    counts改为 collections.Counter(底层 _collections._count_elements 为 C 执行),替代纯 Python dict += 1 循环。
  2. 重复输入缓存
    normalize(text)char_ngrams(text, n) 增加 @lru_cache(maxsize=4096):多次对相同文本重复计算时直接复用结果。
  3. 点积微优化
    _dot 内将 b.get 绑定为局部 b_get = b.get,减少属性查找开销。

4.3 优化后效果

  • VS 性能分析器
  • cProfile(前15个热点函数)
    reports/perf/optimized_top.txt(节选):
  • 总调用:35,126

  • 总耗时:0.246 s

  • 主要消耗函数:_collections._count_elements 0.193 scounts 0.199 s

  • 速度提升1.850 s → 0.246 s(≈ 7.5×)

  • 函数调用数1,835,052 → 35,126(≈ 52× )

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

5.1 测试用例设计(24 条)

ID 目标函数 覆盖点 / 测试目的
TEXTNORM_R001_001 normalize 去 BOM、压缩空白
TEXTNORM_R001_002 normalize 保留必要标点,不误删
TEXTNORM_R001_003 char_ngrams 正常 2-gram
TEXTNORM_R001_004 char_ngrams 短文本 < n 返回空
TEXTNORM_R001_005 char_ngrams 非法参数 n<=0 抛错
TEXTNORM_R001_006 counts 计数字典正确
SIM_R002_001 similarity_ratio 两空 → 1.0
SIM_R002_002 similarity_ratio 一空 → 0.0
SIM_R002_003 similarity_ratio 退化:无 n-gram 用 Jaccard
SIM_R002_004 similarity_ratio 自反性:同文本 = 1.0
SIM_R002_005 similarity_ratio 无重叠 n-gram → 0.0
SIM_R002_006 similarity_ratio 近义改写比分高于无关句
SIM_R002_007 _jaccard_chars(白盒) 空∩空 → 1.0(边界)
SIM_R002_008 _jaccard_chars(白盒) 一空一非空 → 0.0
IO_R003_001 read_text_file/write_text_file 写入后可回读;必要时创建父目录
IO_R003_002 read_text_file 不存在文件 → FileNotFoundError
IO_R003_003 read_text_file 非法 UTF-8 字节 → UnicodeDecodeError
MAIN_R004_001 CLI 三参数端到端:一行两位小数+换行;退出码 0
MAIN_R004_002 CLI 参量不足:打印 USAGE + 退出码 1
MAIN_R004_003 CLI 输入文件缺失:stderr + 退出码 2
MAIN_R004_004 CLI 扩展:-n 3 合法 → 正常
MAIN_R004_005 CLI 扩展:-n 非法(n<=0/非整数)→ USAGE + 退出码 *1*
MAIN_R004_006 CLI 输出路径是目录:IsADirectoryError → 退出码 2
MAIN_R004_007 CLI -n 非整数(如 "x")→ USAGE + 退出码 1

5.2 部分单元测试展示

  1. _jaccard_chars 边界极值
def test_SIM_R002_007_jaccard_both_empty_internal():
    assert _jaccard_chars("", "") == 1.0

def test_SIM_R002_008_jaccard_one_empty_internal():
    assert _jaccard_chars("A", "") == 0.0
    assert _jaccard_chars("", "A") == 0.0
  • 覆盖点_jaccard_chars两个极端分支(空∩空=1;一空=0)。
  • 数据思路:用最小字符串触发极端输入,验证定义语义并避免进入后续 n-gram 路径
  1. similarity_ratio 退化路径(无 n-gram ->Jaccard)
def test_SIM_R002_003_fallback_to_jaccard_when_no_ngram():
    s = similarity_ratio("ab", "ac", n=4)  # len("ab") < 4
    assert 0.0 <= s <= 1.0
  • 覆盖点:当任一侧没有 n-gram时,走 字符集合 Jaccard的退化分支。
  • 数据思路:把 n 设得比文本还大("ab", "ac", n=4),确保 n-gram 为空,从而只比较字符集合。
  1. similarity_ratio 语义合理性对比(改写 > 无关)
def test_SIM_R002_006_paraphrase_higher_than_unrelated():
    a = "机器学习是人工智能的重要分支。"
    b = "人工智能的重要分支之一是机器学习。"
    c = "今天天气晴朗,适合跑步。"
    assert similarity_ratio(a, b) > similarity_ratio(a, c)
  • 覆盖点:主路径下的相对排序合理性检查
  • 数据思路a/b 为改写句,a/c 为主题无关;期望改写比分高于无关。

5.3 测试结果与覆盖率截图

测试全部通过:

分支覆盖率100%:

6. 计算模块部分异常处理说明

异常分类、处理策略与单测样例:
A. 参数错误
  • 设计目标:命令行参数不合法时快速失败,不进入计算和文件读写

  • 覆盖场景:参数不足、-n 非法值、-n 非整数。

  • 代表用例:

    # 参数不足 → Usage + returncode 1
    def test_MAIN_R004_002_invalid_args_exit_code():
        proc = subprocess.run([sys.executable, "main.py"], capture_output=True, text=True)
        assert proc.returncode == 1
        assert "Usage:" in (proc.stdout + proc.stderr)
    
B. 文件不存在
  • 设计目标:输入路径错误时给出明确错误,不崩溃

  • 代表用例:

    def test_MAIN_R004_003_missing_input_files_returncode_2():
        proc = subprocess.run(
            [sys.executable, "main.py", "no_a.txt", "no_b.txt", "ans.txt"],
            capture_output=True, text=True,
        )
        assert proc.returncode == 2
        assert proc.stderr.strip() != ""
    
C. 路径非法/不可写
  • 设计目标:输出路径不是文件、无写权限等情况要被捕获

  • 代表用例:

    def test_MAIN_R004_006_write_path_is_directory(tmp_path):
        """
        输出路径指向目录:write_text_file 内部调用 Path.write_text 抛 IsADirectoryError,
        main 捕获后应返回码 2,并在 stderr 打印错误信息。
        """
        import subprocess
        import sys
    
        o = tmp_path / "o.txt"
        c = tmp_path / "c.txt"
        out_dir = tmp_path / "ans_dir"
        o.write_text("A", encoding="utf-8")
        c.write_text("B", encoding="utf-8")
        out_dir.mkdir()  # 故意把“输出文件”设为一个目录
        proc = subprocess.run(
            [sys.executable, "main.py", str(o), str(c), str(out_dir)], capture_output=True, text=True
        )
        assert proc.returncode == 2
        assert proc.stderr.strip() != ""  # 有错误信息
    
D. 编码异常的容错策略(忽略非法字节)
  • 设计目标:遇到少量非 UTF-8 字节时,忽略非法字节以提升鲁棒性,而不是直接失败

  • 代表用例:

    def test_IO_R003_003_read_ignores_invalid_utf8(tmp_path):
        p = tmp_path / "bad.txt"
        p.write_bytes(b"\xff\xfehello")
        assert "hello" in read_text_file(str(p))  # 非法字节被忽略
    

7. 评价与改进方向

7.1 总体评价

  • 功能:按要求完成,命令行三参 + 可选 -n,输出两位小数;
  • 正确性:单元测试覆盖关键分支(早返回/退化/主路径/异常),分支覆盖率 100%(见截图);
  • 性能:Counter + LRU 优化后,样例耗时约 1.85s → 0.25s,明显加速;内存约 11 MB,达标;
  • 质量:Pylint 10/10,Ruff/Black/isort 全通过。

7.2 改进方向

  1. 更大文本:把 n-gram 改成流式生成,进一步降内存;
  2. 可读性:在 README 里加 阈值示例(相似度 ≥0.8 视为重复)和几条真实样例;
  3. 语义更稳:加入同义词/常见替换的小表(如“星期/周”),减少表面改写带来的分数波动。
posted @ 2025-09-20 19:47  77冰  阅读(39)  评论(0)    收藏  举报