第一次个人编程作业

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

项目 内容
这个作业属于哪个课程 计科24级5班
这个作业的要求在哪 第一次个人编程作业
这个作业的目标 完成个人编程作业,实现论文查重算法,使用 Git 管理代码,编写单元测试和性能分析,并撰写博客记录开发过程。

作业GitHub链接:https://github.com/Charon0921/Charon0921/tree/main/3224004080

一、PSP表格(预估)

在开始编码之前,根据作业要求和自身情况,对各个开发环节的耗时进行预估:

PSP2.1 预估耗时(分钟)
Planning
· Estimate(估计这个任务需要多少时间) 30
Development
· Analysis(需求分析,包括学习新技术) 60
· Design Spec(生成设计文档) 30
· Design Review(设计复审) 20
· Coding Standard(代码规范) 15
· Design(具体设计) 40
· Coding(具体编码) 120
· Code Review(代码复审) 30
· Test(自我测试,修改代码,提交修改) 90
Reporting
· Test Report(测试报告) 30
· Size Measurement(计算工作量) 15
· Postmortem & Process Improvement Plan 30
合计 510

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

1. 代码组织

项目使用 Python 3 标准库实现,无需安装第三方依赖。代码结构如下:

3224004080/
├── main.py                      # 查重程序入口
├── requirements.txt             # 依赖说明
├── README.md                    # 项目文档
├── 测试数据/
│   ├── orig.txt                 # 原文样例
│   ├── orig_add.txt             # 增加版抄袭文
│   ├── orig_del.txt             # 删除版抄袭文
│   ├── orig_dis_1.txt           # 微改版抄袭文
│   ├── orig_dis_10.txt           # 中改版抄袭文
│   └── orig_dis_15.txt          # 大改版抄袭文
├── tests/
│   └── test_main.py             # 单元测试(13项)
└── tools/
    └── profile_performance.py   # 性能分析脚本

main.py 中定义了以下函数,彼此之间为顺序调用关系:

read_text()  →  normalize_text()  →  build_ngrams()  →  calculate_similarity()
                                                                    ↓
                                                              main()(入口)
  • read_text(path):读取文件,自动尝试 UTF-8 和 GB18030 编码。
  • normalize_text(text):使用正则表达式去除空白和标点,只保留中文、英文和数字。
  • build_ngrams(text, size):将清理后的文本切分为连续的二元字符片段集合。
  • calculate_similarity(original, copied):计算两个片段集合的 Jaccard 相似度。
  • main(arguments):命令行入口,接收三个参数(原文路径、抄袭文路径、答案路径),将结果写入答案文件。

2. 算法关键

采用字符二元片段(bigram)+ Jaccard 相似度算法:

第一步:文本清理。 使用正则表达式 re.compile(r"[\W_]+") 一次性替换所有非字母数字字符,去除空白和标点。例如 "今天,天气晴!" 清理后为 "今天天气晴"

第二步:二元片段切分。 将清理后的文本按连续 2 个字符切分。例如 "今天天气" 切分为 {"今天", "天天", "天气"}。对于长度不足 2 的文本,将整个文本视为一个片段。

第三步:Jaccard 相似度计算。

$$相似度\text{相似度} = \frac{|A \cap B|}{|A \cup B|}$$

其中 A 和 B 分别为原文和抄袭文的二元片段集合。结果为 0 到 1 之间的浮点数,保留两位小数。

3. 算法流程图

开始
  │
  ▼
读取原文文件 ──→ read_text()
  │
  ▼
读取抄袭文文件 ──→ read_text()
  │
  ▼
文本清理(去除标点空白)──→ normalize_text()
  │
  ▼
切分二元字符片段 ──→ build_ngrams()
  │
  ├──── 原文片段集合 A
  │
  ├──── 抄袭文片段集合 B
  │
  ▼
计算 Jaccard 相似度 = |A ∩ B| / |A ∪ B|
  │
  ▼
保留两位小数写入答案文件
  │
  ▼
结束

4. 算法独到之处

  • 适合中文文本:不需要分词工具,直接以字符为单位切分,天然支持中文。
  • 零第三方依赖:仅使用 Python 标准库(resyspathlib),无需安装任何外部库。
  • 对少量改字敏感:例如"今天是星期天"和"今天是周天",虽然只改了少数字符,但共享的二元片段比例会明显下降,能检测出局部修改。
  • 标点无关:文本清理步骤去除了所有标点和空格,因此标点差异不会影响相似度计算。

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

1. 性能分析工具

使用 Python 自带的 cProfile 模块进行性能分析。通过 tools/profile_performance.py 脚本生成约 35 万字的模拟文本,对其执行 10 次查重计算,确保分析结果稳定可信。

运行命令:

python -m cProfile -s cumulative -m tools.profile_performance

性能分析截图:

性能分析

2. 优化前分析

优化前的性能分析结果:

函数 累计耗时(秒)
总耗时(10次查重) 1.420
calculate_similarity 1.410
normalize_text 1.178
build_ngrams 0.231

瓶颈定位normalize_text 函数是最大瓶颈,占总耗时的 83%。原实现使用逐字符遍历判断是否为字母数字,效率较低。

3. 优化措施

normalize_text 从逐字符循环改为使用预编译正则表达式一次性替换:

# 优化前:逐字符判断
def normalize_text(text):
    return "".join(c for c in text if c.isalnum())

# 优化后:正则表达式批量替换
NON_ALNUM_PATTERN = re.compile(r"[\W_]+", flags=re.UNICODE)

def normalize_text(text):
    return NON_ALNUM_PATTERN.sub("", text)

4. 优化后结果

指标 优化前 优化后 变化
10次查重总耗时 1.420 秒 0.320 秒 减少 77.5%
normalize_text 1.178 秒 0.078 秒 减少 93.3%
build_ngrams 0.231 秒 0.220 秒 基本不变
模拟文本相似度 0.62 0.62 不变

优化后程序消耗最大的函数变为 build_ngrams 中的 <setcomp>(集合推导式,0.220 秒),属于生成字符片段的必要工作,当前性能已满足需求。优化未改变计算结果,相似度仍为 0.62。

5. 性能分析截图说明

优化前的 cProfile 输出(关键部分):

ncalls  tottime  cumtime  filename:lineno(function)
    10    0.001    1.410  main.py:39(calculate_similarity)
    20    0.231    0.231  main.py:30(build_ngrams)
    20    1.178    1.178  main.py:25(normalize_text)

优化后的 cProfile 输出(关键部分):

ncalls  tottime  cumtime  filename:lineno(function)
    10    0.001    0.299  main.py:39(calculate_similarity)
    20    0.220    0.220  main.py:36(<setcomp>)
    20    0.000    0.078  main.py:25(normalize_text)
    20    0.078    0.078  {method 'sub' of 're.Pattern' objects}

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

1. 测试代码

使用 Python 自带的 unittest 框架,共编写 13 项测试,覆盖正常输入、边界条件和异常情况。

以下是部分核心测试代码:

class SimilarityTests(unittest.TestCase):
    """覆盖文本相似度计算和命令行入口的测试集合。"""

    def test_identical_texts_have_full_similarity(self):
        """相同文本的相似度应为一。"""
        self.assertEqual(main.calculate_similarity("今天是星期天", "今天是星期天"), 1.0)

    def test_completely_different_texts_have_zero_similarity(self):
        """完全不同文本的相似度应为零。"""
        self.assertEqual(main.calculate_similarity("甲乙丙丁", "戊己庚辛"), 0.0)

    def test_both_empty_texts_are_identical(self):
        """两个空文本视作相同。"""
        self.assertEqual(main.calculate_similarity("", ""), 1.0)

    def test_punctuation_and_spaces_do_not_change_similarity(self):
        """标点和空格不应影响相似度。"""
        original = "今天,天气很好!"
        copied = "今天 天气很好"
        self.assertEqual(main.calculate_similarity(original, copied), 1.0)

    def test_main_writes_a_two_decimal_answer_file(self):
        """命令行入口应写入保留两位小数的答案。"""
        with tempfile.TemporaryDirectory() as temporary_directory:
            folder = Path(temporary_directory)
            original = folder / "original.txt"
            copied = folder / "copied.txt"
            answer = folder / "answer.txt"
            original.write_text("相同的论文内容", encoding="utf-8")
            copied.write_text("相同的论文内容", encoding="utf-8")
            status = main.main([str(original), str(copied), str(answer)])
            self.assertEqual(status, 0)
            self.assertEqual(answer.read_text(encoding="utf-8"), "1.00\n")

    def test_main_rejects_wrong_argument_count(self):
        """命令行参数数量错误时应返回错误状态。"""
        self.assertEqual(main.main([]), 2)

2. 测试用例说明

序号 测试方法 测试目标 构造思路
1 test_identical_texts 完全相同文本 → 1.0 基准用例
2 test_completely_different 完全不同文本 → 0.0 基准用例
3 test_both_empty 两个空文本 → 1.0 边界条件
4 test_empty_original 空原文 vs 非空 → 0.0 边界条件
5 test_empty_copied 非空 vs 空文本 → 0.0 边界条件
6 test_punctuation_and_spaces 标点空格不影响结果 标点差异场景
7 test_newlines 换行不影响结果 格式差异场景
8 test_same_single_character 相同单字符 → 1.0 极短文本
9 test_different_single_characters 不同单字符 → 0.0 极短文本
10 test_small_edit_keeps_partial 少量改字 → 部分相似 局部修改场景(核心需求)
11 test_english_and_numbers 英文数字参与比较 混合语言场景
12 test_main_writes_two_decimal_answer 命令行入口写入答案 端到端集成测试
13 test_main_rejects_wrong_argument 参数错误返回状态码2 异常处理

3. 测试数据构造思路

  • 基准用例(用例1-2):用完全相同和完全不同的文本验证算法的上下界。
  • 边界条件(用例3-5):测试空文本的各种组合,确保程序不会因空输入崩溃或产生错误结果。
  • 格式无关性(用例6-7):验证标点、空格和换行不会影响相似度,这是论文查重的基本要求。
  • 极短文本(用例8-9):测试单个字符的情况,确保算法对极短文本也能正确处理。
  • 核心场景(用例10):模拟"今天是星期天"→"今天晚上要看电影"这类少量改字,验证算法对局部修改的检测能力。
  • 混合语言(用例11):验证英文和数字同样参与比较。
  • 集成测试(用例12-13):测试命令行入口的完整流程和异常处理。

4. 测试结果与覆盖率

运行 python -m unittest discover -s tests -v,13 项测试全部通过:

Ran 13 tests in 0.007s

OK

使用 coverage 工具分析测试覆盖率:

set PYTHONPATH=. && python -m coverage run --source=main tests\test_main.py && python -m coverage report -m

测试覆盖率截图:

测试覆盖率

覆盖率结果:

Name      Stmts   Miss  Cover   Missing
---------------------------------------
main.py      40      4    90%   20-22, 65
---------------------------------------
TOTAL        40      4    90%

覆盖率达 90%。未覆盖的 4 行说明:

  • 第 20-22 行read_text 函数中 GB18030 编码回退分支,测试用例均使用 UTF-8 编码文件,未触发此分支。
  • 第 65 行if __name__ == "__main__" 入口,单元测试通过直接调用 main() 函数测试,不经过命令行入口。

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

1. 参数数量异常

设计目标:当命令行参数不等于 3 个时,打印用法提示并返回错误状态码 2,不产生异常退出。

测试样例

def test_main_rejects_wrong_argument_count(self):
    """命令行参数数量错误时应返回错误状态。"""
    self.assertEqual(main.main([]), 2)

对应场景:用户运行程序时未提供足够的参数,例如只输入了 python main.pypython main.py 原文.txt。程序不会崩溃,而是输出用法提示:

用法: python main.py 原文路径 抄袭文路径 答案路径

并返回状态码 2,便于脚本或评测系统判断执行失败的原因。

2. 空文本异常

设计目标:当原文或抄袭文为空时,不产生除零错误,返回合理的相似度值。

测试样例

def test_empty_original_has_zero_similarity(self):
    """空原文与非空文本的相似度应为零。"""
    self.assertEqual(main.calculate_similarity("", "论文内容"), 0.0)

对应场景:用户传入了一个空文件作为原文或抄袭文。程序通过以下逻辑避免除零错误:

if not original_ngrams and not copied_ngrams:
    return 1.0   # 两个都为空,视作相同
if not original_ngrams or not copied_ngrams:
    return 0.0   # 只有一个为空,完全不同

3. 编码异常

设计目标:当文件编码不是 UTF-8 时,自动尝试其他常见编码,避免因编码问题无法读取文件。

对应代码

def read_text(path):
    file_path = Path(path)
    for encoding in ("utf-8-sig", "gb18030"):
        try:
            return file_path.read_text(encoding=encoding)
        except UnicodeDecodeError:
            continue
    return file_path.read_text(encoding="utf-8")

对应场景:评测系统提供的文件可能使用 GB18030 编码(Windows 中文环境常见),程序会先尝试 UTF-8(带 BOM),失败后再尝试 GB18030,确保不同编码的文件都能正常读取。

六、代码质量分析

使用 Pylint 对全部三个 Python 文件进行代码质量检查:

python -m pylint main.py tests\test_main.py tools\profile_performance.py

结果:10.00 / 10,无任何警告。

所有函数和类都有完整的文档字符串,命名符合 PEP 8 规范,无未使用的变量或导入。

七、PSP表格(实际)

编码完成后,回顾实际耗时:

PSP2.1 预估耗时(分钟) 实际耗时(分钟)
Planning
· Estimate(估计这个任务需要多少时间) 30 20
Development
· Analysis(需求分析,包括学习新技术) 60 50
· Design Spec(生成设计文档) 30 25
· Design Review(设计复审) 20 15
· Coding Standard(代码规范) 15 15
· Design(具体设计) 40 35
· Coding(具体编码) 120 150
· Code Review(代码复审) 30 25
· Test(自我测试,修改代码,提交修改) 90 120
Reporting
· Test Report(测试报告) 30 20
· Size Measurement(计算工作量) 15 10
· Postmortem & Process Improvement Plan 30 25
合计 510 525

事后总结

实际总耗时与预估接近,编码和测试阶段略超预期。主要原因是初次使用 Python 进行完整的项目开发,在环境搭建和工具使用上花了一些额外时间。性能优化阶段通过 cProfile 定位瓶颈并使用正则表达式替换逐字符循环,效果显著(耗时减少 77.5%),这一过程加深了对性能分析工具的理解。

八、Git提交记录

项目共进行 6 次提交,遵循功能阶段划分:

提交 说明
0f7d481 feat: 完成论文查重程序初版
643807d 添加 .gitignore 文件
482ef64 test: 添加论文查重单元测试
dace4db perf: 优化文本清理并添加性能分析脚本
979dbef style: 完善代码文档并通过 Pylint 检查
57d7c9a chore: 添加论文查重测试数据文件

每次提交对应一个功能阶段完成节点:初版功能 → 项目配置 → 单元测试 → 性能优化 → 代码规范 → 测试数据。

posted @ 2026-09-13 00:41  Coconut12138  阅读(15)  评论(0)    收藏  举报