简介:面向文本匹配算法学习与毕业设计场景的 Python 工程,完整实现 PointWise(单塔)、DSSM(双塔)、Sentence BERT(双塔)三种主流模型,并配套商品评论与商品类别匹配数据集和使用说明。项目基于 PyTorch 与 transformers 搭建,代码已测试运行成功;训练入口区分不同模型,PointWise 将两段文本拼接后输入编码器分类,DSSM 与 Sentence BERT 则分别编码后计算相似度,便于对比单塔、双塔架构在文本匹配任务中的效果差异。数据部分采用 tab 分隔的 text1、text2、label 格式,可直接替换为自定义业务数据训练,目录包含模型脚本、示例数据与说明文档,zip 压缩包整体约 7.86MB。目前已有 201 人浏览学习,适合计科、人工智能等专业学生用于课程设计、毕业设计或项目初期演示,也可作为文本匹配方向的入门进阶参考。
1. 文本匹配算法:为什么单塔精度更高,线上却普遍用双塔
做自然语言处理的从业者多半遇到过这类需求:平台上有大量商品评论,后台维护着一份类目定义,比如“衣服:指穿在身上遮体御寒并起美化作用的物品”,另一头是用户写下的“为什么是开过的洗发水都流出来了”。判断这段评论到底对不对应当前类目,本质就是文本匹配。这个基于Python实现的文本匹配算法源码包,把文本匹配里三种主流模型一次集齐:PointWise单塔、DSSM双塔、Sentence BERT双塔,还带着可直接跑的示例数据集和训练脚本。适合正在做毕业设计或课程设计的在校学生,也适合想快速搭一套匹配基线、对比不同架构效果的一线开发。拿到手不是让你看理论,而是能直接训练、推理、改数据,把三种模型的差异用指标量化出来。文本匹配领域最反直觉的一点在于:单塔PointWise精度通常最高,但工业落地里双塔才是常客——原因后面会展开。
2. 三种模型的架构差异:PointWise、DSSM、Sentence BERT到底在做什么
2.1 PointWise单塔:两段文本拼起来过同一个编码器
PointWise的逻辑最直观:把待匹配的两段文本拼成一个序列,中间用分隔符隔开,然后一起送进预训练语言模型。以商品评论匹配场景为例,输入是“衣服:指穿在身上遮体御寒并起美化作用的物品 [SEP] 为什么是开过的洗发水都流出来了”,模型经过多层Transformer编码后,取序列首位的特殊标记向量接一个分类头,输出匹配与不匹配的二分类概率。这个方案的特点是两段文本在编码过程中充分交互——模型能在每一个注意力层里看到两边token的关联,信息交互最充分,效果通常也最好。代价是推理时必须逐对拼接输入,假设有1万条候选类目,一条新评论要和1万个类目定义各拼一次,计算量是线性乘上去的。这个项目的PointWise实现就是基于预训练BERT微调,代码里用到的模型类是transformers库自带的序列分类模型,训练目标是常规的交叉熵损失。对毕设而言,PointWise是最容易讲清楚原理的模型,指标也容易刷得好看。
2.2 DSSM双塔:两段文本分别编码,最后算一次相似度
DSSM的思路和PointWise完全相反:两个塔各自独立编码自己的输入文本,得到两个固定长度的向量,再用某种相似度函数把它们变成一个标量。在原始论文里,DSSM的字面意思是深度语义匹配模型,它的经典实现包含词哈希和多个全连接层;在这个源码包里,双塔的基础编码器换成了预训练BERT,两个塔共享权重。共享权重的含义是:text1和text2走的是同一套参数,能不能共享值得反复确认,很多翻车案例就出在这里。训练时,两段文本分别得到句向量,然后算余弦相似度,再接sigmoid二分类。之所以线上系统偏爱双塔,是因为它可以做一次离线索引:把所有类目定义的向量提前算好存起来,新来一条评论只需要编码一次,得到向量后和库里的向量做点积或余弦排序,检索速度比单塔快几个量级。这个源码包里的DSSM实现提供了双塔最基本、也最适合改造成检索系统的范式。
2.3 Sentence BERT双塔:和DSSM形似但训练目标不同
Sentence BERT从名字就能看出来,它的初衷是获得“语义上有意义的句子向量”。结构上它和DSSM双塔长得几乎一样——两个句子分别过共享的BERT编码器,得到向量后再做交互。差别在于训练目标和交互方式:DSSM经典做法是算余弦相似度然后二分类,Sentence BERT在论文里支持多种目标——可以是Softmax分类(两段文本的向量拼接后送入分类器),也可以是回归(直接拟合两个句子的相似度分数),甚至是三元组损失(让正样本对的距离小于负样本对)。这个项目里Sentence BERT用的方式,是拿两个句向量做向量拼接、差值的绝对值等特征组合后过一层全连接分类,比单纯的余弦相似度多一些可学习的参数。这种设计在效果上通常介于DSSM和PointWise之间,但继承了双塔推理时可以先缓存候选向量的优势。做毕业设计时,同一个数据集上跑通这三种模型,对比它们的精度和推理速度,本身就是一个完整且有含金量的章节。
2.4 三种模型的选型对比与参数维度
| 对比项 | PointWise单塔 | DSSM双塔 | Sentence BERT双塔 |
|---|---|---|---|
| 输入方式 | text1和text2拼接后整体编码 | text1、text2分别独立编码 | text1、text2分别独立编码 |
| 特征交互 | 编码阶段全程交互 | 仅在最终向量层交互 | 向量层做拼接、差值等特征组合 |
| 训练目标 | 分类头 + 交叉熵 | 余弦相似度 + 二分类 | 多特征拼接 + 分类或回归 |
| 推理效率 | 低,逐对拼接计算 | 高,候选向量可离线缓存 | 高,候选向量可离线缓存 |
| 精度上限 | 通常最高 | 通常低于单塔 | 介于两者之间 |
| 典型场景 | 精排、小规模候选集 | 召回、大规模候选集 | 召回、相似度计算 |
这里有个值得注意的边界:双塔模型对向量相似度计算方式很敏感。同样是双塔,用余弦相似度、点积还是欧氏距离,效果能差出好几个点。这个项目里DSSM和Sentence BERT对相似度的处理方式不同,训练脚本里对应参数也不一样,后面训练章节会详细展开。选型时不要无脑套单塔,要看你最终部署环境能接受多大的推理延迟;毕设答辩时能说出这个权衡,比单纯报指标更让人觉得你真正吃透了项目。
3. 数据准备与自定义数据集:商品评论匹配任务的格式陷阱
3.1 示例数据的结构解读
项目自带的数据在data/comment_classify目录,每行是一条训练样本,三个字段用制表符分隔,字段顺序是:商品类别定义(text1)、商品评论(text2)、标签(label)。标签0表示这条评论和该类别定义不一致,1表示一致。比如拿“衣服”的类目定义去匹配一条关于“洗发水包装漏了”的评论,标签就是0;匹配一条“买回来大了后来换了小的,尺码不正规建议谨慎”的评论,标签就是1。字段顺序不要调换,训练脚本里load_dataset默认按这个顺序读取,调错了数据不会报错,只是模型会学到完全错误的东西。建议拿到项目先把示例数据打开看一眼原始格式,用文本编辑器打开即可,不要用Excel直接打开,Excel有时会把制表符隐式转成空格,处理时容易踩坑。
除了原始数据文件,项目里大概率还包含按比例切分好的训练集、验证集和测试集划分,或者由数据加载脚本在运行时自动划分。使用说明文档里写了切分比例,我建议在复现时保持默认,除非你有明确理由去改变它。文本匹配任务里,训练集和验证集的分布一致性直接影响指标,不要为了验证集指标好看而手动挑样本,这类操作在答辩时经不起问。数据规模方面,示例数据只是用来跑通流程,量级不会很大;如果你要做正式实验,需要用完整版数据或自行构建。
3.2 自定义数据构建的推荐做法
项目正文强调自定义数据只需要仿照示例格式即可,但实际操作中有几个细节值得注意。首先,text1和text2的文本内容里不要夹带制表符,因为标签和两段文本之间都用\t分隔——文本内部一旦出现制表符,读取时会多拆出一个字段,轻则这条样本被丢弃,重则整个数据加载阶段报列数不一致的错。其次,文本里的换行符也要处理干净,一个评论不可能跨多行;处理MySQL或Excel导出的数据时,行尾可能带着\r\n,需要在写入前统一清洗。我一般会用下面这段代码做基本的格式校验和统计:
import pandas as pd # 读取TSV文件,显式指定分隔符为制表符 df = pd.read_csv('data/comment_classify/train.tsv', sep='\t', header=None, names=['text1', 'text2', 'label'], quoting=3) # 检查列数是否都是3列 print("总样本数:", len(df)) print("列数异常样本:", df.shape[1] if df.shape[1] != 3 else "无") # 检查是否存在空文本 print("text1空值:", df[df['text1'].isna() | (df['text1'].astype(str).str.strip() == '')].shape[0]) print("text2空值:", df[df['text2'].isna() | (df['text2'].astype(str).str.strip() == '')].shape[0]) # 检查标签分布 print("标签分布:\n", df['label'].value_counts()) # 检查文本中是否混入制表符 has_tab = df['text1'].astype(str).str.contains('\t') | df['text2'].astype(str).str.contains('\t') print("文本内含制表符的样本:", has_tab.sum())这段代码做的事情是:读入TSV文件、检查字段数量、检查空文本、统计标签分布、检查文本内是否混入制表符。参数说明:sep='\t'是制表符分隔,header=None表示原始文件没有表头,quoting=3对应QUOTE_NONE,防止文本里的引号干扰解析;names给三列起了名字方便后续调用。如果发现标签分布严重偏斜,比如负样本占了95%以上,训练时就要考虑类别权重或采样策略,否则模型会学会“全预测0”来刷准确率,这个坑后面避坑章节还会细讲。清洗后的数据建议统一用UTF-8编码写入TSV文件,Pandas默认的to_csv分隔符是逗号,要记得显式指定sep='\t'。
3.3 构建与加载流程的完整代码
为了在训练脚本里直接复用,自定义数据集建议保持和示例数据完全相同的目录结构。我一般把数据拆成train.tsv、dev.tsv、test.tsv三个文件,对应训练、验证、测试集合。切分时注意使用固定的随机种子,保证每次复现的结果一致:
from sklearn.model_selection import train_test_split # 加载全部数据 df = pd.read_csv('raw_data.tsv', sep='\t', header=None, names=['text1', 'text2', 'label'], quoting=3) # 先切出测试集,再在剩余部分切分训练集和验证集 train_val, test = train_test_split(df, test_size=0.15, random_state=42, stratify=df['label']) train, dev = train_test_split(train_val, test_size=0.15 / 0.85, random_state=42, stratify=train_val['label']) # 写入三个文件,统一格式 train.to_csv('data/comment_classify/train.tsv', sep='\t', index=False, header=None) dev.to_csv('data/comment_classify/dev.tsv', sep='\t', index=False, header=None) test.to_csv('data/comment_classify/test.tsv', sep='\t', index=False, header=None) print(f"训练集: {len(train)} 条, 验证集: {len(dev)} 条, 测试集: {len(test)} 条")逻辑说明:先用train_test_split把原始数据拆成训练加验证的整体和测试集,stratify=df['label']按标签比例分层采样;第二步再从训练验证整体里拆出验证集,比例要按剩余基数重新计算。参数说明:test_size=0.15是测试集占比,random_state=42固定随机种子保证可复现;这里第二次切分的test_size=0.15/0.85是因为当前基数已经是原来的85%,要让验证集占原始数据的15%,需要用剩余比例换算。分好文件后最好做一次分层比例检查,三个文件的标签分布应该和原始数据基本一致;如果分布有明显偏移,说明切分方式有问题,回头检查随机种子的使用位置。
4. 三个模型训练实操:脚本参数、训练逻辑与性能指标
4.1 环境准备与依赖确认
项目基于PyTorch和transformers库实现,第一步先把依赖装齐。项目的requirements.txt在上级目录,也就是../../requirements.txt,安装时要注意路径,没有激活虚拟环境就直接pip安装会把依赖装进系统Python里,污染全局环境。我习惯拿到项目先创建一个独立的虚拟环境:
python -m venv venv source venv/bin/activate pip install -r ../../requirements.txt逻辑说明:这三步创建虚拟环境、激活环境、按照依赖清单安装包。参数说明:python -m venv venv指定用当前解释器创建名为venv的虚拟环境;source venv/bin/activate在Linux和macOS下激活环境,Windows用户需要换成venv\\Scripts\\activate;requirements.txt里主要是torch、transformers、datasets、scikit-learn等,如果网络较慢可以用镜像源加速。安装完成后用python -c "import torch; print(torch.__version__)"验证一下,PyTorch和CUDA版本不匹配时,能导入但训练时可能报设备端的错误,这类问题放到避坑章节处理。
4.2 PointWise单塔训练:脚本参数逐项拆解
进入项目根目录后,找到训练脚本train_pointwise.sh。它本质上是一行很长的Python启动命令,把超参数通过命令行参数传进去。下面是这个脚本最常见的参数组织形式,实际以项目内文件为准:
python train_pointwise.py \ --model_name_or_path bert-base-chinese \ --train_file data/comment_classify/train.tsv \ --dev_file data/comment_classify/dev.tsv \ --output_dir output/pointwise \ --max_seq_length 128 \ --batch_size 32 \ --learning_rate 2e-5 \ --num_epochs 3 \ --warmup_ratio 0.1 \ --seed 42 \ --do_train \ --do_eval逻辑说明:这是BERT分类模型的常规微调命令,--do_train和--do_eval两个开关控制是否执行训练和验证。参数说明:bert-base-chinese是中文预训练模型,如果你的数据是英文需要换成bert-base-uncased;max_seq_length是拼接后的总长度上限,超过会被截断;batch_size受显存限制,12GB显存以下建议保持16或32;learning_rate用BERT微调的经典值2e-5,不要调成1e-3,预训练模型微调的学习率过大直接导致灾难性遗忘。训练完成后,test集合上的分类准确率通常能到85%到95%,具体取决于数据难度和数据量。
关于PointWise的训练细节,还有一点值得注意:它的输入是“text1 + 分隔符 + text2”,在模型内部会自动加上起始标记和分隔标记,你不需要手动处理。但如果你的文本特别长,比如类目定义写了好几行,截断策略默认是从右侧截断,有可能把后面评论的关键内容截没了。常见做法是设置--truncation_strategy only_second,优先保留text1完整,只截断text2;这两个字段谁更重要取决于业务逻辑,商品评论匹配场景里类目定义是标准描述,评论是噪声更大的文本,保留text1更合理。
4.3 DSSM双塔训练:注意权重共享开关
DSSM的训练脚本和PointWise相比,最大的区别是多了一个双塔特有的参数——是否共享权重。在共享权重模式下,text1和text2编码器用同一套参数;不共享时两个塔各有一套独立的BERT参数。常见做法是共享,因为数据集规模有限时,共享权重可以降低过拟合风险:
python train_dssm.py \ --model_name_or_path bert-base-chinese \ --train_file data/comment_classify/train.tsv \ --dev_file data/comment_classify/dev.tsv \ --output_dir output/dssm \ --max_seq_length 128 \ --batch_size 64 \ --learning_rate 2e-5 \ --num_epochs 3 \ --max_len 50 \ --share_encoder \ --similarity_metric cosine \ --do_train \ --do_eval参数说明:DSSM双塔独立编码两段文本,max_seq_length会分别作用于text1和text2,而不是拼接后的总长,所以batch size可以开到比PointWise更大,显存压力小;share_encoder是共享两个塔参数的开关,去掉它就成了双塔双参数版本;similarity_metric可选cosine或dot,余弦相似度把向量归一化到长度为1再算内积,对向量长度不敏感,通常更稳定。这里有个易错点:很多代码实现里,如果你选了点积相似度,两个向量的模长会影响分数,模型可能会学偏;如果发现训练loss下降很快但验证准确率不涨,先检查是不是相似度函数选择和归一化不匹配。
DSSM双塔在训练时还有一类隐藏问题:Batch内负样本策略。有些实现会把同一Batch内其他样本的text2当作当前样本的负样本,这种策略能极大增加负样本数量,但也让loss的计算方式变得复杂。项目里如果默认是这种Batch内负样本模式,batch size的选择会显著影响最终效果——batch越大,负样本越多,模型收敛越慢但泛化越好;如果batch size只有8,Batch内负样本几乎等于没加,效果退化成只有正负对二分类。复现时建议先确认训练代码用的是哪种负样本策略再调batch size。
4.4 Sentence BERT训练:双塔向量拼接再做分类
Sentence BERT脚本和DSSM的差异主要在网络头部和损失函数:
python train_sentence_bert.py \ --model_name_or_path bert-base-chinese \ --train_file data/comment_classify/train.tsv \ --dev_file data/comment_classify/dev.tsv \ --output_dir output/sentence_bert \ --max_seq_length 128 \ --batch_size 64 \ --learning_rate 2e-5 \ --num_epochs 3 \ --share_encoder \ --pooling_strategy cls \ --do_train \ --do_eval逻辑说明:Sentence BERT在拿到两个句向量后不是直接算余弦,而是把两个向量拼接起来,同时计算它们的逐元素差值绝对值,一起送入一个全连接分类层。pooling_strategy控制句向量怎么从token序列里来,cls表示取起始标记的向量作为整句表示,mean表示对所有token向量做平均。参数说明:中文匹配任务里,cls和mean的效果差异没有公认定论,需要自己在验证集上比较;但如果你的文本长度差异很大,mean对长文本更公平,cls受开头token影响更大。训练时用验证集的准确率选模型,不要觉得loss低就一定对应好的匹配效果。
三种模型训练完,output目录下会各自保存微调后的模型权重和分词器文件。评估时除了准确率,我建议额外打印出每个模型的参数量、单条样本推理耗时,这两项数据在毕设论文里比准确率更能体现工作量。推理耗时的测量方式是把测试集每个batch送入模型并计时,注意要去掉数据加载的时间,用torch.cuda.synchronize()确保GPU计算完成后再取时间差。
5. 训练与推理避坑记录:显存溢出、权重共享、数据清洗的五条血泪经验
5.1 显存溢出:同样batch size,单塔比双塔更容易崩
现象:跑PointWise训练脚本时,batch size设为32,程序报CUDA out of memory,但换成DSSM同批次大小却能正常跑。原因:PointWise把两段文本拼接后一起过BERT,一条样本的token数是text1和text2之和;DSSM两个塔分别处理,虽然总token数一样,但实际计算时每个塔的序列长度只有一半,显存占用明显更低。解决:先降batch size到16或8跑通,再用梯度累积模拟大batch。把这句提示放在显存优化时很有用。
提示:点Wise模型的batch size和DSSM不具可比性,不要用同一份显存预算套不同架构。
5.2 制表符被替换成空格:数据加载静默错位
现象:训练集加载后准确率一直不涨,loss也不下降,打印前几条样本发现text1和text2的边界不对,有的样本只有两列内容。原因:数据文件被人用文本编辑器打开过,编辑器自动把制表符转成空格;或者原始数据的文本里本身带了制表符,split后列数超过三列。解决:用Python脚本检查每行列数,不符合的直接过滤掉或重新清洗:
with open('data/comment_classify/train.tsv', 'r', encoding='utf-8') as f: for line in f: parts = line.rstrip('\n').split('\t') if len(parts) != 3: print("异常行:", parts)5.3 权重共享开关不一致:训练和推理完全不同的表现
现象:训练时开了--share_encoder,推理脚本里却默认加载了两个独立的编码器权重,结果模型效果退化到接近随机。原因:共享权重训练出的模型只有一个编码器,而推理脚本按非共享模式加载时,text2那个塔的权重随机初始化了。解决:检查模型保存目录里的config.json或推理脚本参数,确认推理配置和训练配置严格一致。这个坑在复现开源项目时特别容易出现,因为训练和推理往往拆成两个脚本,参数不一致时完全不报错。
5.4 短文本被过度截断:类别定义被裁没了
现象:验证集准确率正常,但预测时发现所有样本都被预测为同一个标签。排查后发现不是因为模型坏了,而是某条评论特别长,text1加text2超过max_seq_length,默认截断策略从右侧裁掉了text2的尾部,但有效的判别信息在text2里。解决:设置截断策略为只截断第二段,或者把max_seq_length调大。文本匹配任务中,两段文本对任务的贡献往往不均等,看清哪边是判别关键再决定截断策略。
5.5 标签极度不平衡:全预测负样本准确率还很高
现象:数据集里标签为0的样本占了90%,训练后验证集准确率85%,看起来还不错,但打印classification_report发现正样本的召回率几乎为0。原因:模型学会了所有样本都预测0,因为这样已经能拿到90%准确率,交叉熵损失也偏向多数类。解决:给训练脚本加类别权重参数,或者对负样本做下采样。Pytorch的CrossEntropyLoss可以直接传weight,正类的权重设成负样本数除以正样本数;更通用的做法是计算pos_weight = 负样本数 / 正样本数,放在损失函数里。
6. 进阶优化与评估:难负样本和温度系数让双塔模型精度再上一个台阶
跑通基线之后,双塔模型还有两个性价比极高的优化手段,值得在毕设实验或工程落地时加上。第一个是难负样本挖掘,第二个是温度系数。DSSM和Sentence BERT的负样本如果过于简单,模型学不到细粒度的语义差异;商品评论匹配场景里,一条关于“衣服尺码不正规”的评论,它的负样本不该只是“洗发水漏了”这种完全无关的文本,而应该是“这条裤子版型偏小”这类和服装相关但判定不一致的样本。常见做法是先用基线模型做一轮推理,把预测分数高但标签为负的样本挑出来,作为额外难负样本加入训练集,或者直接改变采样权重让难的负样本出现频率更高。
温度系数影响的是相似度到概率的映射陡峭程度。在双塔模型里,logits通常直接取余弦相似度乘以一个标量系数,这个系数就是温度。默认用1.0时,相似度在0.8以上才能给出较高的正样本概率;温度调低到0.05到0.2,会让相似度差异成倍放大,模型更容易区分相近的文本对。实践中温度系数是双塔模型里最值得调的超参数之一,常常比学习率的影响还大:
temperature = 0.1 # 可调,常用范围 0.05 ~ 0.5 logits = cosine_similarity(query_vec, doc_vec) / temperature loss = torch.nn.functional.binary_cross_entropy_with_logits(logits, labels)逻辑说明:除以温度系数等同于放大logits的绝对值,让正负样本的分数差距在损失函数里被强调。参数说明:温度太小会让梯度爆炸、训练不稳定;太大则会让损失对相似度的变化不敏感,等于没调。
评估双塔模型还有一个要注意的习惯:单独跑分类准确率是不够的,工业场景通常用Recall@K来评估——一条评论和候选类目标签排序,看正确类目是否出现在前K个结果里。用Sentence BERT或者DSSM得到所有类目向量后,直接做矩阵乘法算出每条评论对所有类目的分数,排序取TopK:
import numpy as np # query_vecs: 评论向量 (N, D), doc_vecs: 类目向量 (M, D) scores = np.dot(query_vecs, doc_vecs.T) # (N, M) top_k_indices = np.argsort(-scores, axis=1)[:, :10] # 取每行前10个 # 计算 Recall@10 hit = 0 for i, true_label in enumerate(test_labels): if true_label in top_k_indices[i]: hit += 1 print("Recall@10:", hit / len(test_labels))这段代码的优点是绕开了分类阈值,直接看排序位置,适合业务中“从一百个类目里快速锁定最可能的几个”的场景。从那以后我每次做完双塔模型,都不会只报准确率,而是强制走一遍Recall@1、Recall@5、Recall@10的评估流程,至少能直观看到双塔模型在候选集规模变大时召回的衰减速度——这也是双塔和单塔在工程上拉开差距的关键视角。希望帮你把这个项目用得更透,少走我当年实验时走过的弯路。
本文还有配套的精品资源,点击获取