简介:这是一份聚焦人工神经网络的中文语义相似度计算研究项目包,适合人工智能、深度学习方向的毕业设计或课程设计使用。包内共139个文件,包含Java源码、MATLAB数据文件、文本说明以及大量训练/测试数据和标签文件,压缩包约4.95MB,结构清晰,便于直接查看模型实现、数据划分与评估过程。已有106人学习下载。内容覆盖语义相似度的经典与前沿方法,包括预训练模型、CNN/RNN/LSTM/Transformer等结构,以及数据增强、对抗训练、模型压缩与部署等关键环节;同时还包含语义词典与项目配置文件,可辅助理解模型输入的构建与评测基准。通过实际代码和配套数据集,读者可以复现实验流程,深入理解中文语义匹配中的多义性、词序依赖等问题,并在此基础上完成论文写作、答辩演示或功能扩展。
1. 从"中文语义相似度计算研究.zip"出发,先确定问题边界
看到"研究.zip"这样的标题,我第一反应不是急着解压,而是先确认问题边界。中文语义相似度计算,是判断两句话语义是否相近的任务,比如"萝卜刀是什么意思"和"萝卜刀指什么"应判为等价,它是FAQ匹配、长文本检索、舆情去重的底层能力。人工神经网络的核心价值在于把句子映射成稠密向量,避免单纯依据词面重合。中文没有天然空格,分词误差会直接传导进网络,所以预处理和字符级切片往往比模型结构对效果影响更大。这里按一条可复现路径展开:中文语料清洗、网络选型、训练参数、评测指标,以及模型导出与交付时的坑。
2. 中文语料预处理:字符级切片比分词更适合神经语义模型
2.1 分词不是必须的:先想清楚输入单位
中文语义相似度的经典做法是先分词,再做词向量或词频特征。但神经网络的输入端如果仍然用分词后的词表,就会引入两个问题:一是分词器本身无法覆盖领域新词,比如"电子烟"在新闻语料里可能被切错;二是词表规模大,低频词浪费参数量,而训练语料里出现一次的词往往学不充分。常见做法是退回到字符级切片,把每个汉字当作一个token,再让网络自动学习相邻字符的组合。这跟BERT用的WordPiece思路一致——先保证没有未登录词,再让上游任务去学语义。对中文而言,字符级输入还有一个额外好处:不需要额外维护分词词典,数据预处理链路更短,复现时不容易受到分词插件版本差异的干扰。如果你做的是短文本判重,字符级切片通常够用;只有极依赖词语边界的中文任务,比如关键词抽取,才需要回退到分词。
2.2 构建一个最小可用的中文字符表
先把语料里的所有句子读进来,统计字符频率,过滤掉换行、空格和控制字符,同时保留常见中英文标点。下面的代码实现了一个通用的char-level vocab构建方法:
from collections import Counter def build_char_vocab(sentences, max_vocab=20000, min_freq=1): counter = Counter() for sent in sentences: # 只保留可见字符,跳过控制符和普通空格 for ch in sent: if ch == '\n' or ch == '\r' or ch == '\t': continue if ch == ' ': continue counter[ch] += 1 # 按频率从高到低排序,截断到max_vocab items = counter.most_common(max_vocab) items = [(w, f) for w, f in items if f >= min_freq] vocab = {'<pad>': 0, '<unk>': 1, '<cls>': 2, '<sep>': 3} for w, _ in items: if w not in vocab: vocab[w] = len(vocab) return vocabbuild_char_vocab返回一个dict,<pad>用0作为填充对齐,<unk>负责映射没有进词表的生僻字符。max_vocab决定模型embedding矩阵的宽度,一般中文业务语料设20000可以覆盖绝大多数场景;如果你做垂直领域,比如法律文书,建议把高频专业字放开,不要强行限制。min_freq用于过滤语料里只出现一次的罕见字符,减少embedding里的无效行,我一般设1,只有遇到明显脏数据时才改成3。
2.3 把句子编码成定长序列
得到vocab后,还需要把一条句子转成等长的token id序列。中文句子长度差异很大,而神经网络一个batch里必须等长,最简单的方法是截断加padding。下面的代码处理编码和mask,为后续双塔模型提供输入:
def encode_sentence(text, vocab, max_len=64): ids = [] for ch in text.strip(): if ch in vocab: ids.append(vocab[ch]) else: ids.append(vocab['<unk>']) # 预留cls与sep两个位置,后续拼接BERT输入时不会越界 ids = ids[:max_len - 2] ids = [vocab['<cls>']] + ids + [vocab['<sep>']] pad_len = max_len - len(ids) ids = ids + [vocab['<pad>']] * pad_len mask = [1] * (len(ids) - pad_len) + [0] * pad_len return ids, mask这里的max_len我一般结合业务分布来定。统计训练集里95分位长度,如果95%的句子不到60个字,max_len=64已经合理;直接给128会拉长batch内padding,增加无谓的GPU计算。注意在截断时预留了<cls>和<sep>两个位置,如果你后续要拼接两个句子做BERT输入,这个细节能避免序列长度越界。
2.4 标注数据的组织与三种dataset形态
训练相似度模型,线上常见的标注数据有三种形态:第一种是二分类对,每行是句子1 \t 句子2 \t 0/1;第二种是打分对,标签是1到5的连续分数;第三种是只给定句子列表,让你用同义改写自动构造正样本。对初次跑通基线,建议优先用第一种。读取和打batch的代码很直接:
def load_pairs(path, vocab, max_len): data = [] with open(path, encoding='utf-8') as f: for line in f: parts = line.rstrip('\n').split('\t') if len(parts) != 3: continue s1, s2, label = parts[0], parts[1], parts[2] ids1, mask1 = encode_sentence(s1, vocab, max_len) ids2, mask2 = encode_sentence(s2, vocab, max_len) data.append((ids1, mask1, ids2, mask2, int(label))) return data更正式的写法是写一个PyTorch Dataset,在每个__getitem__里实时编码,这样内存里不用一次性保存全量序列。表格归纳一下预处理阶段需要维护的三个核心参数:
| 参数 | 建议初始值 | 作用与注意点 |
|---|---|---|
| max_len | 64 | 需要比训练语料95%长度多2个预留位 |
| vocab_size | 20000 | 垂直领域可放大,罕见字过多时限制min_freq |
| pad_id | 0 | 必须保证embedding里第一行是全零向量 |
最后一个细节:pad_id对应的embedding向量最好初始化为零。用PyTorch的nn.Embedding(padding_idx=0)只能保证反向传播时pad位不更新,要真正让pad位置不贡献计算,还需要在pooling时把mask乘进去。很多人只做了padding,忘记mask,导致短句被pad噪声干扰,这是相似度模型效果差的一个常见原因。
3. 人工神经网络选型:先跑通Siamese TextCNN,再决定要不要上BERT
3.1 为什么用孪生网络(Siamese)而不是双塔
处理中文语义相似度,第一步要确定网络结构。最常见的做法是Siamese Network,也叫双塔结构:两个句子分别经过同一个编码器,得到两个语义向量,再计算余弦相似度或拼接后过一个分类层。这里强调"同一个编码器",是因为共享权重能保证同样的一句话无论放在左侧还是右侧,都会被映射到相同向量空间。如果换成两个独立的编码器,模型在训练时很容易学到"句子在左就往左偏、在右就往右偏"的表征,效果反而变差。
与直接拼接两个句子输入一个大模型(cross-encoder)相比,双塔在训练时精度会略低,但推理时两个塔可以分别缓存向量,用向量检索库做近邻搜索。对于线上FAQ匹配这种数据量大的场景,双塔几乎是唯一可接受的选择。所以顺序应该是:先跑通双塔基线,确认数据没问题,再考虑cross-encoder做精度上限验证。
3.2 从零实现一个TextCNN编码器
用PyTorch写一个可以直接跑的TextCNN编码器,隐藏维度选128,卷积核大小分别取2、3、4,并在pooling时用mask消除padding影响:
import torch.nn as nn import torch.nn.functional as F class TextCNNEncoder(nn.Module): def __init__(self, vocab_size, embed_dim=128, num_filters=128): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.convs = nn.ModuleList([ nn.Conv1d(embed_dim, num_filters, kernel_size=k) for k in (2, 3, 4) ]) self.out_dim = num_filters * 3 def forward(self, ids, mask): # ids: [batch, max_len], mask: [batch, max_len] emb = self.embedding(ids) # [batch, max_len, embed_dim] emb = emb.transpose(1, 2) # Conv1d需要通道维在第二维 mask = mask.unsqueeze(1).float() # [batch, 1, max_len] outputs = [] for conv in self.convs: c = conv(emb) # [batch, num_filters, conv_len] c = c - (1 - mask[:, :, :c.size(2)]).float() * 1e9 pooled = F.max_pool1d(c, c.size(2)).squeeze(2) outputs.append(pooled) return torch.cat(outputs, dim=1) # [batch, num_filters*3]padding_idx=0让embedding中<pad>位不参与梯度更新。mask在卷积后先做遮盖,再用max_pool1d取每个卷积核输出的最大值,这样padding位置的负无穷不会干扰池化结果。out_dim是三个卷积核输出的拼接维度,供后面的相似度头使用。TextCNN参数少,在短文本场景下训练速度快,适合作为第一个基线。
3.3 相似度输出头的两种挂法
编码器上面需要接一个输出层。如果目标是二分类,可以把两个向量拼接后过一个Linear(256, 2),再用交叉熵;如果目标是输出一个0到1的相似度分数,建议保留余弦相似度或曼哈顿距离,不接分类层。下面是一个支持两种模式的相似度头:
class SiameseModel(nn.Module): def __init__(self, encoder, use_classifier=True): super().__init__() self.encoder = encoder self.use_classifier = use_classifier if use_classifier: self.cls = nn.Linear(encoder.out_dim * 2, 2) def forward(self, ids1, mask1, ids2, mask2): vec1 = self.encoder(ids1, mask1) vec2 = self.encoder(ids2, mask2) if self.use_classifier: return self.cls(torch.cat([vec1, vec2], dim=1)) return F.cosine_similarity(vec1, vec2, dim=1)分类头的好处是门槛低,直接用nn.CrossEntropyLoss就能训练,并且能让模型自动学习"哪些维度差异更重要";缺点是输出分数不能直接作为距离使用,还需要再做一层校准。余弦相似度则更适合后续做向量检索,但训练时收敛较慢,需要搭配对比损失。我会先跑分类版本,确认效果正常,再切到余弦版本做线上距离计算。
3.4 BERT作为编码器:什么时候值得换
TextCNN跑通后,下一步自然想到预训练模型。常见做法是用transformers库加载一个中文BERT权重,把上面的TextCNNEncoder替换成BERT的pooler_output。换掉后训练要关注的三个差异:BERT输入需要input_ids、attention_mask、token_type_ids;学习率要从1e-3降到2e-5到5e-5;batch size要尽量大,至少32以上,否则LayerNorm统计不稳定。如果训练数据只有几千条,先不要动BERT的全部参数,可以冻结前面几层,只微调最后两层和相似度头,否则很容易过拟合。
表格对比三种编码器在中文短文本上的折中:
| 编码器 | 参数量级 | 训练速度 | 是否需要外部预训练 | 上线部署成本 |
|---|---|---|---|---|
| 随机初始化TextCNN | 百万级 | 快 | 否 | 低 |
| Word2Vec初始化TextCNN | 千万级 | 快 | 是 | 低 |
| BERT/RoBERTa | 亿级 | 慢 | 是 | 高,需GPU或量化 |
这个表格不是用来看绝对数值,而是帮助在项目初期做选型。数据量小、对延迟敏感,就选第一行;数据量超过50万条、效果要求高,再上第三行。这里没有给出精确的准确率,因为相似度任务的成绩严重依赖数据分布,换一个领域结论就可能反转。
4. 相似度训练的关键配置:损失函数、采样策略与早停参数
4.1 二分类与对比损失之间怎么选
中文语义相似度训练的损失函数常见有三类:交叉熵、对比损失、三元组损失。交叉熵适合标签为0/1且两类均衡的数据集;对比损失适合把相似度作为连续分数使用的场景,它通过一个margin参数控制正负样本向量之间的距离边界。三元组损失更常用于稠密检索,需要额外的困难负样本构造。初次训练,我倾向于从交叉熵开始,因为它对随机初始化的网络最稳定。
下面的代码片段实现了交叉熵方式下的训练循环:
import torch from torch.optim import AdamW optimizer = AdamW(model.parameters(), lr=2e-3) loss_fn = nn.CrossEntropyLoss() for epoch in range(max_epoch): model.train() total_loss = 0.0 for batch in train_loader: ids1, mask1, ids2, mask2, label = batch logits = model(ids1, mask1, ids2, mask2) loss = loss_fn(logits, label) optimizer.zero_grad() loss.backward() # 梯度裁剪防止RNN/CNN训练的梯度爆炸 nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() total_loss += loss.item()AdamW是Adam的权重衰减修正版,比Adam更常出现在现代NLP任务中。clip_grad_norm_设成1.0,主要避免偶尔的长文本样本产生异常梯度,这个值不是越大约好,设太大会失效,设太小会训练缓慢。交叉熵的label必须是0和1的LongTensor,如果从文件读到的是字符串,需要先显式转换为整数。
4.2 超参数表:先按这套跑通再调整
训练超参数不需要一开始就精调,我常用下面这组初始值:
| 超参数 | 初始值 | 调整方向 |
|---|---|---|
| batch_size | 64 | GPU显存不足时先降到32,优先保持一个batch内类别均衡 |
| learning_rate | TextCNN 2e-3,BERT 2e-5 | 损失震荡时降低;收敛过慢时轻微提高 |
| max_epoch | 10 | 配合早停,不要靠固定epoch数 |
| warmup_ratio | 0.1 | BERT类模型需要,TextCNN可以设为0 |
| weight_decay | 0.01 | 过拟合时增大到0.05 |
| dropout | 0.3 | 数据量小时提高到0.5 |
这里重点看warmup_ratio。BERT这种预训练模型在微调早期十分敏感,直接上较大学习率会让权重偏离预训练分布,所以前10%的step用线性从0升到目标值;TextCNN本身没有预训练权重,不需要warmup。dropout建议放在编码器最后一个全连接之前,而不是每个卷积层后面都加。
4.3 Batch内负采样:用同一个batch里的其他样本当难例
如果训练数据是按"句子对+标签"给的,标签0和1的比例可能很悬殊。常见做法是batch内负采样:每个batch里保证一半正样本对,另一半从当前batch中随机换掉一个句子,构成新的负样本对。这样不仅解决类别不均衡,还能让模型在相似的句子中学习更细的区分。下面的代码演示了采样逻辑:
def sample_pair_batch(pairs, batch_size, neg_ratio=0.5): # pairs是全部正样本对,对每个batch动态构造负样本 batch = [] while len(batch) < batch_size: s1, s2, _ = pairs[np.random.randint(len(pairs))] if np.random.random() >= neg_ratio: batch.append((s1, s2, 1)) else: # 随机挑一条其他句子作为s2的干扰项 s3 = pairs[np.random.randint(len(pairs))][0] batch.append((s1, s3, 0)) return batchneg_ratio=0.5表示每个batch里有一半是负样本。这里的pairs必须都是正样本对,负样本从不同句子里临时组合。这种做法有一个隐患:随机挑的干扰句很可能与原句差距过大,模型学到的边界过于宽松,因此更进阶的做法是先在向量空间里找语义接近但标签不同的"难负样本"。
4.4 早停和检查点:验证集F1不再上升就停
训练时只盯着损失值容易误判,因为损失在下降不代表语义相似度在提升。我习惯每个epoch结束后在验证集上计算二分类F1,连续3个epoch F1没有增长就停止训练,并保存历史最优模型。参考代码:
best_f1 = 0.0 bad_epochs = 0 for epoch in range(max_epoch): train_one_epoch() f1 = evaluate_on_valid(model) if f1 > best_f1: best_f1 = f1 torch.save(model.state_dict(), 'best_model.pt') bad_epochs = 0 else: bad_epochs += 1 if bad_epochs >= 3: print('early stop at epoch', epoch) break这里torch.save保存的是state_dict,而不是整个模型。恢复权重时先新建同结构的模型实例,再调用load_state_dict。很多人图方便用torch.save(model),在重构工程时因为类名、定义顺序变化,容易加载失败。保存state_dict配合一个model_config.json记录参数,才是可复现的交付方式。
5. 评测语义相似度:用阈值校准和误差分析替代盲目看准确率
5.1 准确率不是唯一指标:先区分匹配任务和相关性任务
中文语义相似度评测有两种常见设定:一是判断两句是否同义,本质是二分类;二是给两句之间的相近程度打分,与传统相关性任务类似。对二分类,准确率在类别不均衡时会产生严重误导,例如95%标签为1,全预测1也有95%准确率,所以更可靠的指标是F1和AUC。对打分任务,业界更常用Spearman相关系数,它只关注顺序一致性,不要求分数绝对相等。下面的代码用sklearn快速计算几个核心指标:
from sklearn.metrics import f1_score, roc_auc_score, accuracy_score from scipy.stats import spearmanr # preds是模型输出概率,labels是0/1真实标签 auc = roc_auc_score(labels, preds) pred_label = (preds >= 0.5).astype(int) f1 = f1_score(labels, pred_label) acc = accuracy_score(labels, pred_label) # 打分任务:把模型输出的余弦相似度与人工评分计算spearman rho, _ = spearmanr(model_scores, human_scores)roc_auc_score只依赖预测概率,不依赖阈值,适合衡量模型排序能力;spearmanr则适合评估相似度分数与人工标注的单调关系。注意model_scores如果是相似度头直接输出的cos值,范围可能在[-1,1],不要做softmax,因为softmax会改变样本之间的相对顺序。
5.2 阈值校准:在验证集上扫描,而不是默认0.5
分类头或余弦相似度输出后,直接拿0.5当阈值并不稳妥。不同数据分布下的最佳阈值可能落在0.35到0.7之间,而且F1和准确率最大化的点通常不在同一位置。一个可复现的做法是在验证集上遍历候选阈值,选出F1最大的点:
best_thresh, best_score = 0.5, 0.0 for thresh in np.arange(0.3, 0.8, 0.01): pred_label = (preds >= thresh).astype(int) score = f1_score(labels, pred_label) if score > best_score: best_thresh, best_score = thresh, score print('best_threshold =', best_thresh, 'best_f1 =', best_score)arange(0.3, 0.8, 0.01)的步长可以根据验证集大小调整,验证集越小,步长越大,防止阈值点过密引起过拟合。选出最佳阈值后,还需要在另一个独立的测试集或线上A/B中确认,直接拿验证集阈值上线,通常会高估效果。
5.3 分组误差分析:按长度和字符重叠度拆开看
只报告一个整体F1会掩盖模型在某个子集上的失败。我会把测试样本按三个维度分组:句子长度、两句话的字面重叠率、是否包含数字或英文。然后分别打印F1,快速定位是哪类样本拉低了成绩。代码示例:
for s1, s2, label, pred in zip(s1_list, s2_list, labels, pred_labels): overlap = len(set(s1) & set(s2)) / max(len(set(s1)), len(set(s2)), 1) group = 'high_overlap' if overlap > 0.5 else 'low_overlap' # 把样本按group聚合分组 # 每个group单独计算F1,打印出来重叠率高仍然判错的样本,说明模型只学习了字面匹配,没有学到语义改写;重叠率低但判为同义的样本,则是模型真正学到了同义表达。分析时要优先看前一类,因为它直接影响线上系统的误判。为了可持续追踪,建议把误差分析结果导出成json文件,并记录模型版本、数据版本和超参数。
5.4 记录实验的表格模板
不要只记一个最终F1,我用下面这个模板维护每次实验:
| 模型 | 输入单位 | 数据版本 | best_threshold | F1 | 低重叠F1 | 高重叠F1 | 参数量 |
|---|---|---|---|---|---|---|---|
| TextCNN | char | v1 | 0.51 | 待填入 | 待填入 | 待填入 | 1.2M |
| BERT-base | char | v1 | 0.63 | 待填入 | 待填入 | 待填入 | 110M |
这里的数值都不填,因为一组实验的真实成绩会随着随机种子变化。记录时每次固定随机种子,并保存对应checkpoint的哈希值,这样别人从zip包里复现时才不会因为随机性得出不同结果。
6. 交付一个中文语义相似度zip:模型导出与推理边界检查
6.1 用ONNX导出模型,避免依赖冲突
相似度模型训练完成后,交付给服务端时如果仍然依赖完整PyTorch,会因为版本、CUDA版本不一致而难以部署。我通常把模型转为ONNX,在CPU上也能稳定推理。以一个TextCNN的Siamese模型为例,导出代码如下:
import torch import onnxruntime as ort model.eval() dummy_ids = torch.zeros(1, 64, dtype=torch.long) dummy_mask = torch.ones(1, 64, dtype=torch.long) torch.onnx.export( model, (dummy_ids, dummy_mask, dummy_ids, dummy_mask), 'siamese_textcnn.onnx', input_names=['ids1', 'mask1', 'ids2', 'mask2'], output_names=['logits'], dynamic_axes={ 'ids1': {0: 'batch', 1: 'seq_len'}, 'mask1': {0: 'batch', 1: 'seq_len'}, 'ids2': {0: 'batch', 1: 'seq_len'}, 'mask2': {0: 'batch', 1: 'seq_len'}, }, opset_version=12 )导出后必须做一个对齐检查:用同一组输入分别跑PyTorch模型和ONNX会话,比较输出的最大绝对误差。常见错误是dynamic_axes没有把batch维度设为动态,导致上线后一次只能预测一个样本,吞吐量上不去。ONNX导出成功不代表推理结果一致,很多量化版本会因为opset版本不同产生几个百分点的误差。
6.2 推理时的中文边界检查:全角、繁体、大小写
中文语义相似度模型在实验室跑得准,上线后经常被奇怪输入击穿。我总结的检查顺序是:全角转半角、繁体转简体、数字归一化、英文大小写统一。下面的normalize_text函数可以放在推理入口:
def normalize_text(text: str) -> str: text = text.replace(',', ',').replace('。', '。') # 实际要维护完整映射 half = [] for ch in text: code = ord(ch) if code == 0x3000: half.append(' ') elif 0xFF01 <= code <= 0xFF5E: half.append(chr(code - 0xFEE0)) else: half.append(ch) return ''.join(half)这里的全角转换只展示核心写法,生产环境需要维护一张完整的unicode映射表。繁体转简体不要自己在代码里硬映射,常见做法是调用成熟的繁简转换库,但要注意两个方向的分词差异;数字归一化则是把所有连续数字替换成[NUM],避免模型把不同长度电话号码当成两个完全不同语义。这一步放在模型调用之前,可以让训练和上线数据分布保持一致。
6.3 压缩包里应该包含什么,以及解压时的检查
最后要交付一个"研究.zip"时,我会确认里面有这4类文件:模型目录(包含model.onnx和model_config.json)、词典文件(char_vocab.txt)、数据样例(经过脱敏的几百条验证集)、README(写明Python版本、依赖列表、运行命令)。解压后先运行一个自检脚本,校验vocab文件能覆盖推理用到的所有字符。如果解压时系统报error read zip archive,说明下载不完整,先重新下载或校验包完整度,不要急着改模型代码;我会在包里放一个check_env.py,跑完看到所有依赖版本和词表覆盖都正常,再继续后面的实验。
本文还有配套的精品资源,点击获取