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 对两段文本分词。

2.3 算法关键
核心思路是把文本转成词频向量,用余弦相似度衡量两个向量的夹角。
具体步骤:
- 读入两段文本
- 分别用 jieba 分词
- 用 Counter 统计每个词出现的次数
- 取两边词表的并集,逐个词计算点积
- 分别求两个向量的模长
- 点积除以模长乘积,得到相似度
2.4 独到之处
- 读文件时依次尝试三种编码,避免因为编码不同直接报错
- 如果环境里没有 jieba,退化成按字符切分,程序仍能跑
- 命令行参数不是 3 个时,打印用法并退出,不抛异常
三、计算模块接口部分的性能改进
3.1 性能分析图
用 cProfile 跑一遍,再用 snakeviz 看火焰图

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

五、计算模块部分异常处理说明
| 异常 | 设计目标 | 单元测试 | 场景 |
|---|---|---|---|
| 文件不存在 | 抛 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 性能分析这些工具的使用。

浙公网安备 33010602011771号