第一次个人编程作业

https://github.com/yyxyxx/youyong

第一次个人编程作业

这个作业属于哪个课程 计科24级78班 - 广东工业大学 - 班级博客
这个作业要求在哪里 个人项目 - 作业 - 计科24级78班
这个作业的目标 独立完成一次完整的软件工程个人项目,并完成 GitHub 管理、PSP、性能分析、单元测试和覆盖率分析

语言 / 环境:Python 3.8+,入口文件为 main.py,运行方式:

python main.py [原文文件] [抄袭版论文的文件] [答案文件]

程序从命令行读取原文与抄袭版论文的绝对路径,计算两篇文本的重复率,并把结果以保留两位小数的浮点型写入答案文件。


一、准备

1. 我的 GitHub 项目链接

https://github.com/yyxyxx/youyong

仓库根目录下为学号文件夹 3124004447。主要文件如下:

3124004447/
├── main.py
├── requirements.txt
├── README.md
├── PSP.md
├── tests/
│   └── test_main.py
└── docs/
    ├── DESIGN.md
    ├── PERFORMANCE.md
    ├── TEST_REPORT.md
    └── EXCEPTIONS.md

本项目使用 Python 标准库完成,requirements.txt 中没有第三方运行依赖。

2. PSP 表格

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 20 10
· Estimate 估计任务规模 20 10
Development 开发 305 250
· Analysis 需求分析(包括学习 HTML 解析、n-gram、余弦相似度) 40 55
· Design Spec 生成设计文档 30 25
· Design Review 设计复审 20 15
· Coding Standard 代码规范 15 10
· Design 具体设计 30 25
· Coding 具体编码 90 70
· Code Review 代码复审 30 20
· Test 测试(自我测试、修改代码、提交修改) 50 30
Reporting 报告 75 60
· Test Report 测试报告 40 35
· Size Measurement 计算工作量 15 10
· Postmortem & Process Improvement Plan 事后总结和改进计划 20 15
合计 总计 400 320

3. 需求分析

  • 程序通过命令行接收 原文文件路径、抄袭版论文文件路径、答案输出文件路径 三个参数。
  • 程序应能读取普通文本和课程样例中的 HTML 文件,并提取其中的论文正文。
  • 程序需对文本进行规范化,过滤标点和空白,降低格式差异对结果的影响。
  • 程序需计算两篇文本的重复率,并将结果保留两位小数后写入答案文件。
  • 非功能要求:单次运行小于 5 秒,内存小于 2048 MB,不联网,不读写参数以外的业务文件。
  • 文件不存在、文件无法读取或解码、答案文件无法写入、参数数量错误等情况需要统一处理,不能产生未捕获异常。

本项目功能划分为:

  • 基础功能:命令行参数解析、文件读写、文本规范化、相似度计算、两位小数输出。
  • 扩展功能:GitHub blob HTML 正文提取、字符级 1/2/3-gram 加权余弦、自定义异常体系、29 个自动化测试、有效分支覆盖率约 99%、性能分析。

4. 代码设计

项目入口和核心逻辑集中在 main.py,主要组成部分如下:

  • PlagiarismError:业务异常基类。
  • ArgumentError:参数数量错误或答案路径与输入路径相同。
  • InputFileError:文件不存在、读取失败、编码失败或 HTML 解析失败。
  • OutputFileError:答案文件无法写入。
  • DocumentHTMLParser:从普通 HTML 或 GitHub blob 页面提取正文。
  • looks_like_html:判断文本是否为 HTML。
  • extract_document_text:返回普通文本或 HTML 中的论文正文。
  • normalize_text:统一字符宽度、英文大小写,并移除标点和空白。
  • iter_ngrams:生成字符级 1-gram、2-gram、3-gram。
  • cosine_similarity:计算两个词频向量的余弦相似度。
  • calculate_similarity:计算最终加权重复率。
  • read_textwrite_score:负责文件输入输出。
  • parse_argumentsmain:负责命令行参数校验和整体流程调度。

二、代码分析

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

(1)模块组织与函数结构

程序采用“一个入口文件 + 若干职责单一的函数/类”的结构:

  • HTML 处理层DocumentHTMLParser 优先提取 GitHub blob 中的代码行,同时支持普通 HTML 可见文本,并跳过 scriptstylenoscript
  • 文本处理层normalize_text 执行 Unicode NFKC 规范化、英文大小写统一和字符过滤。
  • 特征提取层iter_ngrams 生成 1-gram、2-gram、3-gram。
  • 相似度计算层cosine_similarity 根据词频向量计算余弦相似度,calculate_similarity 完成加权。
  • 输入输出层read_text 读取 UTF-8/GB18030,write_score 输出两位小数。
  • 入口控制层parse_arguments 校验参数,main 负责执行全流程和统一异常处理。

核心调用关系如下:

main
→ parse_arguments
→ read_text
→ calculate_similarity
  → extract_document_text
  → normalize_text
  → iter_ngrams
  → cosine_similarity
→ write_score

(2)关键函数流程图

flowchart TD A[接收三个命令行参数] --> B{参数是否合法} B -- 否 --> C[输出参数错误并返回 2] B -- 是 --> D[读取原文和抄袭版论文] D --> E{是否为 HTML} E -- 是 --> F[提取 GitHub 代码行或可见正文] E -- 否 --> G[保留原始文本] F --> H[Unicode 规范化并过滤字符] G --> H H --> I[统计 1/2/3-gram 词频] I --> J[分别计算余弦相似度] J --> K[按权重加权] K --> L[保留两位小数写入答案文件]

如果不支持 Mermaid,可用下面文字描述代替:

main → 校验参数 → 读取原文 → 读取抄袭版 → 判断 HTML
→ 提取正文 → 规范化 → 统计 1/2/3-gram
→ 计算三个余弦相似度 → 加权 → 写入答案文件

(3)算法的关键要素

程序使用字符级 1-gram、2-gram、3-gram 词频向量的加权余弦相似度。

单个 n-gram 的余弦相似度:

cos(A, B) = (A · B) / (|A| × |B|)

最终重复率:

score = 0.35 × cos(1-gram)
      + 0.45 × cos(2-gram)
      + 0.20 × cos(3-gram)

核心代码:

NGRAM_WEIGHTS = ((1, 0.35), (2, 0.45), (3, 0.20))


def calculate_similarity(original_text: str, suspect_text: str) -> float:
    original = normalize_text(extract_document_text(original_text))
    suspect = normalize_text(extract_document_text(suspect_text))

    if not original and not suspect:
        return 1.0
    if not original or not suspect:
        return 0.0

    weighted_score = 0.0
    for ngram_size, weight in NGRAM_WEIGHTS:
        original_terms = Counter(iter_ngrams(original, ngram_size))
        suspect_terms = Counter(iter_ngrams(suspect, ngram_size))
        weighted_score += weight * cosine_similarity(original_terms, suspect_terms)

    return max(0.0, min(1.0, weighted_score))

(4)算法的技术特征

  • 对增删较稳健:局部插入或删除少量内容时,大部分 n-gram 仍然相同,不会导致结果剧烈下降。
  • 兼顾局部顺序:1-gram 对整体词频敏感,2-gram 和 3-gram 能反映连续文本特征。
  • 不需要中文分词:按字符切分,避免词典差异、分词模式差异带来的不稳定结果。
  • 能够处理 HTML 样例:先从 GitHub blob 的代码行提取论文正文,再参与计算。
  • 计算复杂度接近线性:每个 n-gram 只扫描一次,适合约一万字符的课程样例。

独到之处:

  1. 优先识别 GitHub blob 的 blob-codejs-file-lineid="LC..." 代码行,避免导航栏、脚本和样式干扰结果。
  2. 对普通 HTML 自动回退到可见文本,普通文本则完全不经过 HTML 解析。
  3. 使用 1/2/3-gram 加权组合,而不是只看单一字符频率或字符串匹配。
  4. 不依赖任何第三方运行库,降低评测环境安装失败的风险。
  5. 所有可预期错误统一转换为自定义异常,避免评测时异常退出。

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

(1)性能改进所花费的时间

性能分析、算法替换和复测共约 45 分钟

分析工具:

  • Python 标准库 cProfile
  • time.perf_counter
  • tracemalloc

(2)性能分析图展示

性能分析图

(3)程序中消耗最大的函数

orig.txtorig_0.8_add.txt 执行 cProfile,结果如下:

182568 function calls in 0.109 seconds

main.calculate_similarity       0.069 秒
collections.Counter 统计       0.029 秒
main.cosine_similarity         0.020 秒
main.normalize_text            0.020 秒
main.iter_ngrams               0.014 秒

消耗最大的部分是:

  1. calculate_similarity:相似度计算的总入口。
  2. Counter:统计 1/2/3-gram 的出现次数。
  3. cosine_similarity:计算点积和向量模长。
  4. normalize_text:遍历全文执行规范化。
  5. iter_ngrams:生成连续字符片段。

(4)改进思路与实现

初版问题:

初版使用最长公共子序列,时间复杂度为 O(m × n)。原文约 9,534 个字符,添加版本约 11,297 个字符,单次需要比较超过 1 亿次,不适合 5 秒限制。

改进方案:

  1. 将 LCS 替换为字符级 1/2/3-gram 加权余弦,时间复杂度接近线性。
  2. 使用 Counter 统计 n-gram,避免手写双重循环。
  3. HTML 文件先抽取正文,减少脚本、样式和导航内容。
  4. 对空文本提前返回,避免无意义计算。
  5. 只在两个词频表的共有项上计算点积,降低无效运算。

(5)改进后的效果对比

指标 初版 LCS 改进后 n-gram
时间复杂度 O(m × n) 接近 O(n)
一万字符级别 存在超时风险 可稳定在 1 秒内
第三方依赖
HTML 样例 未专门处理 自动提取正文
单个真实样例耗时 未达到要求 约 0.16~0.19 秒

对约 9,700 个字符的 orig_0.8_dis_15.txt 使用 tracemalloc 测得:

峰值内存约 2.32 MB

远低于 2048 MB 限制。

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

使用 Python 标准库 unittest 编写单元测试,共 29 个测试用例,全部通过。

python -m unittest discover -s tests -v

(1)单元测试代码片段展示

def test_assignment_sample_has_medium_similarity(self) -> None:
    original = "今天是星期天,天气晴,今天晚上我要去看电影。"
    suspect = "今天是周天,天气晴朗,我晚上要去看电影。"
    score = main.calculate_similarity(original, suspect)
    self.assertGreater(score, 0.60)
    self.assertLess(score, 0.80)


def test_github_blob_html_prefers_code_lines(self) -> None:
    html = (
        "<!DOCTYPE html><html><body><nav>GitHub 导航</nav>"
        '<table><tr><td id="LC1" class="blob-code js-file-line">第一行</td>'
        '<td id="LC2" class="blob-code js-file-line">第二行</td></tr></table>'
        "<script>无关脚本</script></body></html>"
    )
    self.assertEqual(main.extract_document_text(html), "第一行\n第二行")


def test_main_writes_score_with_two_decimals(self) -> None:
    original = self._write_text("original.txt", "今天是星期天,天气晴,今天晚上我要去看电影。")
    suspect = self._write_text("suspect.txt", "今天是周天,天气晴朗,我晚上要去看电影。")
    answer = self.directory / "answer.txt"

    exit_code = main.main(["main.py", str(original), str(suspect), str(answer)])

    self.assertEqual(exit_code, 0)
    self.assertEqual(answer.read_text(encoding="utf-8"), "0.67\n")

(2)测试的函数与接口

测试覆盖了:

  • calculate_similarity
  • normalize_text
  • iter_ngrams
  • cosine_similarity
  • looks_like_html
  • extract_document_text
  • read_text
  • write_score
  • parse_arguments
  • main

(3)测试数据的构造思路

采用“白盒 + 边界值 + 等价类划分”的方式:

  • 边界值:两个空文本、单侧为空、完全相同、完全不同。
  • 文本修改:插入字符、删除字符、替换字符。
  • 格式差异:英文大小写、全角/半角、空格和标点差异。
  • HTML 场景:GitHub blob 代码行、普通 HTML、嵌套标签、空代码行、脚本和样式过滤。
  • 异常场景:文件不存在、无法解码、读取失败、写入失败、参数数量错误。
  • 编码场景:UTF-8 和 GB18030。
  • 端到端场景:通过子进程运行 main.py,检查答案文件内容。

(4)单元测试覆盖率展示

使用 coverage.py 的分支覆盖率统计:

模块 语句数 未覆盖语句 分支数 部分分支 覆盖率
main.py 158 1 52 1 99%
tests/test_main.py 173 1 2 1 99%
整体 331 2 54 2 99%

主程序未覆盖的是 if __name__ == "__main__" 的子进程入口行,该入口已经通过命令行和子进程测试执行成功。

覆盖率截图

(5)真实样例运行结果

运行方式:

python main.py 测试文本/orig.txt 测试文本/orig_0.8_add.txt 测试文本/ans.txt
样例 输出重复率 单个样例耗时
orig_0.8_add.txt 0.88 约 0.17 秒
orig_0.8_del.txt 0.88 约 0.19 秒
orig_0.8_dis_1.txt 0.97 约 0.19 秒
orig_0.8_dis_10.txt 0.90 约 0.18 秒
orig_0.8_dis_15.txt 0.76 约 0.18 秒

其中 orig_0.8_del.txtorig_0.8_dis_*.txt 是包含 GitHub blob 页面的 HTML 文件,程序会先提取代码行中的真实论文正文,再计算重复率。

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

程序以 PlagiarismError 为基类,所有可预期错误都会在 main 中统一捕获,并转换为受控退出码。

异常类 设计目标 触发场景 对应测试
ArgumentError 参数不合法时提示用法,返回 2 参数数量不等于 3 个 test_main_rejects_wrong_argument_count
ArgumentError 防止覆盖输入文件,返回 2 答案路径等于输入路径 test_main_rejects_answer_path_equal_to_input
InputFileError 输入文件不存在时输出错误,返回 3 文件不存在或不是普通文件 test_main_reports_missing_input
InputFileError 编码异常时安全退出,返回 3 文件无法按 UTF-8 或 GB18030 解码 test_main_reports_undecodable_input
InputFileError 读取失败时统一包装异常,返回 3 操作系统拒绝读取 test_read_text_reports_os_error
InputFileError HTML 解析失败时安全退出,返回 3 HTML 解析器抛出异常 test_html_parse_error_is_wrapped
OutputFileError 答案无法写入时输出错误,返回 3 答案路径是目录或不可写 test_main_reports_output_write_error

(1)命令行参数异常(ArgumentError

设计目标:当参数数量不是 3 个,或者答案路径与输入路径相同时,禁止继续执行并返回明确错误。

def test_main_rejects_wrong_argument_count(self) -> None:
    error_output = io.StringIO()
    with redirect_stderr(error_output):
        exit_code = main.main(["main.py", "only-one-path"])

    self.assertEqual(exit_code, 2)
    self.assertIn("用法", error_output.getvalue())

对应场景:执行时漏传原文、抄袭版或答案文件路径。

(2)文件读取异常(InputFileError

设计目标:文件不存在、无法读取或编码不合法时,不输出 Python 堆栈,而是输出可读错误并返回退出码 3。

def test_main_reports_missing_input(self) -> None:
    original = self.directory / "missing.txt"
    suspect = self._write_text("suspect.txt", "内容")
    answer = self.directory / "answer.txt"

    with redirect_stderr(io.StringIO()):
        exit_code = main.main(["main.py", str(original), str(suspect), str(answer)])

    self.assertEqual(exit_code, 3)
    self.assertFalse(answer.exists())

对应场景:文件被移动、重命名、路径拼写错误或文件编码不正确。

(3)HTML 解析异常(InputFileError

设计目标:HTML 解析器发生异常时,不让异常直接传播到评测程序。

def test_html_parse_error_is_wrapped(self) -> None:
    html = "<!DOCTYPE html><html><body>正文</body></html>"
    with mock.patch.object(
        main.DocumentHTMLParser,
        "feed",
        side_effect=ValueError("解析失败"),
    ):
        with self.assertRaises(main.InputFileError):
            main.extract_document_text(html)

对应场景:输入文件表面上是 HTML,但内容结构异常或解析器运行失败。

(4)答案写入异常(OutputFileError

设计目标:答案路径是目录、无权限或父目录不存在时,输出错误并返回退出码 3。

def test_main_reports_output_write_error(self) -> None:
    original = self._write_text("original.txt", "内容")
    suspect = self._write_text("suspect.txt", "内容")
    answer_directory = self.directory / "answer"
    answer_directory.mkdir()

    with redirect_stderr(io.StringIO()):
        exit_code = main.main(
            ["main.py", str(original), str(suspect), str(answer_directory)]
        )

    self.assertEqual(exit_code, 3)

对应场景:用户把答案路径写成了已经存在的目录。


三、开发过程与 GitHub 提交记录

项目按照功能推进逐步提交:

880dc0d Initial commit
6da97f0 feat: 添加论文查重基础实现
abae5ba perf: 使用滚动数组优化LCS内存
008ad06 docs: 补充设计测试与异常说明
3288280 feat: 支持HTML正文并改用n-gram查重
1e1fb1f docs: 更新真实样例性能与覆盖率

其中 3288280 是根据真实课程样例完成的关键修改:

  • 增加 GitHub blob HTML 正文提取。
  • 将 LCS 动态规划替换为线性 n-gram 余弦相似度。
  • 单元测试增加到 29 个。
  • 主程序分支覆盖率提升到 99%。

四、代码质量分析

本项目从以下方面保证代码质量:

  • 函数职责单一,HTML 提取、文本处理、相似度计算和文件读写相互分离。
  • 核心函数均有类型标注和中文注释。
  • 文件和参数异常统一处理。
  • 不尝试连接网络。
  • 不读取原文和抄袭版以外的业务文件。
  • 只写入答案路径。
  • 使用 ruff 检查代码,结果为 0 个警告。
  • 使用 unittest 编写 29 个自动化测试。

代码质量检查命令:

python -m compileall .
ruff check .
ruff format --check .
python -m unittest discover -s tests -v

当前结果:

compileall:通过
ruff:0 个警告
单元测试:29 / 29 通过
主程序分支覆盖率:99%
真实样例单次耗时:约 0.16~0.19 秒
峰值内存:约 2.32 MB

五、附录:运行与测试

1. 运行程序

进入学号目录:

cd 3124004447

执行:

python main.py [原文文件] [抄袭版论文的文件] [答案文件]

示例:

python main.py C:\tests\orig.txt C:\tests\orig_0.8_add.txt C:\tests\ans.txt

答案文件中输出保留两位小数的浮点型重复率。

2. 运行单元测试

python -m unittest discover -s tests -v

3. 生成覆盖率报告

安装临时分析工具:

pip install coverage

执行:

python -m coverage run --branch -m unittest discover -s tests
python -m coverage report -m main.py tests/test_main.py
python -m coverage html -d htmlcov

HTML 报告位置:

htmlcov/index.html

4. 运行性能分析

python -m cProfile -s cumulative main.py ..\测试文本\orig.txt ..\测试文本\orig_0.8_add.txt ..\测试文本\ans.txt

posted @ 2026-09-14 21:18  是轩轩呀  阅读(18)  评论(0)    收藏  举报