第一次个人编程作业

第一次个人编程作业

这个作业属于哪个课程 https://edu.cnblogs.com/campus/gdgy/Class78-Grade2024-CS
这个作业要求在哪里 https://edu.cnblogs.com/campus/gdgy/Class78-Grade2024-CS/homework/15702
这个作业的目标 实现一个基于命令行文件输入输出的论文查重程序,准确计算原文与改写文本的重复率。

项目仓库3124004242/Plagiarism_Check at main · Masterfred06/3124004242

学号:3124004242

开发语言:Python3

项目版本:v1.0

一、项目简介

本次作业题目为“论文查重”。程序需要从命令行接收三个参数:原文文件绝对路径、抄袭版论文文件绝对路径、答案文件绝对路径。程序读取前两个文件后计算重复率,并将结果以浮点数形式写入答案文件,精确到小数点后两位。

我使用 Python 3 完成该项目。项目入口文件为 main.py,核心计算模块为 plagiarism_checker.py,测试文件位于 tests/test_plagiarism_checker.py。程序运行时只使用 Python 标准库,不依赖第三方库,因此 requirements.txt 中没有额外运行依赖。

运行方式如下:

python main.py <原文文件绝对路径> <抄袭版论文文件绝对路径> <答案文件绝对路径>

例如:

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

二、PSP 表格

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 20 18
· Estimate · 估计这个任务需要多少时间 20 18
Development 开发 220 205
· Analysis · 需求分析(包括学习新技术) 30 25
· Design Spec · 生成设计文档 25 22
· Design Review · 设计复审 15 12
· Coding Standard · 代码规范 10 8
· Design · 具体设计 30 28
· Coding · 具体编码 60 58
· Code Review · 代码复审 20 18
· Test · 测试(自我测试,修改代码,提交修改) 30 34
Reporting 报告 75 68
· Test Report · 测试报告 25 22
· Size Measurement · 计算工作量 15 12
· Postmortem & Process Improvement Plan · 事后总结,并提出过程改进计划 35 34
合计 合计 315 291

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

3.1 代码组织

项目主要分为入口层、计算层和测试层。

文件 作用
main.py 命令行入口,接收参数并调用核心模块
plagiarism_checker.py 核心计算模块,负责文件读取、HTML 正文抽取、文本归一化、相似度计算和答案写入
tests/test_plagiarism_checker.py 单元测试文件,覆盖正常输入、异常输入、HTML 输入和命令行接口
requirements.txt Python 依赖说明,本项目无第三方运行依赖
README.md 项目运行说明和开发说明

入口文件 main.py 尽量保持简单,只负责把命令行参数传给核心模块:

import sys

from plagiarism_checker import run_cli


if __name__ == "__main__":
    raise SystemExit(run_cli(sys.argv[1:]))

这样的组织方式可以让命令行入口和计算逻辑分离,方便单元测试直接测试核心函数,而不需要每次都启动一个新进程。

3.2 主要接口

核心模块中的主要函数如下:

函数 功能
read_text_file(file_path) 读取文本文件,兼容 utf-8-sigutf-8gb18030 等常见编码
looks_like_html(text) 判断输入内容是否可能是 HTML
extract_document_text(raw_text) 从普通文本或 HTML 中抽取论文正文
normalize_text(text) 文本归一化,统一全角半角和大小写,去掉标点、空白等无关字符
iter_ngrams(text, size) 生成字符级 n-gram
cosine_similarity(first_terms, second_terms) 计算两个词频向量的余弦相似度
calculate_similarity(original_text, suspect_text) 计算最终重复率
check_documents(original_path, suspect_path) 读取两个文件并返回查重结果
write_answer(answer_path, score) 将重复率按两位小数写入答案文件
run_cli(arguments) 命令行流程控制和异常处理

3.3 HTML 文本处理

测试文本中存在 HTML 格式的查重文档,其中真实论文内容被包在 GitHub 页面代码行中。如果直接对整份 HTML 做相似度计算,会把导航栏、CSS、JavaScript、页脚等无关内容也算进去,导致结果失真。

因此我使用 Python 标准库 HTMLParser 实现了 DocumentHTMLParser

  • 遇到普通 HTML 时,提取可见文本。
  • 遇到 GitHub blob 页面时,优先提取 id="LC..." 或带有 blob-codejs-file-line 的代码行。
  • 跳过 scriptstylenoscript 标签中的内容。

这样可以让程序既支持普通纯文本,也能处理题目备注中提到的 HTML 格式目标文档。

3.4 查重算法

我没有使用深度学习或复杂语义模型,因为题目明确强调时间和空间限制。最终使用的是字符级 n-gram 频次向量加权余弦相似度。

算法流程如下:

flowchart TD A[读取原文和待查重文件] --> B{是否为 HTML} B -->|是| C[抽取正文文本] B -->|否| D[保留原始文本] C --> E[文本归一化] D --> E E --> F[构造 1-gram/2-gram/3-gram 词频向量] F --> G[分别计算余弦相似度] G --> H[按权重加权求和] H --> I[输出保留两位小数的重复率]

文本归一化后,程序分别计算 1-gram、2-gram、3-gram 的余弦相似度,然后按如下权重加权:

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

选择混合 n-gram 的原因是:

  • 1-gram 对插入、删除、换序更稳定,但区分度较低。
  • 2-gram 能较好反映局部连续文本。
  • 3-gram 对原文顺序更敏感,但在大量插字时会受到较大影响。

因此混合三者比单一 n-gram 更稳健。对于中文论文查重,这种方法不需要分词,也避免了分词词典差异带来的不稳定性。

3.5 核心代码片段

下面是最终相似度计算函数:

def calculate_similarity(original_text: str, suspect_text: str) -> float:
    original = normalize_text(original_text)
    suspect = normalize_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 min(max(weighted_score, 0.0), 1.0)

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

4.1 初始方案

最初考虑过两种方案:

  1. 使用编辑距离或最长公共子序列。
  2. 使用字符 n-gram 向量相似度。

编辑距离和最长公共子序列在短文本上效果直观,但对于较长论文文本,动态规划的时间和空间开销较大,不适合题目中 5 秒内给出答案的要求。因此我选择了 n-gram 词频向量方案。

4.2 性能瓶颈分析

我使用 Python 标准库 cProfile 进行性能分析:

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

一次性能分析结果如下:

179022 function calls in 0.111 seconds

Ordered by: cumulative time

ncalls  tottime  cumtime  function
1       0.000    0.063    calculate_similarity
6       0.013    0.027    _collections._count_elements
2       0.000    0.018    normalize_text
3       0.000    0.017    cosine_similarity
56247   0.014    0.014    iter_ngrams

性能分析显示,耗时最大的部分是:

  • calculate_similarity:整体计算入口。
  • Counter 统计 n-gram 频次。
  • normalize_text:遍历全文做 Unicode 归一化和字符过滤。
  • cosine_similarity:计算向量点积和模长。

4.3 性能改进

根据性能分析结果,我做了以下改进:

  • 使用 Counter 统计 n-gram,避免手写双重循环比较文本片段。
  • 只遍历文本生成 1/2/3-gram,不构造复杂中间对象。
  • HTML 文档优先抽取正文,减少无关网页文本进入后续计算。
  • 使用标准库,避免第三方分词库的加载开销。
  • 对空文本做提前返回,避免无意义计算。

改进后,样例文本在本机上运行耗时约 0.1s,满足题目中 5 秒内给出答案的要求。

运行情况如下:
image

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

5.1 测试方法

本项目使用 Python 标准库 unittest 编写单元测试。运行命令如下:

python -m unittest discover -s tests

测试结果:

Ran 15 tests in 0.125s

OK

5.2 测试用例设计

我设计的测试用例覆盖了以下场景:

编号 测试内容 目的
1 完全相同文本 检查重复率是否为 1
2 两个空文档 检查边界条件
3 一个空文档 检查空输入处理
4 标点、空格、全角字符归一化 检查文本预处理
5 题目示例中的局部改写 检查改写文本相似度
6 无关文本 检查低相似度判断
7 普通 HTML 正文抽取 检查 HTML 处理
8 GitHub blob HTML 代码行抽取 检查样例 HTML 文件适配
9 UTF-8 文件读取 检查文件读取
10 缺失文件异常 检查异常处理
11 答案文件两位小数输出 检查输出格式
12 CLI 正常运行 检查命令行入口
13 CLI 参数数量错误 检查参数校验
14 样例 orig_0.8_add.txt 检查增添文本样例
15 样例 orig_0.8_dis_1.txt 检查 HTML 样例

5.3 部分测试代码

下面展示几个有代表性的测试:

def test_identical_text_has_full_similarity(self) -> None:
    self.assertAlmostEqual(calculate_similarity("今天天气很好", "今天天气很好"), 1.0)


def test_one_empty_document_has_zero_similarity(self) -> None:
    self.assertAlmostEqual(calculate_similarity("今天天气很好", ""), 0.0)


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


def test_github_blob_html_prefers_code_lines(self) -> None:
    html = (
        "<html><body><nav>GitHub</nav><table>"
        '<td id="LC1" class="blob-code js-file-line">第一行</td>'
        '<td id="LC2" class="blob-code js-file-line">第二行</td>'
        "</table></body></html>"
    )
    self.assertEqual(extract_document_text(html).strip(), "第一行\n第二行")

5.4 覆盖率

覆盖率可用以下命令查看:

python -m coverage run -m unittest discover -s tests
python -m coverage report

image

如果本机没有安装 coverage,可以先安装:

pip install coverage

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

6.1 参数数量错误

设计目标:评测系统会按固定格式传入三个参数。如果参数数量不正确,程序不应继续运行,而应提示正确用法并返回错误码。

处理方式:

if len(arguments) != 3:
    print(
        "Usage: python main.py <original_file> <suspect_file> <answer_file>",
        file=sys.stderr,
    )
    return 2

对应测试:

def test_run_cli_rejects_wrong_argument_count(self) -> None:
    with redirect_stderr(StringIO()):
        self.assertEqual(run_cli([]), 2)

6.2 输入文件不存在

设计目标:当传入的原文路径或抄袭版路径不存在时,程序应给出清晰错误,而不是异常退出。

处理方式:

if not path.is_file():
    raise DocumentError(f"Input file does not exist: {path}")

对应测试:

def test_read_text_file_reports_missing_file(self) -> None:
    with self.assertRaises(DocumentError):
        read_text_file("not_exists.txt")

6.3 输入文件无法读取或无法解码

设计目标:不同系统上的中文文本可能使用不同编码。程序依次尝试 utf-8-sigutf-8gb18030。如果仍无法读取,统一转换为 DocumentError,由命令行入口捕获并返回错误码。

处理方式:

for encoding in ENCODINGS:
    try:
        return path.read_text(encoding=encoding)
    except UnicodeDecodeError as exc:
        last_error = exc
    except OSError as exc:
        raise DocumentError(f"Cannot read input file: {path}") from exc

6.4 答案文件无法写入

设计目标:如果答案路径所在目录不存在,或没有权限写入,程序应输出错误信息并返回错误码。

处理方式:

try:
    path.write_text(f"{score:.2f}", encoding="utf-8")
except OSError as exc:
    raise DocumentError(f"Cannot write answer file: {path}") from exc

对应测试:

def test_write_answer_uses_two_decimal_places(self) -> None:
    with tempfile.TemporaryDirectory() as temp_dir:
        answer = Path(temp_dir) / "answer.txt"
        write_answer(str(answer), 0.836)
        self.assertEqual(answer.read_text(encoding="utf-8"), "0.84")

6.5 空文本处理

设计目标:空文本是边界情况。两个空文本可以认为完全相同;如果只有一个为空,则重复率应为 0。

处理方式:

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

对应测试:

def test_empty_documents_are_equal(self) -> None:
    self.assertAlmostEqual(calculate_similarity("", ""), 1.0)


def test_one_empty_document_has_zero_similarity(self) -> None:
    self.assertAlmostEqual(calculate_similarity("今天天气很好", ""), 0.0)

七、样例运行结果

在项目已有测试文本上运行,结果如下:

查重文件 输出重复率
orig_0.8_add.txt 0.88
orig_0.8_del.txt 0.88
orig_0.8_dis_1.txt 0.97
orig_0.8_dis_10.txt 0.90
orig_0.8_dis_15.txt 0.76

其中 orig_0.8_dis_1.txt 等文件是 HTML 格式,程序会自动提取其中的正文代码行再参与计算。

八、代码质量分析

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

  • 函数职责单一,入口、计算、文件读写分离。
  • 核心函数均添加类型标注。
  • 对文件读取、文件写入、参数数量等异常情况做了统一处理。
  • 不尝试连接网络。
  • 不读取原文、抄袭版文件以外的其他业务文件。
  • 不写入答案路径以外的业务文件。
  • 使用 unittest 编写自动化测试。

基础检查命令如下:

python -m compileall .
python -m unittest discover -s tests

检查结果:

compileall 通过
Ran 15 tests in 0.125s
OK

九、总结与改进方向

本项目实现了一个轻量级论文查重程序。程序使用字符级 n-gram 频次余弦相似度,能够处理普通文本、HTML 文本、插入、删除和局部改写等情况。相比使用复杂语义模型,该方法更符合题目的时间和空间限制。

本次开发中比较关键的点是 HTML 正文抽取。如果直接把 HTML 页面整体参与计算,结果会受到网页结构和脚本内容干扰。通过识别 GitHub blob 的代码行,可以让程序更准确地提取真正论文内容。

后续可改进方向:

  • 根据更多样例微调 1/2/3-gram 权重。
  • 增加更多编码格式兼容。
  • 在保证性能的前提下加入简单的句子级相似度辅助判断。
posted @ 2026-09-14 17:08  MasterFred  阅读(10)  评论(0)    收藏  举报