第一次个人作业

第一次个人作业

这个作业属于哪个课程 首页 - 计科24级56班 - 广东工业大学 - 班级博客 - 博客园
这个作业要求在哪里 第一周作业 - 作业 - 计科24级56班 - 班级博客 - 博客园
这个作业的目标 写一篇随笔博客

一、PSP 表格

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

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

2.1 需求拆解

程序要做的事情可以拆成四步:

  1. 从命令行拿到三个绝对路径(原文、抄袭版、答案文件);
  2. 把两个文件读成文本,并处理编码问题;
  3. 计算两段文本的重复率;
  4. 把结果按"两位小数"写入答案文件。

其中第 3 步是核心,其余三步是必要的工程包装。基于"高内聚、低耦合"的原则,程序被拆成 7 个模块:

main.py                                  程序入口,只有 4 行有效代码
└── dupcheck/                            核心包
    ├── cli.py          参数解析 + 流程编排 + 退出码
    ├── text_io.py      多编码文件读取 + 答案写出
    ├── preprocessor.py 文本规范化 + n-gram 切分
    ├── similarity.py   核心算法:多特征加权相似度
    ├── exceptions.py   自定义异常体系
    ├── utils.py        通用工具(数值截断)
    └── __init__.py     包出口,统一暴露 API

2.2 模块调用关系

flowchart TD
    A[main.py] -->|sys.argv| B[cli.main]
    B --> C[cli.parse_args]
    C -->|InvalidArgumentError| X[stderr 提示 + 退出码 2]
    B --> D[cli.check_similarity]
    D --> E[text_io.read_text]
    E -->|EmptyDocumentError| F[降级为空串]
    E -->|FileAccessError / EncodingDetectionError| Y[stderr 提示 + 退出码 1]
    D --> G[similarity.compute_similarity]
    G --> H[preprocessor.normalize]
    G --> I[_count_ngrams 切片计数]
    G --> J[multiset_cosine / dice / jaccard]
    D --> K[text_io.write_result]
    K --> L[答案文件 0.49]

1789283706286

2.3 关键算法的设计

核心矛盾:抄袭版论文经过了增删改,逐字比较(编辑距离)会"一错到底",而单纯比较词集合又会丢失顺序信息。

设计选择:论文被改写后,局部的连续字片段依然会大量保留,所以选择字符级 n-gram作为基本特征,并把多种度量做加权融合:

特征 权重 解决什么问题
unigram_cosine 0.10 单字层面的用词是否接近
bigram_cosine 0.30 二字词语是否被保留
trigram_cosine 0.25 三字短语是否被保留
trigram_dice 0.25 片段"是否出现",对出现次数差异敏感
trigram_jaccard 0.10 与 Dice 互补,抑制短文本的虚高得分

计算流程:

  1. 规范化normalize):NFKC 把全角字符折叠为半角 → casefold() 统一大小写 → 只保留汉字/字母/数字。这样"晴天,天气晴朗"和"晴天 天气 晴朗"会得到完全相同的序列,标点和空白不再干扰结果。
    (第 2、3 步的顺序不能调换,原因见 3.4 节末尾的单元测试。)
  2. 切分build_ngrams 为该模块公开的参考实现,生产路径使用等价的 _count_ngrams):把规范化文本切成 1 元、2 元、3 元的连续片段,用 collections.Counter 统计频次。
  3. 度量:对 1/2/3 元片段算余弦相似度(词频向量),对 3 元片段再算 Dice 与 Jaccard(集合)。
  4. 融合_weighted_average):按权重加权平均,最后截断到 [0, 1]

算法的独到之处

  • 多粒度融合:单独用三元片段对短文本过于苛刻(题目样例只有十几个字),单独用单字又过于宽松。1~3 元加权后,短文和长文都能给出合理结果。
  • 完全 O(n):全程只做切片、计数、求交并,没有任何 O(n²) 的序列比对(这一点由性能分析驱动,详见第三节)。
  • 权重可配置:所有权重集中在 DEFAULT_WEIGHTS 一个字典里,单元测试可以用自定义权重做定向验证(例如只开 unigram_cosine 验证 cosine 本身正确)。
  • 可解释explain_similarity() 会返回每个特征的取值,调试和写博客时能直接看到"分数是怎么来的"。

样例的实测明细:

{
  "length_a": 19,
  "length_b": 17,
  "features": {
    "unigram_cosine": 0.9074,
    "bigram_cosine": 0.6149,
    "trigram_cosine": 0.3757,
    "trigram_dice": 0.3750,
    "trigram_jaccard": 0.2308
  },
  "score": 0.4860
}

即答案文件输出 0.49

2.4 关键函数流程

flowchart TD
    S[compute_similarity text_a text_b] --> N1[normalize text_a]
    S --> N2[normalize text_b]
    N1 --> E{任一方为空?}
    N2 --> E
    E -->|是| R0[返回 0.0]
    E -->|否| EQ{两者完全相同?}
    EQ -->|是| R1[返回 1.0]
    EQ -->|否| F[_extract_features]
    F --> C1[统计 1/2/3 元片段 Counter]
    F --> C2[计算 5 个特征值]
    C2 --> W[_weighted_average 加权平均]
    W --> CL[clamp 到 0..1]
    CL --> OUT[返回重复率]

1789283799984

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

3.1 改进前的瓶颈

第一版原型的思路是"统计特征 + 结构特征":除了 n-gram 之外,再调用 difflib.SequenceMatcher 计算最长匹配块比率,用来刻画整体结构。

cProfile 分析 2 万字符的输入时,总耗时 2.881 秒,耗时排名如下(节选):

   ncalls  tottime  percall  cumtime  percall  function
        1    0.000    0.000    2.881    2.881  similarity.py:compute_similarity
        1    0.000    0.000    2.844    2.844  similarity.py:sequence_similarity
        1    0.000    0.000    2.837    2.837  difflib.py:597(ratio)
      157    2.224    0.014    2.836    0.018  difflib.py:305(find_longest_match)
 10473390    0.612    0.000    0.612    0.612  {method 'get' of 'dict' objects}

结论非常明确:99% 的时间花在 difflib.find_longest_match。把文本长度提高到 8000 字符、并施加约 2% 的密集改动时,单次调用需要 3.356 秒——已经逼近评测"5 秒内给出答案"的红线,再长一点必然超时。

【在此处插入 cProfile / 性能分析工具的截图】

3.2 改进思路

difflib 的最坏复杂度是 O(n·m),而查重场景的输入长度完全不可控,因此靠调参或加长度上限都无法根治(加了上限又会在阈值附近造成结果跳变)。

最终采取的做法是彻底移除 O(n·m) 的序列比对,只保留统计类特征:

  1. 删除 difflib 结构特征,全部使用 O(n) 的 n-gram 余弦 / Dice / Jaccard;
  2. 等价替换:把原来由结构特征承担的"整体相似"职责,交给更高阶的 n-gram(2 元、3 元)——它们本身就隐含了顺序信息;
  3. 补齐权重:新增 trigram_jaccard,把原 sequence_ratio 的权重按比例分摊到 n-gram 特征上,保证权重和仍为 1;
  4. 细节优化:计算余弦时始终遍历"键更少"的那个向量;三元组的 Counter 直接转成 set 复用,避免重复切片。

3.3 第一轮改进的效果

同样用 cProfile 分析 2 万字符输入:

         121897 function calls (121883 primitive calls) in 0.032 seconds
   ncalls  tottime  percall  cumtime  percall  function
        2    0.000    0.000    0.019    0.009  preprocessor.py(normalize)
        2    0.003    0.002    0.015    0.007  {method 'join' of 'str' objects}
        1    0.000    0.000    0.013    0.013  similarity.py(_extract_features)
        6    0.000    0.000    0.012    0.002  similarity.py(_count_ngrams)
    37461    0.005    0.000    0.012    0.000  preprocessor.py(<genexpr>)
        6    0.007    0.001    0.007    0.001  preprocessor.py(<listcomp>)
    40360    0.005    0.000    0.007    0.000  preprocessor.py(_is_meaningful)

总耗时 2.881 s → 0.032 s,提升约 90 倍,并且复杂度从 O(n·m) 降为 O(n),输入再大也不会"爆炸"。

各规模实测(python tools/benchmark.py):

文本规模 改进前(含 difflib) 第一轮改进后 速度
1 万字符 0.781 s 0.0152 s 66 万字/秒
5 万字符 0.058 s(该规模已跳过 difflib) 0.0546 s 92 万字/秒
10 万字符 0.127 s 0.1108 s 90 万字/秒
50 万字符 0.666 s 0.6073 s 82 万字/秒

与教科书式的 O(n·m) 动态规划基线对比(python tools/benchmark.py --naive,输入 1200 字符):

动态规划基线: 得分 0.9733, 耗时 0.1814 秒
当前实现:     得分 0.9272, 耗时 0.0015 秒
加速比: 117.8x

动态规划基线的耗时随规模平方增长,1200 字符就需要 0.18 秒,外推到 2 万字符是 50 秒以上,完全无法满足评测要求。

3.4 第二轮改进:性能分析是一个循环

第一轮结束后再回看那张排名表,会发现一个值得警惕的现象:热点已经完全落到"预处理 + 切片计数"这一层,而不再是算法本身
既然可优化空间都集中在这里,就值得再挖一轮。改动前先在同一台机器上重新采集基线(2 万字符):

         121897 function calls (121883 primitive calls) in 0.032 seconds
    37461    0.005    0.000    0.012    0.000  preprocessor.py(<genexpr>)       ← 逐字符过滤
    40360    0.005    0.000    0.007    0.000  preprocessor.py(_is_meaningful)  ← 4 万次 Python 函数调用
        6    0.007    0.001    0.007    0.001  preprocessor.py(<listcomp>)      ← n-gram 切片

三处问题与对应改法:

# 问题 原因 改法
1 normalize 占总耗时约 58% 逐字符调用 str.isalnum(),2 万字符就产生约 4 万次 Python 层函数调用 ① 整段文本已全是有效字符时直接返回(纯汉字论文很常见);② 否则用正则 [\W_]+ 在 C 层一次性剔除
2 _count_ngrams 每个规模都多建一个中间列表 Counter(build_ngrams(...)) 先用列表推导式生成 list 再计数 一元片段直接 Counter(text);二元/三元用 zip 把错位切片对齐、由 str.join 拼接,让 Counter 直接消费迭代器
3 _extract_features 有两次多余的集合拷贝 set(trigram_counter) 复制了全部键 直接使用 Counter 的键视图参与交并运算

正确性怎么保证? 三处改动都是"换实现、不换语义",因此验证也必须针对语义本身:

  1. 全码位等价性验证:正则 [^\W_]str.isalnum() 是否真的等价,不能只抽几个字符试一下,而是把
    全部 1114112 个 Unicode 码位拼成一个字符串,比较两种过滤方式的结果:

    码位总数      : 1114112
    isalnum 保留数: 133022
    filter  一致  : True
    regex   一致  : True
    
  2. 双实现互验_count_ngrams(优化实现)与 build_ngrams(照定义写的参考实现)逐规模比对,
    并锁定"文本长度小于 n"的边界行为;

  3. 端到端比对:优化前后所有测试的分数逐项一致(样例 0.49、长文 0.67、无关文 0.00 均未变)。

3.5 第二轮改进的效果与当前最大热点

同样 2 万字符,cProfile 总耗时 0.032 s → 0.017 s

         3734 function calls (3706 primitive calls) in 0.017 seconds
   ncalls  tottime  percall  cumtime  percall  function
        6    0.011    0.002    0.011    0.002  {built-in method _collections._count_elements}
        2    0.004    0.002    0.004    0.002  {built-in method unicodedata.normalize}
        2    0.001    0.000    0.001    0.000  {method 'sub' of 're.Pattern' objects}

特别注意函数调用次数:121897 → 3734,减少了 97%。这正是"把 Python 层循环换成 C 层调用"带来的收益。

各规模前后对比(同一台机器立即复测):

文本规模 第二轮前 第二轮后 提升
1 万字符 0.0152 s 0.0081 s 1.9×
5 万字符 0.0546 s 0.0410 s 1.3×
10 万字符 0.1108 s 0.0829 s 1.3×
20 万字符 0.1654 s
50 万字符 0.6073 s 0.4173 s 1.5×

吞吐量从约 82 万字/秒提升到约 120 万字/秒;与动态规划基线的加速比从 117.8× 提升到 139.7×

当前程序中消耗最大的函数(20 万字符,按 tottime 排序):

排名 函数 tottime 说明
1 _collections._count_elements(C) 0.120 s Counter 的计数本体,共 6 次调用
2 unicodedata.normalize(C) 0.043 s NFKC 全角折叠,2 次调用
3 re.Pattern.sub(C) 0.008 s 噪声字符剔除,2 次调用

结论:前三名全部是 C 层内建函数,Python 层已经没有任何值得一提的开销
_count_ngramsmultiset_cosine 等函数的 tottime 都已降到 0.001 s 量级)。
要再往下提速只能更换实现语言或引入专用扩展库,对本作业而言收益与代价不成比例,因此性能改进到此结束。

image

复现方式:python tools/benchmark.py --svg docs/profile.svg --size 200000
也可以导出原始数据后用可视化工具出图:python tools/benchmark.py --profile --dump profile.out
再执行 python -m snakeviz profile.outgprof2dot -f pstats profile.out | dot -Tpng -o profile.png


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

4.1 测试组织

测试使用 Python 标准库 unittest 编写,共 75 个测试用例,分成 6 个文件:

测试文件 用例数 测试对象
test_exceptions.py 6 异常的继承关系与默认提示
test_preprocessor.py 14 文本规范化与 n-gram 切分
test_similarity.py 25 各度量函数、整体得分、n-gram 计数优化
test_text_io.py 16 多编码读取、答案写出、IO 失败
test_cli.py 9 参数解析与端到端流程
test_main.py 5 以子进程运行 main.py

4.2 构造测试数据的思路

  1. 正常输入:使用题目给出的样例文本,以及自己编写的长文与"加了一段"的抄袭版;
  2. 边界输入:空串、单字符、纯标点、只有空白字符的文件、超长文本;
  3. 异常输入:文件不存在、路径是目录、GBK 编码文件、无法解码的二进制文件、open 直接抛 OSError
  4. 关系验证:不比对绝对分数,而是断言"更相似的一对得分必然更高",避免把测试写死在对某个算法的偏好上;
  5. 端到端验证:直接以子进程方式执行 python main.py a.txt b.txt ans.txt,检查退出码与答案文件内容。

4.3 部分测试代码

(1)底层度量函数的正、反、边界样例

class SetMetricTest(unittest.TestCase):
    """集合类相似度指标。"""

    def test_dice_identical_sets(self):
        self.assertAlmostEqual(dice_coefficient({"a", "b"}, {"a", "b"}), 1.0)

    def test_dice_disjoint_sets(self):
        self.assertEqual(dice_coefficient({"a"}, {"b"}), 0.0)

    def test_jaccard_partial_overlap(self):
        self.assertAlmostEqual(jaccard_similarity({"a", "b"}, {"b", "c"}), 1.0 / 3.0)


class CosineTest(unittest.TestCase):
    """多重集余弦相似度。"""

    def test_identical_counters(self):
        self.assertAlmostEqual(multiset_cosine(Counter("ab"), Counter("ab")), 1.0)

    def test_cosine_is_symmetric(self):
        left = multiset_cosine(Counter("banana"), Counter("bandana"))
        right = multiset_cosine(Counter("bandana"), Counter("banana"))
        self.assertAlmostEqual(left, right)

(2)整体得分的单调性验证

def test_more_similar_pair_scores_higher(self):
    base = "软件工程的个人项目要求实现一个论文查重程序,需要输入三个文件路径。"
    close = "软件工程的个人项目要求实现一个论文查重程序,需要输入三个文件路径!"
    far = "今天的晚饭是番茄炒蛋和米饭,饭后我要去操场跑步锻炼身体。"
    self.assertGreater(compute_similarity(base, close), compute_similarity(base, far))

(3)端到端测试:真正执行 python main.py

class MainScriptTest(unittest.TestCase):
    def _run(self, arguments):
        return subprocess.run(
            [sys.executable, MAIN_SCRIPT] + list(arguments),
            cwd=PROJECT_ROOT, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
        )

    def test_sample_pair_outputs_expected_value(self):
        with tempfile.TemporaryDirectory() as temp_dir:
            answer = os.path.join(temp_dir, "ans.txt")
            result = self._run([
                os.path.join(DATA_DIR, "original_example.txt"),
                os.path.join(DATA_DIR, "suspect_example.txt"),
                answer,
            ])
            self.assertEqual(result.returncode, 0)
            with open(answer, encoding="utf-8") as handle:
                self.assertEqual(handle.read(), "0.49")

(4)性能回归测试:防止有人把 O(n²) 改回去

class PerformanceRegressionTest(unittest.TestCase):
    def test_long_text_finishes_well_under_limit(self):
        text = "软件工程论文查重算法性能优化" * 5000  # 约 7 万字符
        start = time.perf_counter()
        score = compute_similarity(text, text[: len(text) // 2])
        elapsed = time.perf_counter() - start
        self.assertLess(elapsed, 5.0)
        self.assertGreater(score, 0.0)

(5)性能优化后的"双实现互验"

第 3.4 节把 Counter(build_ngrams(...)) 换成了 zip + str.join 的写法。为了保证优化没有偷偷改变结果,
测试里同时保留"照定义写"的参考实现,让两条路径互相验证:

class CountNgramsTest(unittest.TestCase):
    """_count_ngrams 的优化实现必须与直观的参考实现完全等价。"""

    def test_matches_reference_implementation(self):
        text = "软件工程论文查重算法性能优化"
        for size in (1, 2, 3):
            with self.subTest(size=size):
                self.assertEqual(
                    _count_ngrams(text, size),
                    Counter(build_ngrams(text, size)),
                )

    def test_text_shorter_than_size_yields_empty_counter(self):
        with self.subTest(size=3):
            self.assertEqual(_count_ngrams("ab", 3), Counter())
            self.assertEqual(build_ngrams("ab", 3), [])

(6)锁定"先折叠再过滤"的顺序

casefold() 会把某些字符展开成"基字符 + 组合符号",而组合符号不是有效字符。这条用例把顺序钉死,
避免以后有人为了性能把两步调换顺序而悄悄改变结果:

def test_combining_mark_from_casefold_is_removed(self):
    # 'İ'.casefold() 会展开成 'i' + U+0307(组合符号),而组合符号不是有效字符。
    # 这条用例锁定了"先 casefold 再过滤"的顺序:顺序反了结果就会变成 'i̇'。
    self.assertEqual(normalize("İ"), "i")

4.4 测试结果与覆盖率

$ python -m unittest discover -s tests -t .
----------------------------------------------------------------------
Ran 75 tests in 3.412s

OK

覆盖率参数(branch = True、排除 tests/tools/)已固化在 .coveragerc 中,
所以统计覆盖率只需要两条命令:

$ python -m coverage run -m unittest discover -s tests -t .
$ python -m coverage report -m
Name                       Stmts   Miss Branch BrPart  Cover   Missing
----------------------------------------------------------------------
dupcheck\__init__.py           5      0      0      0   100%
dupcheck\cli.py               41      0      2      0   100%
dupcheck\exceptions.py        13      0      0      0   100%
dupcheck\preprocessor.py      18      0      8      0   100%
dupcheck\similarity.py        70      0     26      0   100%
dupcheck\text_io.py           44      0     16      0   100%
dupcheck\utils.py              4      0      0      0   100%
main.py                        2      0      0      0   100%
----------------------------------------------------------------------
TOTAL                        197      0     52      0   100%

语句覆盖率 100%,分支覆盖率 100%(52 个分支全部走到,BrPart 为 0),无遗漏行。

生成带逐行高亮的 HTML 报告便于截图:

$ python -m coverage html
$ start htmlcov\index.html

1789283999979


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

程序定义了 4 种自定义异常,全部继承自 DuplicateCheckError,调用方只要捕获这个基类就能兜住所有已知错误,不会出现未捕获异常导致的异常退出

5.1 InvalidArgumentError(命令行参数个数错误)

  • 设计目标:评测机固定传 3 个参数,一旦参数个数不对,说明调用方式错了,必须立刻给出明确提示,而不是继续跑下去算出一个无意义的分数。
  • 处理策略:向 stderr 打印用法说明,返回退出码 2(与 argparse 的约定一致)。
  • 对应测试
def test_wrong_argument_count_returns_usage_error(self):
    self.assertEqual(main([]), EXIT_USAGE_ERROR)
  • 错误场景python main.py a.txt b.txt(少传了答案文件)。

5.2 FileAccessError(无法访问文件)

  • 设计目标:把"文件不存在 / 路径是目录 / 磁盘读取失败 / 答案文件不可写"统一成一种可预期的错误,避免把 OSErrorIsADirectoryError 等底层异常泄漏给用户。
  • 处理策略:向 stderr 打印具体路径,返回退出码 1
  • 对应测试
def test_missing_file_raises_file_access_error(self):
    with self.assertRaises(FileAccessError):
        read_text(os.path.join(DATA_DIR, "not_exists.txt"))

def test_directory_path_raises_file_access_error(self):
    with self.assertRaises(FileAccessError):
        read_text(DATA_DIR)
  • 错误场景:把目录当作输入文件传入;open() 因权限或磁盘错误而失败(用 mock 模拟 OSError 验证)。

5.3 EncodingDetectionError(无法识别编码)

  • 设计目标:论文文件可能来自 Word、记事本、网盘,编码不一定是 UTF-8。程序按 UTF-8 → UTF-8-SIG → GB18030 → BIG5 → UTF-16 的顺序尝试解码;如果全部失败,说明文件根本不是文本,此时必须明确报错而不是返回乱码结果。
  • 处理策略FileAccessError 的子类,向 stderr 打印路径,返回退出码 1
  • 对应测试
def test_undecodable_file_raises_encoding_error(self):
    with tempfile.TemporaryDirectory() as temp_dir:
        path = os.path.join(temp_dir, "binary.txt")
        with open(path, "wb") as handle:
            handle.write(b"\xff\xfe\x00\x01\xff")
        with self.assertRaises(EncodingDetectionError):
            read_text(path, encodings=("utf-8",))
  • 错误场景:输入的是一个二进制文件(例如误传了 .docx 而不是 .txt)。

5.4 EmptyDocumentError(文档内容为空)

  • 设计目标:空文件是合法输入——"空文档与任何文档的重复率是 0"是一个有意义的结论,不应该让整个程序失败。因此它被单独定义成一个异常,由上层 cli 捕获并降级处理
  • 处理策略:打印警告,把该文档按空串参与计算,最终写出 0.00,退出码 0
  • 对应测试
def test_empty_document_is_downgraded_to_zero(self):
    with tempfile.TemporaryDirectory() as temp_dir:
        empty = os.path.join(temp_dir, "empty.txt")
        open(empty, "w", encoding="utf-8").close()
        answer = os.path.join(temp_dir, "ans.txt")
        code = main([empty, SUSPECT, answer])
        self.assertEqual(code, EXIT_SUCCESS)
        self.assertEqual(_read_answer(answer), "0.00")
  • 错误场景:抄袭版论文内容为空,或整篇只有换行和空格。

5.5 异常处理的边界:不越权访问文件

程序全程只读写三个路径:两个输入文件 + 一个答案文件。它不联网、不读取环境变量、不执行任何系统命令,也不存在 system("shutdown") 之类的危险调用。


六、实际耗时汇总

见第一节 PSP 表格的"实际耗时"列,本次开发实际总耗时 150 分钟,其中开发占了大头(约 30 分钟)。

posted @ 2026-09-13 16:02  车俊贤  阅读(20)  评论(0)    收藏  举报