作业一
GitHub 仓库地址:https://github.com/LS-XH/3124004171
| 这个作业属于哪个课程 | https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS |
|---|---|
| 这个作业要求在哪里 | https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS/homework/15693 |
| 这个作业的目标 | 学习使用github进行项目管理,熟悉项目开发流程和规划 |
PSP 记录(打分点2,7)
估计耗时在编码开始前填写,实际耗时在开发、测试和报告完成后填写,单位均为分钟。
| PSP | Personal Software Process Stages | 估计耗时(min) | 实际耗时(min) |
|---|---|---|---|
| Planning | 计划 | 30 | 20 |
| · Estimate | · 估计这个任务需要多少时间 | 30 | 20 |
| Development | 开发 | 390 | 285 |
| · Analysis | · 需求分析(包括学习新技术) | 40 | 30 |
| · Design Spec | · 生成设计文档 | 30 | 20 |
| · Design Review | ·设计复审 | 20 | 15 |
| · Coding Standard | ·代码规范 | 10 | 10 |
| · Design | ·具体设计 | 40 | 30 |
| · Coding | ·具体编码 | 120 | 75 |
| · Code Review | ·代码复审 | 30 | 25 |
| · Test | ·测试、自测、修改代码 | 100 | 80 |
| Reporting | 报告 | 120 | 95 |
| · Test Report | ·测试报告 | 40 | 30 |
| · Size Measurement | ·计算工作量 | 20 | 15 |
| · Postmortem & Process Improvement Plan | ·事后总结并提出过程改进计划 | 60 | 50 |
| 合计 | 540 | 400 |
计算模块接口的设计与实现(打分点3)
项目没有需要保存状态的对象,因此没有为了形式而设计类,而是采用职责单一的函数式接口。命令行、文件读写和计算逻辑彼此分离,其中 calculate_similarity 是核心计算模块对外提供的接口,不直接访问文件,因而可以独立测试。
核心模块和函数
| 所属文件 | 模块或函数 | 输入 | 输出 | 主要功能 | 直接调用或依赖 |
|---|---|---|---|---|---|
main.py |
命令行入口模块 | 命令行中的三个绝对路径 | 进程退出码 | 连接用户输入、文件接口和计算模块,是程序的执行入口 | main() |
main.py |
main(argv=None) |
参数字符串序列;省略时读取 sys.argv |
int:0 成功、1 运行错误、2 参数错误 |
检查参数数量及绝对路径,调用业务入口,统一捕获可预期异常并向标准错误输出提示 | run() |
main.py |
run(original_path, suspect_path, answer_path) |
三个 Path 对象 |
float 重复率 |
读取两篇论文,调用纯计算接口,并以两位小数写入指定答案文件 | read_text()、calculate_similarity()、Path.write_text() |
plagiarism_checker.py |
相似度计算模块 | 两段文本或文本路径 | 文本、特征集合或相似度 | 集中实现编码兼容、文本预处理、特征生成和相似度公式 | 下列计算函数 |
plagiarism_checker.py |
read_text(path) |
输入文件的 Path |
str 文本 |
优先按 UTF-8(含 BOM)读取,解码失败后尝试 GB18030;无法解码时抛出明确异常 | Path.read_text() |
plagiarism_checker.py |
normalize_text(text) |
原始字符串 | 规范化字符串 | 执行 NFKC 全半角统一、英文小写化,并移除空白和标点,降低排版差异干扰 | unicodedata.normalize()、正则表达式 |
plagiarism_checker.py |
tokenize(text) |
规范化文本 | list[str] |
提取英文单词和中文二元子词,作为可单独使用和测试的辅助分词接口 | _ngrams() |
plagiarism_checker.py |
_ngrams(text, size=2) |
文本和 n-gram 长度 | Iterable[str] |
以生成器形式产生连续字符二元组;单字符文本自身作为一个特征,避免额外列表内存 | 无 |
plagiarism_checker.py |
_cosine_similarity(left, right) |
两个特征频次 Counter |
[0, 1] 内的 float |
计算稀疏词频向量的余弦相似度,衡量两篇文本的特征频率分布 | math.sqrt()、Counter.get() |
plagiarism_checker.py |
_dice_similarity(left, right) |
两个特征频次 Counter |
[0, 1] 内的 float |
计算两个多重集合的 Sørensen-Dice 系数,衡量实际重合特征数量 | Counter 交集运算 |
plagiarism_checker.py |
calculate_similarity(original, suspect) |
原文和疑似抄袭文本 | [0, 1] 内的 float |
规范化两篇文本,只生成一次特征计数,融合 0.65 × cosine + 0.35 × dice,并处理空文本边界 |
normalize_text()、_ngrams()、_cosine_similarity()、_dice_similarity() |
以下划线开头的函数属于模块内部实现细节;read_text、normalize_text、tokenize 和 calculate_similarity 可以作为公开接口导入。实际命令行计算的主路径不会调用 tokenize,而是让两项相似度直接复用同一对 _ngrams Counter,以减少约一半的特征生成开销。
性能分析和测试辅助模块
| 所属文件 | 模块或函数 | 功能 |
|---|---|---|
profile/profile_benchmark.py |
build_documents(repetitions=12000) |
构造包含局部增删改的大型中文原文和疑似文本,为性能测试提供可重复输入 |
profile/profile_benchmark.py |
benchmark() |
调用 calculate_similarity() 完成一次大文本基准计算并返回结果 |
profile/generate_profile_report.py |
cumulative_time(stats, function_name) |
从 pstats.Stats 中提取指定函数的累计耗时 |
profile/generate_profile_report.py |
main() |
读取优化前后的 .prof 数据,计算耗时下降比例,并生成 TXT 与 SVG 性能报告 |
tests/test_main.py |
文件接口测试模块 | 测试两位小数输出、正确参数、参数不足、相对路径、文件不存在和输出失败等分支 |
tests/test_plagiarism_checker.py |
核心算法测试模块 | 测试文本规范化、特征生成、数学公式、字符编码、空文本和相似度边界 |
调用关系
下面的图只表示函数之间的静态调用关系,不表示程序处理步骤:
性能分析与改进(打分点4)
性能分析与改进阶段实际耗时 35 分钟。使用 Python 自带的 cProfile 对合计约 61.2 万字符的两篇构造文本进行分析,基准代码为 profile/profile_benchmark.py。首个版本的热点是 Counter 构造及 _ngrams 生成器:余弦和 Dice 分别生成特征,共建立四份 Counter。
优化前 cProfile 按累计耗时排序的核心函数如下:
| 函数 | 调用次数 | 函数自身耗时 | 累计耗时 | 分析结论 |
|---|---|---|---|---|
calculate_similarity() |
1 | 0.006 s | 0.311 s | 整个核心接口累计耗时最大,是性能改进的主要对象 |
_ngrams 生成器表达式 |
1,056,000 | 0.139 s | 0.139 s | 内部实际计算热点,说明特征被重复生成 |
tokenize() |
2 | 0.000 s | 0.116 s | 累计时间主要来自其内部生成、扩展和计数操作 |
normalize_text() |
2 | 0.001 s | 0.022 s | 文本规范化耗时较小,不是首要瓶颈 |
改进后,两种指标复用原文和疑似文本各一份二元组 Counter,生成器调用次数由约 105.6 万次降至 52.8 万次。核心函数累计耗时降至 0.1692 秒,下降 45.6%,相似度结果仍为 0.6823。性能改进与复测约耗时 35 分钟。
| 性能指标 | 实测结果 |
|---|---|
| 原文字符数 | 300,000 |
| 疑似文本字符数 | 312,000 |
| 优化前核心函数累计耗时 | 0.3110 s |
| 优化后核心函数累计耗时 | 0.1692 s |
| 耗时下降比例 | 45.6% |
tracemalloc 测得峰值额外内存 |
5.30 MiB |
| 优化前后相似度 | 均为 0.6823 |
时间和内存都远低于评测限制的 5 秒与 2048 MB。字符二元组保留局部顺序,余弦与 Dice 的加权同时兼顾频率分布和实际重合量;完全相同、完全不同、局部增删改等自动化测试均符合预期,有效课程 add 样例输出 0.84。由于其余四份课程样例实际为 HTML 页面,项目不使用这些无效文件虚构准确率结论。

性能分析可以使用以下命令复现:
python -m cProfile -o profile/before.prof profile/profile_benchmark.py
# 切换到优化后的代码,再生成 after.prof
python -m cProfile -o profile/after.prof profile/profile_benchmark.py
python profile/generate_profile_report.py
生成脚本从 .prof 数据自动输出 profile/profile_report.txt 和 profile/performance_comparison.svg。
单元测试与覆盖率(打分点5)
采用等价类、边界值和白盒分支覆盖设计了 21 个自动化测试,包括完全相同、完全不同、局部增删改、对称性、双方空文本、单方空文本、纯标点、全半角与大小写、单字符、多重特征、UTF-8 BOM、GB18030、未知编码、文件不存在、参数数量错误、相对路径和输出目录不存在等情况。
测试对象和数据构造思路如下:
| 被测试函数或模块 | 测试数据构造方法 | 主要验证内容 |
|---|---|---|
calculate_similarity() |
构造完全相同、完全不同、局部替换和前后顺序互换的文本 | 结果边界、局部修改敏感性和算法对称性 |
normalize_text() |
混合全角/半角、大小写、空格和中英文标点 | 与内容无关的格式差异被正确消除 |
_ngrams()、tokenize() |
使用空串、单字符、三字符及中英文混合内容 | 循环边界、短文本分支和特征顺序 |
_cosine_similarity()、_dice_similarity() |
手工构造空 Counter、单边为空、完全重合及重复特征 | 数学公式及零向量分支正确 |
read_text() |
临时生成 UTF-8 BOM、GB18030、非法字节和不存在的文件 | 编码回退与异常分支正确 |
main()、run() |
使用临时绝对路径、错误参数数量、相对路径和无效输出目录 | 文件接口、退出码、两位小数格式和错误提示 |
代表性的计算模块测试如下:
def test_small_edit_retains_similarity() -> None:
original = "今天是星期天,天气晴,今天晚上我要去看电影。"
suspect = "今天是周天,天气晴朗,我晚上要去看电影。"
assert 0.5 < calculate_similarity(original, suspect) < 1.0
该用例直接测试纯计算接口,通过只替换“星期天/周天”、增加“晴朗”中的字符和删除一个“今天”,验证少量增删改后重复率应下降但仍明显大于完全不同文本。数学边界另用可人工计算期望值的 Counter 验证:
def test_dice_handles_multisets_and_empty_vectors() -> None:
assert _dice_similarity(Counter(), Counter()) == 1.0
assert _dice_similarity(Counter({"ab": 2}), Counter()) == 0.0
assert _dice_similarity(Counter({"ab": 2}), Counter({"ab": 1})) == pytest.approx(
2 / 3
)
执行以下命令进行测试和质量检查:
python -m pytest
python -m ruff check .
python -m ruff format --check .
测试结果为 21 passed,语句与分支综合覆盖率为 96.43%,超过配置中要求的 95%;Ruff 静态分析和格式检查均为零警告。

处理说明(打分点6)
异常处理的目标是在输入不合法时明确失败、返回非零退出码,并且不留下伪造的答案文件。
| 异常类型 | 设计目标 | 对应错误场景 | 单元测试样例 |
|---|---|---|---|
| 参数错误 | 在访问文件前拒绝无效调用,打印标准用法并返回退出码 2 | 参数不足、多余或任一路径不是绝对路径 | test_main_rejects_wrong_argument_count、test_main_rejects_relative_paths |
| 输入文件 I/O 错误 | 不发生异常退出,不生成误导性的答案文件,返回退出码 1 | 文件不存在、无读取权限或把目录作为输入文件 | test_main_handles_missing_input_file |
| 字符编码错误 | UTF-8 失败后兼容 GB18030,两种编码均失败才报告明确错误 | 输入包含两种编码都无法解析的非法字节 | test_read_text_rejects_unknown_encoding |
| 输出文件 I/O 错误 | 计算后若答案无法落盘,给出错误信息并返回退出码 1 | 输出目录不存在或文件不可写 | test_main_handles_unwritable_output_location |
| 空正文边界 | 对合法但没有正文的输入给出确定结果,不发生除零错误 | 双方为空、单方为空或文本仅含标点 | test_empty_content_boundaries |
下面为每类异常或边界情况各选取的实际测试代码。
参数错误:
def test_main_rejects_relative_paths(capsys) -> None:
assert main(["original.txt", "suspect.txt", "answer.txt"]) == 2
assert "绝对路径" in capsys.readouterr().err
输入文件不存在:
def test_main_handles_missing_input_file(tmp_path: Path, capsys) -> None:
missing = tmp_path / "missing.txt"
answer = tmp_path / "answer.txt"
assert main([str(missing), str(missing), str(answer)]) == 1
assert "错误" in capsys.readouterr().err
assert not answer.exists()
无法识别字符编码:
def test_read_text_rejects_unknown_encoding(tmp_path) -> None:
invalid_file = tmp_path / "invalid.txt"
invalid_file.write_bytes(b"\xff")
with pytest.raises(ValueError, match="无法识别文件编码"):
read_text(invalid_file)
输出目录不存在:
def test_main_handles_unwritable_output_location(tmp_path: Path, capsys) -> None:
original = tmp_path / "original.txt"
suspect = tmp_path / "suspect.txt"
original.write_text("正文", encoding="utf-8")
suspect.write_text("正文", encoding="utf-8")
invalid_answer = tmp_path / "missing-directory" / "answer.txt"
assert main([str(original), str(suspect), str(invalid_answer)]) == 1
assert "错误" in capsys.readouterr().err
空正文边界使用参数化测试同时覆盖三个分支:
@pytest.mark.parametrize(
("left", "right", "expected"),
[("", "", 1.0), ("", "正文", 0.0), ("!!!", "??", 1.0)],
)
def test_empty_content_boundaries(left: str, right: str, expected: float) -> None:
assert calculate_similarity(left, right) == expected
浙公网安备 33010602011771号