assignment 2:第一次个人编程作业

Github作业链接:https://github.com/w7885/w7885

一、PSP 表格

PSP Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 20 25
· Estimate · 估计这个任务需要多少时间 20 25
Development 开发 240 260
· Analysis · 需求分析(包括学习新技术) 30 35
· Design Spec · 生成设计文档 20 20
· Design Review · 设计复审 15 15
· Coding Standard · 代码规范 10 10
· Design · 具体设计 25 30
· Coding · 具体编码 80 75
· Code Review · 代码复审 20 25
· Test · 测试 40 50
Reporting 报告 40 45
· Test Report · 测试报告 15 15
· Size Measurement · 计算工作量 10 10
· Postmortem & Process Improvement Plan · 事后总结 15 20
合计 300 330

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

2.1 代码组织

程序入口是 main.py,主要包含以下函数:

函数 作用
read_file 读文件,依次尝试 utf-8、gbk、gb18030 编码
tokenize 用 jieba 分词,去掉空白和标点
cosine_similarity 算两段文本的词频余弦相似度
write_answer 把结果写到答案文件,保留两位小数
main 接收命令行参数,串起整个流程

2.2 函数之间的关系

main 先调用两次 read_file,分别拿到原文和抄袭版文本,再把它们交给 cosine_similarity 计算,最后用 write_answer 输出。

cosine_similarity 内部会调用 tokenize 对两段文本分词。
image

2.3 算法关键

核心思路是把文本转成词频向量,用余弦相似度衡量两个向量的夹角。

具体步骤:

  1. 读入两段文本
  2. 分别用 jieba 分词
  3. 用 Counter 统计每个词出现的次数
  4. 取两边词表的并集,逐个词计算点积
  5. 分别求两个向量的模长
  6. 点积除以模长乘积,得到相似度

2.4 独到之处

  • 读文件时依次尝试三种编码,避免因为编码不同直接报错
  • 如果环境里没有 jieba,退化成按字符切分,程序仍能跑
  • 命令行参数不是 3 个时,打印用法并退出,不抛异常

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

3.1 性能分析图

用 cProfile 跑一遍,再用 snakeviz 看火焰图
image

3.2 消耗最大的函数

ncalls  tottime  percall  cumtime  percall  filename:lineno(function)
     1    0.850    0.850    1.230    1.230  main.py(cosine_similarity)
     2    0.320    0.160    0.320    0.160  {jieba.lcut}
     1    0.045    0.045    0.045    0.045  main.py(tokenize)

从数据看,cosine_similarity 占了总时间的 69%,jieba.lcut 占 26%。

3.3 改进思路

优化点 改进前 改进后
词频统计 双重循环 O(n²) 用 Counter O(n)
向量点积 遍历全文词表 只遍历并集词表
空文本 无判断直接算 提前 return 0.0

3.4 改进耗时

性能改进大概花了 30 分钟:15 分钟用 cProfile 定位瓶颈,10 分钟把双重循环改成 Counter,5 分钟重新测一遍确认结果没变。

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

4.1 测试文件

测试文件 test_main.py,用 Python 标准库 unittest 编写,一共 11 个用例。

4.2 测试代码

# -*- coding: utf-8 -*-
import os
import sys
import tempfile
import unittest

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from main import cosine_similarity, read_file, write_answer, tokenize


class TestPlagiarism(unittest.TestCase):

    def test_identical_text(self):
        self.assertAlmostEqual(cosine_similarity("今天天气很好", "今天天气很好"), 1.0)

    def test_completely_different(self):
        self.assertEqual(cosine_similarity("今天天气很好", "苹果香蕉橘子"), 0.0)

    def test_partial_overlap(self):
        sim = cosine_similarity("今天是星期天天气晴", "今天是周天天气晴朗")
        self.assertTrue(0 < sim < 1)

    def test_empty_text(self):
        self.assertEqual(cosine_similarity("", "任何内容"), 0.0)
        self.assertEqual(cosine_similarity("任何内容", ""), 0.0)

    def test_both_empty(self):
        self.assertEqual(cosine_similarity("", ""), 0.0)

    def test_tokenize_filters_punct(self):
        self.assertEqual(tokenize("你好,世界!"), ["你好", "世界"])

    def test_tokenize_empty(self):
        self.assertEqual(tokenize(""), [])

    def test_read_file_missing(self):
        with self.assertRaises(FileNotFoundError):
            read_file("not_exist_file.txt")

    def test_read_file_ok(self):
        with tempfile.NamedTemporaryFile('w', suffix='.txt', delete=False, encoding='utf-8') as f:
            f.write("测试内容")
            name = f.name
        try:
            self.assertEqual(read_file(name), "测试内容")
        finally:
            os.remove(name)

    def test_write_answer_precision(self):
        with tempfile.NamedTemporaryFile('w', suffix='.txt', delete=False) as f:
            name = f.name
        try:
            write_answer(name, 0.123456)
            with open(name, encoding='utf-8') as f:
                self.assertEqual(f.read(), "0.12")
        finally:
            os.remove(name)

    def test_similarity_range(self):
        sim = cosine_similarity("今天是星期天,天气晴,今天晚上我要去看电影", "今天是周天,天气晴朗,我晚上要去看电影")
        self.assertTrue(0 <= sim <= 1)


if __name__ == '__main__':
    unittest.main()

4.3 测试函数与数据构造

用例 测的函数 构造思路
test_identical_text cosine_similarity 完全相同,期望 1.0
test_completely_different cosine_similarity 完全不同,期望 0.0
test_partial_overlap cosine_similarity 部分重叠,期望在 0 到 1 之间
test_empty_text cosine_similarity 一边为空
test_both_empty cosine_similarity 两边都空
test_tokenize_filters_punct tokenize 带标点的中文句子
test_tokenize_empty tokenize 空字符串
test_read_file_missing read_file 不存在的路径
test_read_file_ok read_file 临时文件,内容为测试内容
test_write_answer_precision write_answer 写 0.123456,读回来应该是 0.12
test_similarity_range cosine_similarity 验证结果落在 0 到 1 之间

4.4 运行方式

python test_main.py -v

或者:

python -m unittest test_main -v

4.5 覆盖率

用 coverage 查看覆盖率:

pip install coverage
coverage run -m unittest test_main
coverage report
coverage html

image

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

异常 设计目标 单元测试 场景
文件不存在 抛 FileNotFoundError,提示用户路径错 test_read_file_missing 传入不存在的文件
编码不识别 依次尝试三种编码,全失败抛 ValueError 手动测试 二进制文件
参数不足 打印用法并退出 手动测试 只传 1 到 2 个参数
空文本 相似度直接返回 0.0 test_empty_text 原文或抄袭版为空
分词结果为空 由 cosine_similarity 兜底返回 0.0 test_tokenize_empty 全是标点或空白

5.1 代码示例

def read_file(path):
    if not os.path.exists(path):
        raise FileNotFoundError("文件不存在: " + path)
    for enc in ('utf-8', 'gbk', 'gb18030'):
        try:
            with open(path, 'r', encoding=enc) as f:
                return f.read()
        except UnicodeDecodeError:
            continue
    raise ValueError("无法解码文件: " + path)

5.2 单元测试

def test_read_file_missing(self):
    with self.assertRaises(FileNotFoundError):
        read_file("not_exist_file.txt")

路径不存在时,os.path.exists 返回 False,程序抛 FileNotFoundError,在 main 里被捕获,打印提示后 sys.exit(1)。

六、实际运行结果

在样例上跑:

python main.py orig.txt orig_0.8_add.txt ans.txt

输出 重复率: 0.99,答案文件 ans.txt 内容是 0.99。

对 orig_0.8_del.txt、orig_0.8_dis_1.txt 等文件测试,输出都在合理范围内,有0.97等等。主要原因在于词频余弦相似度只统计每个词出现几次,不关心词的顺序

七、签入记录

Github 仓库:https://github.com/w7885/w7885

主要 commit 记录:

  • feat: implement initial plagiarism detection — 初版程序,实现读文件、分词、余弦相似度
  • test: 添加单元测试 — 增加 11 个 unittest 用例
  • refactor: 将项目文件移入学号目录 — 按作业要求整理目录结构

八、总结

本次作业完成了论文查重程序的开发。核心算法是 jieba 分词加词频余弦相似度,程序包含读文件、分词、相似度计算、写答案四个部分,并处理了文件不存在、编码错误、空文本等异常。

通过这次作业,熟悉了 Git 的工作流程、unittest 单元测试、cProfile 性能分析这些工具的使用。

posted @ 2026-09-14 21:59  wenqian1  阅读(20)  评论(0)    收藏  举报