简介:面向毕业设计及深度学习实践者的完整项目包,实现基于BERT模型的文本相似度检测系统。系统综合欧氏距离、余弦相似度、曼哈顿距离等算法,并配备文件管理模块:支持创建文件夹、按指定目录上传、批量删除/下载、搜索及收藏;文本查重模块则提供Word上传、粘贴上传、批量上传及从文件管理模块导入四种方式,能够自动输出相似度检测报告。资源共396个文件,以72个py源文件、82个pyc编译文件为核心,辅以75个gif图标、35个js交互脚本、18个css样式、15个html页面,以及docx说明文档、pdf与sql数据库文件,压缩包约65.86MB。已有292人学习该资源,适合需要完整课程设计、查重系统落地实现或深度学习可视化研究的学习者。
1. 一份能跑的 Python 深度学习文本相似度检测系统:先说你拿到的是什么
一份 Python 深度学习文本相似度检测系统的源码包,解压之后里面通常是一整套能跑通的东西:中文预处理、句子编码模型、相似度计算、训练与推理脚本,外加用于演示的数据集。适合两类人——正在做 NLP 方向毕业设计、想要一个能改能复现的地基的人;以及工作中要处理搜索去重、客服语义匹配、论文查重这类「判断两段话是不是一个意思」的人。这篇笔记按我拿到 zip 包后的真实操作顺序写:先看结构、再跑起来、最后踩坑与改造,不绕弯子。
2. 文本相似度不止一种算力路线:它怎么把句子变成向量
2.1 为什么选深度学习而不是 TF-IDF
传统的文本相似度做法是先把句子分词,再用 TF-IDF 或 BM25 映射成稀疏向量,最后计算余弦相似度。它的优点是快、可解释,但缺点非常明显:它只在字面上算重合度,两个用词完全不同、语义一致的句子,算出来的相似度可能很低。比如「这台机器今天出故障了」和「设备宕机就在今天」,TF-IDF 会认为它们几乎不相关,因为分词后几乎没有一个词是重合的。
深度学习路线的核心思路是训练一个句子编码器,把整句话编码成一个稠密向量,让「语义相近的句子在向量空间里距离更近」。这种方式不依赖字面重合,而是从大量语料里学到「故障」和「宕机」在语境上相近。这份源码走的就是这条路:模型负责把句子变成向量,相似度计算只做向量间的度量。
2.2 从 zip 包结构反推数据流
接到源码包,第一步不是急着装依赖,而是先解压看目录结构。我见过太多人上来就python train.py,然后被各种路径报错劝退。一个典型的深度学习文本相似度项目,目录里通常有data/(原始语料和预处理脚本)、models/(模型定义)、utils/(公共函数)、train.py、predict.py、requirements.txt。
text_similarity_system/ ├── data/ # 原始数据与预处理产物 │ ├── train_pair.txt │ └── stopwords.txt ├── models/ │ └── encoder.py # 句子编码模型定义 ├── utils/ │ ├── preprocess.py # 清洗、分词、建词表 │ └── metrics.py # 相似度计算函数 ├── train.py # 训练入口 ├── predict.py # 推理入口 └── requirements.txt从目录就能反推出完整数据流:原始语料先进preprocess.py清洗和分词,建词表后转成 id 序列;train.py读取 id 序列训练编码模型;模型收敛后,predict.py加载权重,对两条输入文本编码,再用metrics.py里的函数算相似度分数。后面的操作基本沿着这条链路走,哪个环节报错就定位哪个模块。
2.3 句子编码器与相似度分数:代码里的关键逻辑
相似度系统的核心就两部分代码:编码器和度量函数。编码器负责把「自然语言句子」变成「固定长度的向量」,度量函数负责把两个向量变成一个可比较的数字。先看预处理部分,这是最容易翻车的地方,因为中文和英文不一样,必须分词。
import re import jieba def clean_and_seg(text: str, stopwords: set) -> list: # 统一空白、去特殊符号,避免脏字符影响分词 text = re.sub(r"\s+", " ", text.strip()) text = re.sub(r"[^\u4e00-\u9fa5a-zA-Z0-9]", "", text) words = jieba.lcut(text) # 过滤停用词和单字词,降低噪声 return [w for w in words if w not in stopwords and len(w) > 1这段代码做了三件事:清理空白和符号、用 jieba 分词、过滤停用词。stopwords是从停用词表读进来的集合,实践里会比代码里展示的更大,包含标点、虚词和常见无意义词。过滤单字词的目的是减少向量空间的稀疏性,否则「的」「了」「在」这类词会占据大量维度。
模型部分常见的方案是双塔结构,也就是两个共享权重的编码器分别处理两条句子,各自输出一个向量,再拿这两个向量算相似度。编码器可以选 TextCNN、BiLSTM 或者预训练模型,毕业论文场景里 BiLSTM 是性价比最高的选择。
class SentenceEncoder(nn.Module): def __init__(self, vocab_size, embed_dim, hidden_dim, num_layers=2): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.lstm = nn.LSTM(embed_dim, hidden_dim, num_layers=num_layers, batch_first=True, bidirectional=True) self.dropout = nn.Dropout(0.3) def forward(self, input_ids): emb = self.dropout(self.embedding(input_ids)) # output 是每个时刻的输出,h_n 是最后一层隐状态 output, (h_n, c_n) = self.lstm(emb) # 取双向最后一层的最后一时刻隐状态拼接成句子向量 last_hidden = torch.cat((h_n[-2], h_n[-1]), dim=-1) return last_hiddenpadding_idx=0让所有 padding 位置的 embedding 保持为零向量,不会参与梯度更新。bidirectional=True意味着每个位置既能看左边也能看右边,对语义建模更充分。取h_n[-2]和h_n[-1]分别是正向和反向最后一层最后一个时刻的隐状态,拼接起来就是整句话的向量。Dropout 只加在 embedding 上,防止过拟合同时不破坏 LSTM 内部状态。
度量函数可以选择余弦相似度或者欧氏距离,文本相似度场景下余弦相似度是默认选择,因为它只关心方向、不关心向量模长,对句子长度差异有一定容忍度。
def cosine_similarity(vec_a, vec_b): # 归一化后做点积,结果落在 [-1, 1] norm_a = vec_a / torch.norm(vec_a, dim=-1, keepdim=True) norm_b = vec_b / torch.norm(vec_b, dim=-1, keepdim=True) return torch.sum(norm_a * norm_b, dim=-1)keepdim=True是为了保持维度方便 broadcast,两条向量长度不一致时也能按 batch 计算。注意这里用的是归一化后的向量,如果跳过归一化直接点积,结果会被向量长度主导,长句子天然占便宜,这是新手最容易忽略的细节。
3. 从解压到调出分数:环境配置与全流程复现
3.1 解压与目录结构确认
拿到 zip 包,第一步是确认压缩包完整性。我习惯用 7-Zip 或 Bandizip 打开而不是直接用 Windows 自带解压器,因为自带解压器遇到特殊编码的文件名会直接抛错。解压前先看看包内文件列表,确认是不是分卷压缩——如果显示文件名后缀是.z01、.z02这种,必须把全部分卷放在同一目录再解压,缺一个都会提示「文件损坏」。
解压后别急着跑,先检查目录里有没有README或requirements.txt。README 里一般写着数据集来源、训练参数、Python 版本要求,这些信息比任何教程都准。requirements.txt 则是依赖清单,通过它能判断项目用的 PyTorch 还是 TensorFlow、有没有用到 transformers 这类重型库。
3.2 创建虚拟环境与安装依赖
深度学习项目最忌讳直接在全局 Python 环境里装依赖,不同项目的 torch、numpy 版本经常互相打架。我一般用 Anaconda 建独立虚拟环境,Python 版本优先选 3.8 或 3.9,这两个版本对 PyTorch 和 TensorFlow 的兼容性最稳。Python 3.10 以上跑老项目时,经常碰到typing或distutils相关报错。
conda create -n text_sim python=3.8 conda activate text_sim pip install -r requirements.txt安装依赖时有个小技巧:先装 torch 再装其他库。因为 transformers、jieba、sklearn 这些库在安装时会检测 torch 是否已存在,如果后装 torch,前面装的库可能拿到的是 CPU 版本推断逻辑,虽然不影响最终运行,但会让显存调用变得不可控。requirements.txt 里如果没有锁版本号,建议给 torch 单独指定版本,比如pip install torch==2.0.0或 CPU 版pip install torch --index-url https://download.pytorch.org/whl/cpu,避免默认装成最新的 CUDA 版本导致与显卡驱动不匹配。
3.3 训练一个句对模型
环境就绪后,先看数据格式。这类源码的训练数据通常是「句对 + 标签」的格式,标签1表示语义相似,0表示不相似,每一行是一对样本。
这台机器今天故障了 设备宕机就在今天 1 今天天气不错 我在写代码 0训练脚本的通用逻辑是:读数据 → 预处理 → 建词表 → 转 id → 送进模型 → 计算损失 → 反向传播。核心训练循环的写法各家不一样,但骨架是一样的:
def train_one_epoch(model, dataloader, optimizer, criterion): model.train() total_loss = 0 for batch in dataloader: # batch 包含 a 句 ids、b 句 ids、标签 ids_a, ids_b, labels = batch vec_a = model(ids_a) vec_b = model(ids_b) sim = cosine_similarity(vec_a, vec_b) # 用 MSE 或 BCELoss 把相似度压到标签附近 loss = criterion(sim.squeeze(), labels.float()) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() return total_loss / len(dataloader)这里的损失函数值得展开说。很多源码用的是BCELoss,它要求输入落在[0, 1],余弦相似度落在[-1, 1],所以需要在计算损失前做一次归一化映射:(sim + 1) / 2。如果源码里直接拿原始 sim 算 BCE,训练会非常不稳定,loss 可能出现 NaN。另一种常见方案是用CosineEmbeddingLoss,它内部自己处理了 margin 逻辑,不需要手动映射,更适合相似度任务。
训练参数方面,文本相似度任务的 batch size 不宜太大,我一般设 32 到 64。学习率初始值 1e-3 配 Adam 优化器,训练轮次 5 到 10 轮。这个任务收敛很快,因为句对分类比生成任务简单得多,第 3 轮左右准确率就能到 85% 以上,跑太多轮反而过拟合。
3.4 推理并确定相似度阈值
训练完成后,predict.py会加载保存的权重,对输入的两条文本输出一个相似度分数。但「分数大于多少算相似」这个问题,源码通常不会直接给你答案,而是留了一个threshold参数。
# predict.py 中常见的推理逻辑 def predict(model, text_a, text_b, threshold=0.5): ids_a = text_to_ids(text_a) ids_b = text_to_ids(text_b) vec_a = model(ids_a) vec_b = model(ids_b) sim = cosine_similarity(vec_a, vec_b).item() # 判定相似与否全靠这个阈值 return sim, sim > threshold阈值 0.5 只是一个初始猜测,不能直接当最终值用。正确做法是留出一部分训练数据不参与训练,跑一遍推理,把所有句对的相似度分数画出来,观察正负样本的分数分布,选一个让准确率和召回率平衡的点。这个工作必须做,否则系统上线后你会发现误判率远超预期。
推理阶段还有两个细节:一是确认输入文本走了和训练完全一样的预处理流程,比如训练时过滤了停用词,推理时也必须过滤,否则向量分布会偏移;二是确认模型处于eval()模式,Dropout 在推理时应当关闭,否则同样两句话每次算出来的分数都不同。
# 标准推理命令 python predict.py --model_path checkpoints/best.pt --text_a "今天机器故障了" --text_b "设备宕机了"如果源码的 CLI 接口和这个不同,直接看argparse定义部分,确认参数名后按实际调整。预测结果会返回一个0-1之间的浮点数,先跑几条正负样本确认分数区分度,再决定要不要动阈值。
4. 复现中值得注意的四个问题:现象、原因、解决
4.1 解压提示文件损坏,实际是伪加密
现象:用 Windows 自带解压器打开 zip 包,提示「文件已损坏或压缩包格式错误」,换 7-Zip 又能正常打开,但部分文件显示带锁图标。
原因:zip 文件头里的 general purpose bit flag 第 0 位被置 1,表示「加密」,但实际数据并没有被加密。这种情况叫伪加密,常见于某些压缩工具或从论坛下载的资源。Windows 自带解压器对标志位校验严格,看到加密位就直接拒绝解压,而 7-Zip 会尝试按数据实际内容解压。
解决:先用 7-Zip 打开,如果能预览文件列表但不让解压,说明是伪加密。用 ZipCenOp 或 010 Editor 把文件头的加密标志位改回 0,再保存。命令行下也可以用 Python 的zipfile库强行读取,因为它只在真正读取加密数据时才校验。我一般直接用 7-Zip 右键「解压到当前文件夹」,多数情况它能绕过去,绕不过去再改标志位。
4.2 torch 版本与 CUDA 不匹配,GPU 完全跑不起来
现象:训练时torch.cuda.is_available()返回False,或者报错CUDA error: no kernel image is available for execution on the device。
原因:pip 默认安装的 torch 是带 CUDA 支持的通用版本,但它内置的 CUDA 运行时版本必须和显卡驱动支持的版本兼容。显卡驱动太老、或装成了 CPU 版、或 Windows 下 PATH 里混入了多个 CUDA 版本,都会触发这个报错。
解决:先跑一条命令确认环境:
python -c "import torch; print(torch.__version__, torch.cuda.is_available())"返回False就看显卡驱动和 CUDA 版本,nvidia-smi能看到支持的 CUDA 版本。如果驱动太老,直接装 CPU 版 torch 最省事,毕设场景的数据量 CPU 完全能跑,只是慢一些。确认需要 GPU 就按pip install torch==对应版本+cu118的方式指定版本重装,不要装最新版。
4.3 Windows 下中文路径与 GBK 编码
现象:训练时读取数据文件报UnicodeDecodeError,或者提示找不到某个文件,但路径明明是对的。
原因:两个问题叠加。一是源码里写死了 Linux 风格路径,比如data/train_pair.txt,在 Windows 下如果盘符或目录层级不同就会找不到文件;二是源码用open()读文件时没指定encoding='utf-8',Windows 默认编码是 GBK,读 UTF-8 保存的中文语料直接抛异常。
解决:首先把所有文件读取的地方显式加上encoding='utf-8'。其次把写死的绝对路径改成相对路径,并用os.path.join拼路径。
import os # 不推荐:写死项目绝对路径,换机器就崩 data_path = "D:/project/data/train_pair.txt" # 推荐:基于当前文件位置动态拼路径 base_dir = os.path.dirname(os.path.abspath(__file__)) data_path = os.path.join(base_dir, "data", "train_pair.txt")换机器的场景下,绝对路径是最容易翻车的地方。写死C:/Users/xxx/...这种路径,源码发给别人跑第一句就报错。用__file__定位当前脚本所在目录,再往上层或下层拼,才是稳定的做法。
4.4 相似度分数总是逼近 1:阈值形同虚设
现象:训练完模型后,不管拿什么句子去测,相似度都落在 0.9 以上,正负样本完全没有区分度。
原因:训练时损失函数没有正确约束向量分布。常见两种诱因:一是损失函数只用了 MSE 或 BCE,但没做(sim + 1) / 2的映射,模型所有输出都往正方向偏;二是训练标签分布严重不平衡,负样本太少,模型学到的策略就是「全部输出高相似度」,因为这样整体损失最小。
解决:先检查训练集正负样本比例,负样本太少就补充或做数据增强,把「不相似」的句对样本量拉上来。再检查损失函数映射逻辑,确认输入到 BCELoss 的值确实落在[0,1]区间。最后还有一个简单有效的办法:把损失函数换成CosineEmbeddingLoss(margin=0.2),它会显式要求相似样本距离近、不相似样本距离远,对区分度有硬约束。改完之后重新训练看测试集分布,正常情况下正样本均值应该在 0.75 以上,负样本均值在 0.4 以下。
5. 把这份源码改造成自己的相似度服务:两个进阶方向
5.1 编码层之外的 pooling 策略
双塔模型的最终输出是「最后一层隐状态拼接」,但对于长句子,这个向量会丢失很多信息。一个更稳的做法是拿最终输出做平均池化再加一个全连接层压缩维度。常见做法是同时保留last_hidden和mean_pooling,拼接成一个更长的向量,这个技巧在短文本相似度上能稳定提升一到两个百分点的准确率。
def mean_pooling(model, input_ids): emb = model.embedding(input_ids) output, _ = model.lstm(emb) # 对非 padding 位置求平均 mask = (input_ids != 0).unsqueeze(-1).float() masked = output * mask pooled = masked.sum(dim=1) / mask.sum(dim=1) return torch.cat((pooled, model.pool_proj(pooled)), dim=-1)mask的用途是让 padding 位置不参与平均计算,否则所有句子都会混入零向量的稀释,长句和短句的分数可比性会变差。改造后记得同步更新训练和推理两个入口,否则模型结构不一致直接加载权重报错。
5.2 三个请求暴露一个 FastAPI 接口
把项目打包成一个 HTTP 服务,是我自己每次拿到源码都会顺手做的事。好处是可以脱离命令行,让前端或其他服务通过接口调用。做法很简单,加载一次模型权重,用 FastAPI 封装两个端点。
from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() model = load_model() # 启动时加载一次 class Pair(BaseModel): text_a: str text_b: str @app.post("/similarity") def get_similarity(pair: Pair): sim = predict(model, pair.text_a, pair.text_b) return {"similarity": sim}model在模块加载时初始化一次,不要在每个请求里重新加载,否则高频调用时延迟会非常高。接口返回的 similarity 直接就是0-1浮点数,由调用方决定阈值落在哪。加上uvicorn app:app --host 0.0.0.0 --port 8000就能启动服务。从那以后,我每次拿到任何 NLP 源码的第一件事,都是先确认模型加载函数和推理函数的输入输出格式,然后封装成接口,再回头去补训练细节。这样即使项目后面被改得面目全非,接口层始终稳定,希望帮到你。
本文还有配套的精品资源,点击获取