ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

SimBERT+FAISS中文数据增强:用最近邻为NLP分类扩充标注数据

SimBERT+FAISS中文数据增强:用最近邻为NLP分类扩充标注数据 简介面向中文自然语言处理与数据增强实践场景压缩包内提供一套基于faiss索引与chinese simbert向量化的最近邻中文label数据增强实现适合需要扩充带标签小样本数据集的算法工程师与NLP学习者。资源共6个文件包括3个csv数据文件、1个yaml配置、1个faiss索引文件及1个Python主脚本压缩包整体约55.06MB结构紧凑便于直接对照运行。核心流程会在无标签数据集上构建faiss索引再为每条带标签文本检索最相似的无标签句子输出sim_text、sim_value、label三元组到扩充后的csv文件实现低成本数据扩充。工程依赖Python3.7、bert4keras0.11.3、faiss-gpu1.7.2与yaml6.0并附有mkl-service安装提示可快速搭建同款环境。目前已有197人学习浏览适合作为中文相似文本检索与半监督数据增强的训练参考。1. 用SimBERT向量化加FAISS最近邻做中文label数据增强先搞清楚它在解决什么做中文NLP分类任务时最头疼的往往不是模型结构而是标注数据不够。所谓数据增强在这套方案里指的是利用faiss索引和Chinese SimBERT向量化给每一条中文label数据找到语义最接近的几条邻居再把邻居文本连同原标签一起并入训练集。它能解决一个很具体的痛点标注量卡在几千条模型换了两三个仍然过拟合但你又没有预算和人力去标更多数据。这个方向适合手里已经有一批干净的中文文本分类数据、熟悉Python和基本NLP流程并且愿意静下心调一遍相似度阈值的人。思路听上去不复杂但落地时牵涉到模型选型、向量索引、阈值设定、去重和验证策略。任何一个环节草率增强出来的数据不仅没用还会污染原有训练集。下面我把整条链路拆开讲从原理到最小可跑代码把参数怎么定、坑在哪里一次说清楚。2. 先把原理踩实SimBERT向量、FAISS索引与最近邻增强为什么能成2.1 SimBERT为什么适合做中文句子向量做向量化最近邻增强前提是相似文本在向量空间里真的彼此靠近。原生BERT的CLS向量经常被批评为各向异性不同领域的句子向量会挤在一个狭窄的锥形空间里直接拿它计算最近邻排在前面的往往不是语义相似而是表层字面重合。SimBERT在预训练阶段使用了对偶对比学习把语义相同的句子拉近、语义不同的句子推远这让它的向量空间在中文短文本上比原生BERT更均匀、更有区分度。这里要强调的是SimBERT最初的目标是短文本相似度计算不是通用句子嵌入。它的特点是能抓住改个说法但意思不变的同类表达而这正好是数据增强需要的候选。对比回译增强SimBERT向量近邻不需要额外依赖机器翻译接口也不会产生大量语法怪异的长句对比同义词替换它不局限于词典能识别跨表达的意译关系。所以在这套项目实践里SimBERT承担的是语义相似度判别器的角色而不是常规的分类特征提取器。选型时还要注意一个容易踩的认知误区SimBERT只有一个中文领域版本英文场景并不适用。如果你的数据是混合语言要先把中文文本过滤出来或者再去寻找英文对应的SimBERT权重。向量维度通常在768计算方式和BERT一致这决定了后续FAISS的索引参数不需要额外适配。2.2 FAISS在这里的角色不是检索是给每个样本找邻居FAISS在工业界最常被用作向量召回引擎从千万级向量库里快速捞出与查询向量相似的topK。但在当前场景下我们不关心用户来了要搜什么而是关心训练集里每一条文本它和其他文本的语义距离是多少。所以FAISS的角色更像是一个并行的最近邻计算器利用索引结构和底层指令集把暴力搜索的耗时压缩到可接受范围。样本量在五万以内直接用numpy算全量内积矩阵也能跑内存开销大约是N * N * 4字节五万条就是10GB这还不算中间拷贝。把向量交给FAISS的IndexFlatIP内存同样会占用那么多但搜索时FAISS会帮你分块处理并且查询整个索引返回所有近邻的速度会比两层for循环快两个数量级以上。当样本量到百万级还需要考虑IVF这类倒排索引它是用聚类把向量空间划分成若干区域查询时只扫候选区域牺牲少量精度换取速度。在这个项目里FAISS还有一个隐藏优势支持向量按ID管理。每条文本的向量可以从1开始编号查询结果返回的是整数下标直接对应到DataFrame的行。这样就不需要手工维护一份“向量到文本”的字典避免后续去重和合并时出现下标错位。我们在编码时保持顺序和原始DataFrame一致查询结果里的indices矩阵就能直接当成样本编号用。2.3 最近邻增强的数据流从一条标注怎么变成一组标注把原理落实到操作层整个流程可以归纳成四步第一步用SimBERT把每一条标注文本转成高维向量并做L2归一化第二步把全部向量写入FAISS索引第三步对索引中每一条向量查询topK近邻排除自身第四步把符合条件的邻居文本与标签组合成新样本经过滤去重后合并进训练集。这里的核心决策是邻居文本的标签从哪里来。如果邻居本身就是有标注的最稳妥的做法是直接采用邻居自己的标签因为它是人工标注的结果可信度高。如果邻居是外部无标注语料那只能把当前样本的标签复制给邻居相当于打一条伪标签。伪标签的质量完全取决于SimBERT的相似度是否可靠所以阈值必须比前者更严格。我一般优先使用第一种来源也就是只在原始标注集内部寻找邻居。这种方式不会引入新的文本类型但能有效增加同一语义表达的多样性让模型在保持类别区分能力的同时对同义词和语序变化更鲁棒。只有当原始数据实在太少、内部邻居都是重复句子时才考虑引入外部语料。无论哪种来源相似度阈值都不能省否则“苹果好吃”和“苹果手机好用”这种语义确实接近但标签不同的噪声样本就会被当成增强样本吸收进来。3. 动手做用SimBERT向量化到FAISS最近邻增强的最小可跑流程3.1 环境准备与模型加载少不了的几个包和硬件要求先说依赖项Python 3.8以上需要安装faiss-cpu或faiss-gpu、pandas、numpy、torch以及sentence-transformers。如果只想跑通流程faiss-cpu足够几万条文本的建索引和查询都在秒级。样本量超过百万时再考虑GPU版而且GPU版在调用多块卡时需要显式指定资源并不是装上就自动加速。模型加载我推荐通过sentence-transformers接口因为它内部封装了tokenize、池化、归一化等步骤能让我们把精力集中在FAISS参数上。SimBERT原始权重是TensorFlow格式需要先转换成sentence-transformers能加载的目录结构。转换方式一般是先下载官方TF权重再利用transformers的转换脚本生成PyTorch权重最后用sentence-transformers的Snapshots目录组织起来。如果不想折腾本地临时用其他中文句向量模型占位也可以但线上复现时仍然推荐换回SimBERT。from sentence_transformers import SentenceTransformer # 改成你本地转换好的 chinese-simbert 目录 MODEL_PATH /data/models/simbert-st model SentenceTransformer(MODEL_PATH) def embed_texts(texts, batch_size64): 批量向量化中文文本。 返回经过 L2 归一化的 float32 向量shape(N, emb_dim)。 emb model.encode( texts, batch_sizebatch_size, show_progress_barTrue, convert_to_numpyTrue, normalize_embeddingsTrue, # sentence-transformers 直接做 L2 归一化 ) return emb.astype(np.float32)这段代码里最关键的是normalize_embeddingsTrue它保证后续FAISS用内积计算时得到的就是余弦相似度不需要自己再除模长。batch_size根据显存或内存调整文本越长、batch越大越吃内存。我一般先把batch设置成64如果内存溢出再降到32。convert_to_numpyTrue是为了直接拿到numpy数组给FAISS避免在torch和numpy之间反复转换省掉不必要的开销。如果不用sentence-transformers而是直接用transformers加载BERT类模型就必须自己决定池化策略取CLS向量还是做均值池化。这个决策对SimBERT的影响很大因为SimBERT的对比学习目标可能已经内置了特定的池化方式。直接取CLS在SimBERT上往往表现一般所以除非你完全清楚原始实现否则不建议自己手写池化。3.2 构建向量库把已有标注数据全部过一遍SimBERT假设原始数据是一份CSV两列分别为text和label。读进来之后先做清洗这一步比想象中重要。去掉空文本、只含标点的文本把换行符和连续空格替换成单个空格这些脏字符会让SimBERT的分词结果发生偏移向量被推向一个不合理的位置。import pandas as pd df pd.read_csv(train.csv, encodingutf-8-sig) df[text] df[text].astype(str).str.strip() df df[df[text].map(len) 1].reset_index(dropTrue) vectors embed_texts(df[text].tolist()) print(vectors.shape) # (N, emb_dim) print(vectors.dtype) # float32清洗后调用embed_texts一次性编码全部样本。df[text].map(len) 1过滤了空字符串和单字符文本单字符文本在SimBERT向量空间里的位置一般很边缘不适合作为邻居参考。向量数组的第二个维度是embedding维度常见是768这个值在下一节建FAISS索引时会直接用到。向量化结果是后续所有参数实验的基础建议立刻存盘。np.save(train_vectors.npy, vectors)一条命令就能搞定。之后每次改阈值、改邻居数直接读取npy文件不需要重新加载SimBERT跑一遍编码能省下大量时间。这一点在做项目实践时尤其重要模型权重加载通常要十秒到几十秒几千条文本的编码也可能要几分钟重复跑几次会非常烦躁。3.3 用FAISS建索引并查最近邻参数怎么设FAISS索引类型选择是很多人第一次接触faiss时最容易纠结的地方。对于几万到几十万条样本IndexFlatIP是最稳妥的选择。它不做任何压缩也不依赖聚类训练添加向量后直接进行精确搜索每个查询都计算和全量向量的内积结果无近似误差。数据量超过百万后在内存和耗时上会有点吃力那时可以考虑IndexIVFFlat但小项目没有必要为了花哨而牺牲精确性。import faiss import numpy as np vectors np.load(train_vectors.npy) dim vectors.shape[1] index faiss.IndexFlatIP(dim) index.add(vectors) print(index.ntotal) # 确认索引里的向量数 k 5 # 每个样本取5个最近邻后面去掉自身 distances, indices index.search(vectors, k 1) # 多查1个因为自己一定在最前面index.add(vectors)会把向量全部拷贝进FAISS内部存储此时内存占用等于向量的原始大小。index.search返回两个数组distances是内积分数indices是命中的向量行号。因为我们查询的是全量向量本身所以每条向量和自己的内积一定是1.0会排在第一个位置。因此查询时传入k1后续循环里通过j i把自身跳过剩下的就是真正的邻居。如果样本量超过百万可以把索引换成IndexIVFFlat。它有几个额外参数量化器常用IndexFlatL2、nlist聚类中心数和nprobe查询时扫描的聚类数。nlist一般取4 * sqrt(N)nprobe从4开始调越大召回越高但耗时也越长。注意IVF索引需要先train再add训练数据可以直接用原始向量的子集比如随机抽一万条不必穷举全量。3.4 增强样本生成合并标签、去重、控制比例拿到查询结果后开始组装增强数据。下面这段代码里threshold控制邻居必须达到的最低相似度max_aug_per_sample限制每条原样本最多产生多少条增强样本。def generate_augmented(df, distances, indices, threshold0.8, max_aug_per_sample3): aug_rows [] for i, (dist_row, idx_row) in enumerate(zip(distances, indices)): added 0 for dist, j in zip(dist_row, idx_row): if j i: continue if dist threshold: continue aug_rows.append({ text: df.loc[j, text], label: df.loc[i, label], # 继承当前样本标签 src: faug_{i}_{j}, cos: float(dist), }) added 1 if added max_aug_per_sample: break aug_df pd.DataFrame(aug_rows) # 去掉重复文本标签也去掉与原始样本完全相同的文本 aug_df aug_df.drop_duplicates(subset[text, label]) orig_set set(zip(df[text], df[label])) mask ~aug_df.apply(lambda r: (r[text], r[label]) in orig_set, axis1) aug_df aug_df[mask].reset_index(dropTrue) return aug_df这段代码里的max_aug_per_sample3是我常用的初始值。之前遇到一条模板化文本在近邻里拉出三十多条重复表达全部吞进去会让模型对这个模板过拟合。逐条限制数量后增强数据分布明显更平缓。dist threshold是硬过滤阈值越大、增强数量越少。实际操作中如果每条样本的top1距离都达不到阈值说明这个数据集内部样本间语义差异很大此时要考虑引入外部无标注文本而不是硬调低阈值。生成增强数据后和原始训练数据合并重新洗牌。final_df pd.concat([ df[[text, label]], aug_df[[text, label]], ]).drop_duplicates(subset[text, label]).sample(frac1, random_state42).reset_index(dropTrue) final_df.to_csv(train_augmented.csv, indexFalse, encodingutf-8-sig)合并后还要检查各类别数量。增强前1000条增强后3000条这个比例算健康。如果增强后某个类别变成了原来的50倍需要怀疑该类别的文本模板化严重这种类别在测试集上往往表现虚高。另外drop_duplicates(subset[text, label])只能去重文本和标签完全相同的样本如果两条文本不同但语义相同靠这个去重是去不掉的需要在下一章用更严格的逻辑处理。4. 把增强比例、相似度阈值和去重逻辑调好否则翻车4.1 相似度阈值怎么定先看分布再拍脑袋阈值直接决定增强数据的质量与数量。很多人第一次跑完直接看结果发现增强样本数量少得可怜或者多到爆炸然后凭感觉把阈值改一改。更靠谱的做法是先统计所有邻居距离的分布看它集中在哪个区间。import numpy as np dist_flat distances[:, 1:].flatten() for low, high in [(0.6, 0.7), (0.7, 0.8), (0.8, 0.9), (0.9, 1.0)]: count np.sum((dist_flat low) (dist_flat high)) print(fcos in [{low}, {high}): {count})如果大量邻居分布在0.9以上说明原始数据里存在大量重复模板此时阈值设置成0.85可能会导致增强样本全是模板变体缺乏泛化价值。如果大量分布在0.7以下说明样本间语义距离普遍偏远0.8阈值下增强数量接近于0这时候不是阈值的问题而是这个数据集本身内部语义多样性太高需要外部语料补充。初始阈值我建议定在0.85然后根据增强样本数量调整到一个能产生原数据量30%50%增量的值。除了数量还要看质量。从增广结果里随机抽20条人眼确认语义是否真的接近。文本分类里语义接近不等于标签可替换比如“苹果好吃”和“苹果手机好用”在SimBERT向量空间可能距离较近但标签完全不同这种样本一旦被增强进去模型就会被带偏。人眼抽样检查能把这类风险最快暴露出来。4.2 增强比例与类别均衡别把少数类变成噪声放大器增强比例指增强样本数与原样本数的比值我一般控制在1:1以下。比例太高时模型会过度拟合邻居之间的共性而不是学习类别本身的可区分特征。尤其在类别极不均衡的情况下给少数类做增强要限量否则少数类里的几条特殊表达会被复制成几百条模型在验证集上对这个类别的P/R会异常波动。更稳妥的做法是分类别统计增强前后数量。可以在生成增强数据时保留label列然后用groupby查看各类别数量变化。建议把增强倍数限制在多数学类最多2倍、少数学类最多3倍以内。如果某个类别的邻居距离普遍偏低说明该类内文本差异大强行增强会引入噪声这时候宁可放弃这个类别的增强也不要硬凑数。4.3 验证方式固定验证集对比增强前后的宏F1增强是否有效最终要看训练结果。在增强之前先从原始数据里划分一个固定的验证集这个验证集不能参与向量索引构建更不能作为邻居被查询到。我习惯把10%的数据留出作为验证集剩余90%做增强并且确保增强操作只读取训练集部分。验证流程很简单先用原始训练集训练一个分类模型记录验证集的宏F1再用增强后的训练集训练同样的模型保持随机种子、超参数不变对比F1。只有验证集有明显提升增强方案才算成立。如果F1没有提升甚至下降优先检查三件事阈值是否过低、增强比例是否过大、验证集里有没有文本出现在训练集的邻居列表里。这里要特别提醒一点划分验证集后构建FAISS索引时只能使用训练集文本。如果把全量数据都进索引验证集的文本很可能出现在某个训练样本的topK邻居中增强后的训练数据就间接包含了验证集信息的近似复述这会让你在验证集上的表现虚高上线后立刻打回原形。这一步是很多项目实践里最容易忽略的泄露风险。5. 避坑SimBERTFAISS最近邻增强的五个常见翻车现场5.1 最近邻全是同一句话的不同前缀现象查出来的top5邻居文本高度雷同只是句子被截断了或者多了一个标点。原因短文本在SimBERT向量空间中容易聚在一起加上原始数据里有大量重复模板没有清洗就直接建索引。解决先做文本归一化去掉首尾空格、全半角统一、过滤长度小于4的句子再做向量化。如果原始数据里确实存在大量模板化表达先对文本做去重再进FAISS索引。这里的去重要基于文本本身而不是基于向量距离。5.2 用了faiss-gpu后显存爆掉现象IndexFlatIP添加几百万向量后程序直接OOM连模型都加载不了了。原因IndexFlatIP是精确索引向量全量存在显存或内存里没有压缩。GPU资源分配不当索引常驻显存挤压了后续训练模型的显存配额。解决样本量大时改用IndexIVFFlat先训练聚类再添加向量或者在CPU内存里建索引搜索时再拷贝到GPU。注意IndexIVFFlat需要提供训练向量集可以用原始向量的子集。如果业务要求实时性不高直接全程用CPU版faiss更省心几万条数据的效果差距并不明显。5.3 距离普遍集中在0.9以上阈值形同虚设现象内积0.9以上的邻居非常多过滤后增强样本数量依然爆炸而且很多邻居语义并不一致。原因SimBERT对某些领域文本的区分度有限归一化后高维向量的点积普遍偏高绝对值高不代表语义就一定相近。解决不要只看内积绝对值改用相对排名。把threshold替换成“每个样本只取top2或top3邻居”不设绝对阈值然后通过人工抽查控制质量。另一个办法是改用FAISS的IndexFlatL2观察欧氏距离分布不同数据集中总有一个距离度量更有区分度需要实际对比再决定。5.4 验证集上F1不升反降现象增强后的训练集在验证集上的宏F1比基线低两个点。原因最常发生在阈值过低或max_aug_per_sample过大时增强样本里混入了错误标签模型学到了伪造特征。解决立即提高阈值收紧每样本增强数量并且对比去掉某一类增强后的效果。同时检查验证集里有没有文本是训练集增强邻居的近似复述如果有说明你在划分之前就混入了验证集文本参与索引。这也是我前面强调固定验证集的原因。5.5 加载SimBERT权重时各种不兼容报错现象报KeyError: bert/pooler/dense/kernel之类的TF checkpoint读取失败或者模型输出向量全部相近完全无法区分样本。原因SimBERT原始权重是TensorFlow格式直接塞给PyTorch的BertModel不认或者缺少了sentence-transformers需要的最小文件结构。解决先转换成PyTorch格式再加载。transformers官方提供了convert_tf_checkpoint_to_pytorch.py脚本转换时记得把config里的model_type设成bert。如果不想做转换也可以找社区已导出的sentence-transformers目录直接用SentenceTransformer加载。这里不建议自己去改transformers源码强行加载TF权重坑太多。6. 进阶用法与落地技巧把最近邻增强做成可持续迭代的组件这套方案不应该只在一次实验里闪一下我建议把它拆成三个长期可用的能力向量缓存、索引复用和增量更新。向量缓存的意义在于原始标注集在增长每次新增几十条数据不需要把全量文本重新过一遍SimBERT。常见做法是把已处理的文本哈希后作为缓存键新增文本只编码一次然后追加到原来的npy文件末尾。FAISS索引里也做同样处理建一个基于ID的索引让每条文本与向量ID一一对应这样才能知道最近邻对应的是哪条原始文本。如果业务是持续进件的比如每天来一批新标注数据我建议用FAISS的IndexIDMap包装IndexFlatIP这样删除旧样本和追加新样本时不需要重建全部索引。当然IndexFlatIP本身不支持删除但你可以定期重建或者用IndexIVFFlat这类支持remove_ids的索引结构。对于多数中小规模项目每周一次重建索引的代价完全可以接受。另一个容易出效果的进阶方向是引入外部无标注数据。把几十万条与业务相关的无标注中文语料也编码后放进同一个FAISS索引查询时可能为某条训练样本找到外部语义邻居。此时没有真实标签只能打伪标签所以阈值要拉高到0.9以上而且只取距离最近的那一个邻居。这种方法适合冷启动场景我在电商评论分类里靠它把准确率往上抬了两个点但前提是外部语料的领域贴得很近否则基本都是噪音。最后每次增强后要做一次“同标签一致性抽查”。打印出原始样本和它被增强出来的5个邻居人工看一遍这能最快发现阈值和模型选型的问题。我自己的教训是有一次阈值设在0.75增强数据量虽然大了但模型在真实线上数据上明显变差后来查下来是SimBERT把“价格便宜”和“质量不错”这类对比词当成了近邻这种错误人眼一眼就能看出来但距离分数很难看出来。你如果也在做中文文本分类且标注量卡在半万以下这套SimBERTFAISS的最近邻数据增强方法值得花一两个下午跑一遍。先按最小流程跑通再调阈值和比例最后再考虑外部语料。希望帮到你。本文还有配套的精品资源点击获取
返回列表