第一次编程作业

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

Github 作业链接:https://github.com/xxyyll0323/3224004083

项目 内容
这个作业属于哪个课程 https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS
这个作业要求在哪里 https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS/homework/15693
学号 3224004083
入口程序 3224004083/main.py
开发语言 Python 3(零第三方运行时依赖)

一、PSP 表格

「预估耗时」在动手写代码之前填写;「实际耗时」在整个项目做完之后回填。

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

预估是怎么来的:按"任务清单 → 逐项估时"推出来的,不是拍脑袋。最容易低估的是
Analysis —— 作业里有几条硬约束(5 秒内给出答案、内存不超过 2048 MB、不联网、
不读写命令行之外的文件、不能异常退出
)会反过来决定算法选型和异常设计,必须先吃透。

偏差分析

阶段 预估 实际 偏差 原因
Analysis 40 55 +15 确认硬约束如何影响算法与异常设计
Coding Standard 15 25 +10 引入 pylint 后回头统一命名与文档规范
Coding 120 150 +30 多阶 n-gram 的权重反复试了几轮
Test 100 160 +60 为把覆盖率补到 100%,额外覆盖异常分支与进程入口
Postmortem 45 60 +15 性能分析发现瓶颈不在算法而在规范化,多花时间做对照实验
合计 520 665 +145 超支主要在两块:把边界想全用数据确认优化方向

真正花时间的不是"写出能跑的代码"(Coding 只占 1/4),而是把边界情况和性能问题用数据
确认清楚。PSP 的价值就在这里:预估时我明显低估了测试与性能分析的成本。


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

2.1 代码组织

程序入口是 3224004083/main.py,核心计算放在 plagiarism/ 包里,按职责拆成五个模块:

模块 函数 / 类 作用
main.py main(argv) 接收命令行参数,串起整个流程;把可预期错误转成退出码
configure_output_streams() 让标准输出/错误在 Windows 控制台也能打印中文
EXIT_OK / EXIT_RUNTIME_ERROR / EXIT_USAGE_ERROR 退出码 0 / 1 / 2
plagiarism/reader.py read_text(path) 读文件:依次尝试 UTF-8 / GB18030 / UTF-16,去 BOM,做 NFKC
strip_html(text) 剥离 <script> / <style> 与所有 HTML 标签、解码实体
plagiarism/normalize.py normalize(text) 规范化:只保留字母数字汉字,字母统一小写
normalize_reference(text) 逐字符参考实现,仅用于测试与基准对照
plagiarism/similarity.py count_ngrams(text, n) 统计相邻 n 个字符的片段各出现多少次
cosine_similarity(a, b) 两个频次向量的夹角余弦
ngram_similarity(a, b) 1~4 阶余弦的加权平均
compute_similarity(p1, p2) 读两个文件 → 规范化 → 计算,模块对外的总入口
explain(a, b) 返回逐阶明细,便于调试
_select_orders(n) 超长文本的降阶策略
plagiarism/writer.py format_score(score) 保留两位小数、四舍五入、越界夹紧
write_answer(path, score) 把答案写进文件
plagiarism/errors.py PaperCheckError 及三个子类 可预期错误的异常体系

为什么这样分:文件读写(reader / writer)与算法(normalize / similarity
彻底分开。算法模块完全不碰磁盘,所以能用纯函数的方式做单元测试;全项目只有 reader
writer 里出现 open(),"不读写命令行之外的文件"这条约束在代码结构上就看得见。

2.2 函数之间的关系

main(argv)
 ├─ 参数个数 != 3 ──────────────► 打印用法 → 返回 EXIT_USAGE_ERROR (2)
 │
 ├─ compute_similarity(原文, 抄袭版)
 │    ├─ reader.read_text(原文) ──► strip_html 剥离网页样板
 │    │                            └─ 编码识别 → 去 BOM → NFKC
 │    ├─ reader.read_text(抄袭版)
 │    ├─ normalize(...) 两段 ─────► 去标点空白、字母小写、中文主导时去英文噪声
 │    ├─ 有效字符为空 ────────────► EmptyTextError
 │    └─ ngram_similarity(规范化的两段)
 │         ├─ _select_orders(长度) ──► 超长文本自动降阶
 │         └─ 逐阶:count_ngrams ×2 → cosine_similarity
 │
 ├─ 得到一个 0~1 的浮点数
 ├─ writer.write_answer(答案文件, score)
 │    └─ format_score ──► 保留两位小数
 └─ 返回 EXIT_OK (0)

任何 PaperCheckError / OSError ──► 打印提示 → 返回 EXIT_RUNTIME_ERROR (1)

一句话概括:main 只负责"参数校验 + 串流程 + 异常转退出码",真正的计算全在 plagiarism
包里,compute_similarity 是唯一对外的计算入口。

2.3 算法关键

核心思路:把文本转成"字符 n-gram 频次向量",用余弦相似度衡量两个向量的夹角。

具体步骤:

  1. 读入两段文本,做编码识别与网页噪声剥离;

  2. 规范化:去掉标点、空白、换行,字母转小写,全角转半角;

  3. 把规范化后的字符流切成相邻 n 个字符的片段(n-gram),用 Counter 统计频次

  4. 对每一阶 n,算两个频次向量的余弦:设片段 g 在 A、B 中出现次数为 A(g)、B(g),则

    dot    = Σ_g A(g) × B(g)
    cos    = dot / ( |A| × |B| )        |A| = √(Σ_g A(g)²)
    
  5. 把 1~4 阶的余弦按权重加权平均,得到最终相似度。

n(片段长度) 1 2 3 4
权重 0.30 0.30 0.25 0.15

为什么是"多重集"而不是"集合"aaaa 里的 aa 出现 3 次,频次要参与计算,
否则重复段落的权重会被抹平。这一点有专门的单元测试(test_keeps_multiplicity)。

权重为什么低阶占主导:短片段对局部改写(同义替换、逐字插入、词级乱序)鲁棒,长片段
对无关文本区分度更高,两者互补。这组 0.30/0.30/0.25/0.15 不是拍脑袋定的,是在老师下发的
官方样例上实测调出来的(见 2.4 第 7 条)。

2.4 独到之处

  1. 和顺序无关。n-gram 频次是全文统计量,所以"把段落顺序打乱"不会让相似度塌掉——
    这正是作业要求里"能处理段落顺序发生变化的相似内容"。有对应用例
    test_paragraph_reordering_keeps_score_high 守着这条性质。

  2. 零第三方依赖。没有用 jieba 之类的分词库,只用标准库。这样评测机不用联网装包,
    pip install -r requirements.txt 立刻成功,也不会踩"尝试连接网络"这条红线。

  3. 中文主导时去英文噪声。官方样例里 del / dis 系列是从网页抓下来的,正文外面
    裹着 GitHub 页面的英文导航(skip to content / sign up / why github)。这些英文样板
    混进正文会把相似度压低 0.2 以上。做法是:当文本以汉字为主(占比 ≥50%)时丢弃非中文
    字符;英文论文(无汉字)不受影响。

  4. 三种编码 + BOM + NFKC。依次尝试 UTF-8 / GB18030 / UTF-16,去掉开头的 BOM,
    再做 Unicode NFKC 归一化(全角 BC123 → 半角 BC123)。

  5. HTML 噪声剥离。除了 <script> / <style>,还要处理网页抓取版里那种超长的
    <link integrity="sha384-…"> 标签(这种标签超过 200 字符,用"限制长度的正则"会漏掉,
    必须写成无长度上限的匹配)。

  6. 超长文本自动降阶。超过 80 万字符不再统计 4-gram,超过 200 万字符只保留 2、3 阶,
    用可控的精度损失换取内存与时间的安全边界。

  7. 权重在真实样例上校准。用官方样例跑出"增/删 ≈ 0.80、段落乱序 ≈ 0.95、重度乱序
    0.64、无关文本 0.09",而不是凭直觉设参数。调参前后的对比见第六章。

  8. 快慢两版实现互相验证normalize 是优化后的查表实现,normalize_reference
    朴素的逐字符实现,用差分测试逐字符比对,防止"优化把语义改坏了"。


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

3.1 性能分析图

cProfile 跑一遍,再用 pstats 把热点排出来:

python tools/perf_analysis.py

图1:性能分析——热点函数(cProfile)

3.2 消耗最大的函数

大文本(55 万字符)下的 cProfile 结果,按自身耗时(tottime)降序:

函数 调用次数 tottime 占比
Counter._count_elements(统计词频) 8 0.728 s 35.0%
<genexpr>(切 n-gram 片段) 3 074 115 0.503 s 24.2%
str.join 2 0.178 s 8.5%
unicodedata.normalize(NFKC) 2 0.153 s 7.4%
<genexpr>(余弦的点积) 1 024 709 0.152 s 7.3%
builtins.sum 12 0.119 s 5.7%
_translation_for(建翻译表) 2 0.051 s 2.5%
str.translate(规范化) 2 0.044 s 2.1%

结论:消耗最大的是 Counter._count_elements(35%)和切片生成器(24%),
瓶颈在特征统计环节,不在余弦计算,也不在文件读写

我原本以为瓶颈会在余弦的点积上,实际测出来完全不是——这就是先用剖析工具、再动手优化的
意义。

3.3 改进思路

优化点 改进前 改进后 效果
文本规范化 逐字符调用 Python 函数判断保留与否 建「字符 → 目标字符」映射表 + str.translate,映射在 C 层完成 60 万字符 0.318 s → 0.174 s(1.8×)
一阶 n-gram 走通用的切片生成器 直接 Counter(text) 快速路径,跳过切片 少一次全量生成器
超长文本 一律统计 1~4 阶 超 80 万字符去掉 4 阶,超 200 万字符只留 2、3 阶 内存可控
余弦点积 遍历两个词表的并集 只遍历元素较少的一侧,模长在循环内累加 额外空间 O(1)

改进后重新剖析,热点已经从"规范化"转移到"n-gram 频次统计"(Counter 占 35%),
说明这一步改对了方向。

图2:规范化改进前后对比

一个被否决的方案(也值得记下来):我一度认为"计算期间关闭分代 GC"能大幅提速。
第一次测出 2.2×,差点写进报告。后来改成交替测量 A/B 才发现——那 2.2× 是机器负载漂移,
不是算法差异
。重新做对照实验(两种写法交替运行)后差距只有 1.06×,落在噪声范围内,
于是这个方案被否决,没有进入最终代码。

这件事让我把测量方法改成:对比实验一律交替 A/B,规模表同时给最快/中位数/最慢

3.4 改进耗时

性能改进一共花了约 50 分钟

  • 20 分钟用 cProfile 定位瓶颈(先排除了余弦和文件读写);
  • 15 分钟把规范化改成"映射表 + str.translate",把一阶 n-gram 走快速路径;
  • 10 分钟重新测量、确认分数没有变化(防止"优化改坏了语义");
  • 5 分钟做那个被否决的 GC 对照实验(虽然方案没采用,但避免了把噪声当成果)。

3.5 各规模下的时间与内存

规模档位 原文(字符) 抄袭版(字符) 最快 中位数 最慢 峰值内存
样例级 5 541 4 724 10.3 ms 11.4 ms 25.7 ms 0.4 MiB
中篇 55 386 47 074 95.6 ms 96.7 ms 133.0 ms 3.9 MiB
长篇 184 616 156 831 366.8 ms 374.7 ms 387.0 ms 13.2 MiB
极端 553 847 470 860 1 067.6 ms 1 128.4 ms 1 181.7 ms 39.4 MiB

即便 55 万字符的极端输入(约 1.1 MB 文本,远大于课程样例),也在 1.2 秒内、40 MiB
以内
完成,距离 5 秒 / 2048 MB 的限制有很大余量。运行时间与文本长度基本呈线性关系。


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

4.1 测试文件

用 Python 标准库 unittest 编写,放在 3224004083/tests/,共 73 个用例

测试文件 覆盖对象 用例数
tests/test_similarity.py 相似度算法:count_ngrams / cosine_similarity / ngram_similarity / explain 24
tests/test_reader.py 规范化与读取:normalize / normalize_reference / read_text / strip_html 15
tests/test_cli.py 命令行契约:main 退出码、write_answer / format_score、参数个数 18
tests/test_edge_cases.py 边界与异常:降阶、编码失败、OSError 包装、子进程入口 16
合计 73

作业要求"至少 10 个测试用例",这里做了 73 个。

4.2 测试代码

① 可手算的数值(不是只验证"落在 0~1 之间"):

def test_hand_computed_multiplicity_case(self) -> None:
    # 两个向量的分量可以完全手算出来:
    #   A = "甲乙甲乙" -> {"甲乙": 2, "乙甲": 1}   |A|² = 4 + 1       = 5
    #   B = "甲乙乙甲" -> {"甲乙": 1, "乙乙": 1, "乙甲": 1}   |B|² = 1+1+1 = 3
    #   点积 = 2×1 + 1×1 = 3
    #   cos  = 3 / (√5 × √3) = 3 / √15 ≈ 0.774597
    grams_a = count_ngrams("甲乙甲乙", 2)
    grams_b = count_ngrams("甲乙乙甲", 2)
    self.assertAlmostEqual(cosine_similarity(grams_a, grams_b), 3 / math.sqrt(15), places=12)

② 多重集频次必须保留(不能只记"出现过"):

def test_keeps_multiplicity(self) -> None:
    # "aa" 在 "aaaa" 中出现 3 次,必须保留频次而不是只记录"是否出现"。
    self.assertEqual(count_ngrams("aaaa", 2), {"aa": 3})

③ 段落乱序仍应是高相似(作业的显式要求):

def test_paragraph_reordering_keeps_score_high(self) -> None:
    # 段落顺序变化不应该让相似度塌掉,这是作业要求的显式场景。
    first = "第一段描述问题背景与已有工作。"
    second = "第二段给出算法设计与实现细节。"
    third = "第三段展示实验结果并分析误差来源。"
    original = first + second + third
    shuffled = third + first + second
    self.assertGreater(ngram_similarity(original, shuffled), 0.9)

④ 无关文本必须压得足够低

def test_fully_unrelated_text_stays_low(self) -> None:
    red_mangrove = "红树林生长在热带与亚热带海岸的潮间带上"
    software = "代码复用是软件工程中被反复讨论的核心议题之一"
    self.assertLess(ngram_similarity(red_mangrove, software), 0.15)

⑤ 答案格式精确到两位小数

def test_writes_value_with_newline(self) -> None:
    path = self.tmp / "ans.txt"
    write_answer(str(path), 2.0 / 3.0)
    self.assertEqual(path.read_text(encoding="utf-8"), "0.67\n")

⑥ 参数错误时保护已有的答案文件(不能把上一次的结果覆盖成垃圾):

def test_missing_input_file_keeps_existing_answer(self) -> None:
    self.answer.write_text("keep\n", encoding="utf-8")
    code, message = self._run(
        [str(self.original), str(self.tmp / "missing.txt"), str(self.answer)]
    )
    self.assertEqual(code, EXIT_RUNTIME_ERROR)
    self.assertIn("不存在", message)
    self.assertEqual(self.answer.read_text(encoding="utf-8"), "keep\n")

⑦ 优化后的快速实现必须和参考实现逐字符一致

def test_translate_version_matches_reference(self) -> None:
    # 查表 + translate 的快速实现必须与逐字符参考实现完全一致,
    # 防止"性能优化改坏了语义"。
    samples = [
        "",
        "。。。!?",
        "Hello, World! 123",
        "ABC123",
        "今天是星期天,天气晴。",
        "Mixed 中英 text 2026",
        "café Ünïcode ∑∆",
    ]
    for sample in samples:
        self.assertEqual(normalize(sample), normalize_reference(sample))

⑧ 超长文本的降阶策略(白盒测试私有函数):

def test_large_text_drops_fourth_order(self) -> None:
    self.assertEqual(_select_orders(1_000_000), [1, 2, 3])

def test_huge_text_keeps_middle_orders_only(self) -> None:
    self.assertEqual(_select_orders(5_000_000), [2, 3])

4.3 测试函数与数据构造

用例 测的函数 构造思路
test_counts_adjacent_pairs count_ngrams "abcd" 切 2 阶,期望 3 个片段各 1 次
test_keeps_multiplicity count_ngrams "aaaa" 切 2 阶,验证频次是 3 而不是"出现"
test_returns_empty_when_text_shorter_than_n count_ngrams 文本比 n 短("ab" 切 4 阶),期望空
test_rejects_non_positive_n count_ngrams 边界值 n=0,期望抛 ValueError
test_hand_computed_multiplicity_case cosine_similarity 分量可手算,期望值 3/√15
test_single_dimension_vector_scores_one cosine_similarity 单维度向量,把"余弦对长度不敏感"显式记录下来
test_is_symmetric cosine_similarity 交换两个输入,期望结果相同
test_identical_text_scores_exactly_one ngram_similarity 完全相同的文本,期望恰好 1.0
test_fully_unrelated_text_stays_low ngram_similarity 红树林 vs 软件工程,期望 < 0.15
test_paragraph_reordering_keeps_score_high ngram_similarity 三段文字循环移位,期望 > 0.9
test_insertion_keeps_score_high ngram_similarity 在原句后追加一句,期望仍在 0.6~1.0
test_deletion_keeps_score_high ngram_similarity 删掉后半句,期望仍在 0.5~1.0
test_score_always_within_unit_interval ngram_similarity 6 组含空串、单字符的极端输入,验证值域
test_explain_reports_every_order explain 验证逐阶明细的键齐全且与总分一致
test_removes_whitespace_and_punctuation normalize 带全角标点与换行的句子
test_translate_version_matches_reference normalize / normalize_reference 差分测试:6 组含全角、重音、空串的样本逐字符比对
test_reads_gb18030_file read_text 等价类:用 GB18030 编码写临时文件
test_bom_is_removed_even_without_utf8_sig read_text 桩替换编码列表,验证 BOM 被去掉
test_removes_tags_and_scripts strip_html <script> 的网页片段
test_numeric_html_entities_are_decoded strip_html 十进制与十六进制实体
test_keeps_two_decimal_places format_score 0.8"0.80"2/3"0.67"
test_rounds_half_up format_score 边界值 0.005 / 0.0049
test_clamps_to_unit_interval format_score 越界值 1.4 / -0.2
test_writes_value_with_newline write_answer 临时文件写入后读回比对
test_rejects_directory_as_result write_answer 把目录当答案路径
test_argument_count_zero/one/two/four main 边界值:参数 0 / 1 / 2 / 4 个,期望退出码 2
test_missing_input_file_keeps_existing_answer main 文件不存在 + 预置答案内容,验证答案没被破坏
test_directory_as_input_returns_error main 把目录当输入文件
test_empty_copied_file_returns_error main 空文件,期望退出码 1
test_punctuation_only_file_returns_error main 纯标点文件,规范化后为空
test_answer_not_created_when_input_missing main 验证失败时不会创建半成品答案文件
test_large_text_drops_fourth_order _select_orders 白盒测试降阶阈值 100 万字符
test_huge_text_keeps_middle_orders_only _select_orders 500 万字符只留 2、3 阶
test_open_failure_is_wrapped read_text / write_answer mock 掉 openOSError,模拟磁盘故障
test_unrecognized_encoding_raises read_text 桩替换编码列表为 ascii,使三种编码全失败
test_entry_point_script_reports_usage main.py 进程入口 子进程启动,验证退出码与用法提示

测试数据全部用 tempfile.TemporaryDirectory() 动态生成,不依赖任何外部文件,
所以用例可重复、可移植,换台机器照样跑。

4.4 运行方式

# 方式一:标准库 unittest(零依赖)
python -m unittest discover -s tests -t .

# 方式二:pytest(需要 pip install -r requirements-dev.txt)
python -m pytest tests -q

用例写成 unittest 风格,好处是两种跑法都支持——评测机没装 pytest 也能直接跑。

4.5 覆盖率

coverage 统计:

python tools/test_coverage.py

输出:

Name                       Stmts   Miss  Cover   Missing
--------------------------------------------------------
main.py                       31      0   100%
plagiarism\__init__.py         6      0   100%
plagiarism\errors.py           5      0   100%
plagiarism\normalize.py       40      0   100%
plagiarism\reader.py          53      0   100%
plagiarism\similarity.py      73      0   100%
plagiarism\writer.py          24      0   100%
--------------------------------------------------------
TOTAL                        232      0   100%

用例总数:73    失败:0    错误:0
总体语句覆盖率:100.0%

903a9bc684228397545c62ca1afffee3

73 个用例全部通过,232 条语句 0 条未覆盖,语句覆盖率 100%。其中
if __name__ == "__main__" 这类进程入口按惯例用 # pragma: no cover 排除,改由
"以子进程方式启动 main.py"的用例实际验证退出码,所以没有留下未验证的路径。


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

评测环境把"异常退出"视为失败,所以程序内部所有可预期的错误都转成自定义异常,
main 统一捕获、打印提示、以非零退出码结束,绝不把 Python 堆栈抛给评测机

退出码约定:0 正常 / 1 运行期错误 / 2 参数个数错误。

异常 设计目标 单元测试 场景
参数个数错误 阻止参数越界访问,避免 IndexError,并提示正确用法 test_argument_count_zero/one/two/four 只给 0~2 个参数,或给了 4 个以上
FileReadError(不存在) 输入不可用时明确提示,并保护已有答案不被覆盖 test_missing_input_file_keeps_existing_answer 路径拼错、把目录当文件传、传了空字符串
FileReadError(编码) 不把乱码当正文参与计算;三种编码都失败时给出明确提示 test_unrecognized_encoding_raises 文件既不是 UTF-8,也不是 GB18030 / UTF-16
FileReadError(打开失败) 把底层 OSError 包装成统一异常,不让原始堆栈外泄 test_open_failure_is_wrapped 权限不足、设备不可用
AnswerWriteError 写不进去就明确报错,不能"看起来成功了" test_open_failure_is_wrapped(writer 版)、test_rejects_directory_as_result 答案路径是目录、上级目录不存在、磁盘只读
EmptyTextError 没有可比较内容时不给出一个没有意义的分数,而要明确报错 test_punctuation_only_file_returns_errortest_both_files_empty_raises 文件是空的,或者全是标点(规范化之后什么都不剩)
兜底 OSError 没预料到的系统级错误也不能崩溃 test_unexpected_oserror_returns_runtime_error 磁盘故障等运行时才出现的错误

5.1 代码示例

异常体系(plagiarism/errors.py):

class PaperCheckError(Exception):
    """本项目所有可预期错误的基类。"""

class FileReadError(PaperCheckError):
    """输入文件不可用:不存在、是目录、无权限或编码无法识别。"""

class EmptyTextError(PaperCheckError):
    """输入文件存在,但内容里没有任何可供比较的有效字符。"""

class AnswerWriteError(PaperCheckError):
    """答案文件无法写入:路径是目录、上级目录不存在、磁盘不可写等。"""

读取时把底层错误统一包装(plagiarism/reader.py 节选):

def read_text(path: str) -> str:
    if not isinstance(path, str) or not path:
        raise FileReadError("输入文件路径为空")

    if os.path.isdir(path):
        raise FileReadError(f"输入路径是目录而不是文件: {path}")

    if not os.path.exists(path):
        raise FileReadError(f"输入文件不存在: {path}")

    try:
        with open(path, "rb") as handle:
            raw = handle.read()
    except OSError as exc:
        raise FileReadError(f"无法读取输入文件 {path}: {exc}") from exc

    for encoding in _ENCODINGS:
        try:
            text = raw.decode(encoding)
        except (UnicodeDecodeError, LookupError):
            continue
        break
    else:
        raise FileReadError(f"无法识别的文件编码(已尝试 UTF-8/GB18030/UTF-16): {path}")

    if text.startswith("\ufeff"):
        text = text[1:]

    text = text.replace("\x00", "")
    text = unicodedata.normalize("NFKC", text)
    text = text.replace("\r\n", "\n").replace("\r", "\n")
    return strip_html(text)

先把文件读成字节,再逐个编码尝试解码——这样即使第一次解码失败,也不用重新读盘。

5.2 单元测试

① 文件不存在 → FileReadError,且不破坏已有答案

def test_missing_input_file_keeps_existing_answer(self) -> None:
    self.answer.write_text("keep\n", encoding="utf-8")
    code, message = self._run(
        [str(self.original), str(self.tmp / "missing.txt"), str(self.answer)]
    )
    self.assertEqual(code, EXIT_RUNTIME_ERROR)
    self.assertIn("不存在", message)
    self.assertEqual(self.answer.read_text(encoding="utf-8"), "keep\n")

路径不存在时抛 FileReadError,在 main 里被捕获,打印提示后以退出码 1 结束;
答案文件保持原样,不会被覆盖成垃圾。

② 编码无法识别 → FileReadError

def test_unrecognized_encoding_raises(self) -> None:
    path = self.tmp / "cn.txt"
    path.write_text("论文查重程序", encoding="utf-8")
    with mock.patch("plagiarism.reader._ENCODINGS", ("ascii",)):
        with self.assertRaises(FileReadError) as ctx:
            read_text(str(path))
    self.assertIn("编码", str(ctx.exception))

用 mock 把编码列表替换成只有 ascii,模拟"三种编码全部失败"。

③ 打开失败 → 包装成 FileReadError / AnswerWriteError

def test_open_failure_is_wrapped(self) -> None:
    path = self.tmp / "x.txt"
    path.write_text("内容", encoding="utf-8")
    with mock.patch("builtins.open", side_effect=OSError("设备不可用")):
        with self.assertRaises(FileReadError) as ctx:
            read_text(str(path))
    self.assertIn("无法读取", str(ctx.exception))

④ 空文本 / 纯标点 → EmptyTextError

def test_punctuation_only_file_returns_error(self) -> None:
    punct = self.tmp / "punct.txt"
    punct.write_text("。。。!!!", encoding="utf-8")
    code, _ = self._run([str(self.original), str(punct), str(self.answer)])
    self.assertEqual(code, EXIT_RUNTIME_ERROR)

⑤ 参数个数错误 → 退出码 2

def test_argument_count_four(self) -> None:
    code, _ = self._run([str(self.original), str(self.copied), str(self.answer), "extra"])
    self.assertEqual(code, EXIT_USAGE_ERROR)

⑥ 兜底:无法归类的系统错误

def test_unexpected_oserror_returns_runtime_error(self) -> None:
    buffer = io.StringIO()
    with mock.patch("main.compute_similarity", side_effect=OSError("磁盘故障")):
        with contextlib.redirect_stderr(buffer):
            code = cli.main(["a.txt", "b.txt", "c.txt"])
    self.assertEqual(code, cli.EXIT_RUNTIME_ERROR)
    self.assertIn("磁盘故障", buffer.getvalue())

还有一条容易忽略的边界:答案文件不能指向输入文件,否则一次误操作就会把原文覆盖掉。
当前实现通过"任何失败都保持答案文件原样"来规避(见测试 ①)。


六、实际运行结果

用老师下发的官方样例(测试文本.zip)跑,命令行方式与作业要求一致:

python main.py sample/official/orig.txt sample/official/orig_0.8_add.txt ans.txt

批量跑完整目录:

python tools/batch_check.py sample/official/orig.txt sample/official/

官方样例的重复率

样例 改动类型 相似度
orig_0.8_add.txt 逐字插入(增) 0.7951
orig_0.8_del.txt 删除约 20%(删) 0.7976
orig_0.8_dis_1.txt 乱序 · 轻 0.9492
orig_0.8_dis_10.txt 乱序 · 中 0.8355
orig_0.8_dis_15.txt 乱序 · 重(词级) 0.6373

add / del 恰好落在 0.80 附近,与文件名里的 "0.8" 一致;乱序程度递增时分数单调
下降,重度乱序仍能给出 0.64 的明确"抄袭"信号,而不会被误判成无关文本。

自造样例的重复率python tools/run_samples.py):

样例 说明 相似度
orig_1.0.txt 与原文完全一致 1.0000
orig_1.0_html.txt 正文相同但夹带 HTML 样板 0.9955
orig_0.8_syn.txt 术语替换为近义词(改) 0.9588
orig_0.8_dis_1.txt 段内句子局部旋转(乱序) 0.9860
orig_0.8_dis_2.txt 全部句子整体打乱(乱序) 0.9640
orig_0.8_dis_3.txt 句子内部分句顺序颠倒(乱序) 0.9501
orig_0.8_add.txt 插入约 20% 新句子(增) 0.9355
orig_0.8_del.txt 删除约 20% 句子(删) 0.9215
orig_0.8_del_gbk.txt 同上,但文件是 GB18030 编码 0.9215
orig_0.0.txt 完全不同主题 0.1066
orig_empty.txt 空文件 报错退出,不给分数
orig_punct_only.txt 纯标点 报错退出,不给分数

评测约束自查

约束 实测
5 秒内给出答案 官方样例约 20 ms;55 万字符极端输入 1.13 s
内存不超过 2048 MB 极端输入峰值 39.4 MiB
不连接网络 零第三方依赖,全项目只 import 标准库
不读写其他文件 全项目只有 reader / writer 两处 open(),只碰命令行给出的三个路径
不异常退出 所有可预期错误都转成退出码 1 / 2

七、代码质量分析

作业要求"代码经过 Code Quality Analysis 工具分析并消除所有的警告"。Python 生态里
承担这个角色的是 pylint(按 PEP 8 与常见缺陷模式打分,满分 10.00)。

python tools/code_quality.py
# 或
python -m pylint main.py plagiarism tests tools

结果:10.00 / 10,0 条警告

做法是先修代码,再谈配置

  • 补全缺失的 docstring、拆分过长的函数、消除重复的绘图样板、修正过长的行、
    清理未使用的变量与导入、把裸 Exception 换成具体异常;
  • 抽出 tools/plotting.py 统一处理 matplotlib 的导入与中文字体,消除两个脚本间的
    重复代码;
  • .pylintrc 里只保留两处有理由的局部豁免:测试函数名已完整表达断言意图,
    不重复写 docstring;unittestsetUp / tearDown 用不了 with

八、签入记录

Github 仓库:https://github.com/xxyyll0323/3224004083

按"每完成一个功能、编译通过就提交一次"的要求管理代码,提交按功能阶段划分:

提交 说明
feat: 基本功能 —— 命令行论文查重 最小可用版本:三参数、读写文件、二元组相似度、两位小数
feat: 扩展功能 —— 多阶加权、多编码识别、噪声剥离与降阶保护 补齐 1~4 阶加权、UTF-8/GB18030/UTF-16、HTML 剥离、超长降阶
perf: 规范化改用查表 + str.translate,提速约 3 倍 用 cProfile 定位瓶颈后做的性能改进
feat: 批量查重工具(扩展功能) tools/batch_check.py,一次算完整个样例目录并导出 CSV
test: 72 个单元测试 + 自造样例集 + 覆盖率报告 四类用例 + 12 份自造样例 + 覆盖率图
refactor: 相似度度量由 Dice 统一为余弦相似度,并补充选型对照实验 换度量,附 Dice / Jaccard / 余弦的对照数据
chore: 引入 pylint 做代码质量分析,把所有警告清零(10.00/10) 补 docstring、拆函数、消重复,警告从 149 降到 0
docs: 博客随笔、PSP 表格、使用指南与项目说明 交付文档
docs: 恢复被取代的两份旧文档到 docs/archive/ 留档,避免误以为删除
fix: 用老师下发的官方样例跑通并调优算法(增/删 ≈ 0.80) 按真实样例调规范化与权重
chore: 公开仓库前清理,移除老师下发的测试文本与《活着》全文 官方样例含出版小说全文,不放进公开仓库
docs: 博客随笔整理成可直接上交的最终版 PSP 实际耗时回填、图片链接整理
docs: 补强博客第五、六章(单元测试 12' 与异常处理 6') 补被测函数清单、测试数据构造手法、覆盖率数字纠错
docs: 博客按参考范例重写为九章结构,PSP 表格回填实际耗时 按评分细则重排章节,并核对全部引用的测试用例名与代码

可以看出来,提交不是一次性堆上去的:基本功能 → 扩展功能 → 性能 → 测试 → 代码质量
→ 文档 → 按真实样例调优
,每一步都能在仓库里找到对应记录。最新的完整提交记录见仓库
的提交历史。


九、总结

这次的收获

  1. 算法选型要用数据说话。同一个 n-gram 特征,换成 Dice / Jaccard / 余弦会差多少?
    我把三种度量放在同一批样例上跑了对照(差异都在 0.03 以内),才决定用余弦。
    权重也是在官方样例上实测调出来的,不是凭感觉。
  2. 不要凭直觉优化。我原以为瓶颈在余弦的点积上,cProfile 测出来却是"文本规范化"和
    "n-gram 频次统计"。改成查表 + str.translate 后规范化提速 1.8×。
  3. 测量方法本身也会出错。那个"关闭分代 GC 提速 2.2×"的结论是假的——先测完 A 再测 B,
    把机器负载漂移当成了算法差异。改成交替测量后差距只有 1.06×,方案被否决。
  4. 覆盖率补到 100% 靠的是覆盖分支,不是堆用例。为了补上"进程入口"和"编码全部失败"
    这两条路径,专门加了子进程启动测试和 mock 替换编码列表的测试。

不足与改进计划

  1. 相似度是字面相似度,不理解语义,所以"星期天 ↔ 周天"这类同义改写只能靠字符重合
    来识别(实测 0.94,靠的是"天""晴"这些字)。要真正解决得上语义向量,但那会引入第三方
    模型,违反"不联网 + 零依赖"的约束。
  2. 权重只在官方那一份样例上调过。更严谨的做法是构造几十对无关论文,看分数分布的上界,
    用它来定阈值。
  3. 没有标注数据集,因此给出的分数是相对指标,不能直接当成"抄袭比例"来解读。

perf_chart

微信图片_20260915231800_370_59

normalize_compare

posted @ 2026-09-15 23:20  言午住在林子里  阅读(8)  评论(0)    收藏  举报