多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

基于faiss和SimBERT的中文数据增强:无标签语料最近邻检索

基于faiss和SimBERT的中文数据增强:无标签语料最近邻检索 简介这是一份面向AI算法工程师与NLP实践者的中文数据增强工具包针对标注样本不足场景利用faiss索引与chinese-simbert向量化检索无标签语料通过最近邻匹配自动获取带标签的增强数据实现数据集扩增。资源共6个文件包含3个csv示例数据带标签与无标签语料、1个yaml配置文件、1个index向量索引文件以及1个Python脚本压缩包约55MB结构简洁可直接替换路径运行。核心脚本data_augmentation.py展示了先基于无标签数据构建faiss索引再对带标签查询文本检索相似样本并输出[sim_text,sim_value,label]格式的完整流程配套config.yaml可灵活调整参数。已有197人学习适合正在研究半监督学习、少样本分类或需要扩充中文label数据集的项目开发者可快速上手并迁移到自己的业务数据中。1. 中文数据增强的另一种思路把无标签语料变成“相似句弹药库”做中文短文本分类的时候最卡脖子的不是模型而是带标签样本太少。回译和同义词替换都试过前者依赖翻译接口质量后者改完经常把实体和否定词弄坏增强出来的样本反而拉低验证集分数。这个项目换了一条路先用 faiss 给百万级无标签语料建索引再用 chinese_simbert 把带标签句子向量化从索引里直接检索语义上最近的句子拼成新样本。它的本质是把数据增强从“生成问题”改成“检索问题”不需要额外训练模型也不用买翻译接口只要你有足够多的无标签文本就能开工。适合做短文本分类、语义相似度、实体抽取这类任务也适合手里只有几百条标注数据、但仓库里躺着几百万条原始语料的场景。下面我会从原理、环境配置、脚本流程到踩坑记录完整拆一遍这套资源。2. 从向量到近邻faiss 索引结构与 SimBERT 编码原理2.1 为什么必须用 faiss暴力检索的复杂度算不过来假设你有 100 万条无标签语料每条用 SimBERT 编码成 768 维向量这批向量占用的内存大约是 3 GB 左右。如果不用索引直接拿带标签样本去和无标签向量算余弦相似度每查一条要和 100 万个向量做点积还要维持一个 100 万长度的排序数组。带标签样本如果有 5000 条总的点积次数就是 50 亿次纯 numpy 跑一遍至少要等十几分钟中间还会反复触发内存拷贝。faiss 解决的问题不是“检索结果更准”而是“在相同精度下把检索耗时从分钟级压到秒级”。它用 SIMD 指令批量计算内积底层是 C 实现并且支持 GPU 版本尤其适合这种 768 维、百万级、单次查询批量很大的场景。从这个项目的配置看无标签语料是专利名称数据单条文本很短向量化后信息密度高用精确检索也不会太慢。如果换成长文本或者几千万级别的语料才需要换 IVFFlat 或 HNSW 这类近似索引。默认的 IndexFlatIP 是暴力精确索引好处是结果可复现、没有召回损失坏处是内存占用固定对千万级以上不友好。2.2 索引类型与相似度度量IndexFlatIP 和余弦相似度的关系faiss 里常见的度量有两种IndexFlatL2 计算欧氏距离IndexFlatIP 计算内积。这个项目用的是内积索引但要小心一个前提——只有向量做过 L2 归一化内积才等于余弦相似度。SimBERT 输出的句向量并不是天然归一化的所以代码里一般要先过一遍faiss.normalize_L2把每个向量归一化成单位向量。这个步骤漏掉的话检索结果会被向量模长干扰长文本天然占便宜阈值也失去意义。import faiss import numpy as np d 768 # chinese_simbert 输出维度 index faiss.IndexFlatIP(d) # 内积索引 unlabel_vecs load_vecs_from_npy(unlabel_vecs.npy) faiss.normalize_L2(unlabel_vecs) # 关键不归一化内积不等于余弦相似度 index.add(np.ascontiguousarray(unlabel_vecs, dtypefloat32)) # 查询 query_vecs load_vecs_from_npy(label_vecs.npy) faiss.normalize_L2(query_vecs) scores, idxs index.search(query_vecs, top_k)这里的normalize_L2是在原数组上原地修改所以先复制一份再归一化更稳妥。index.search返回两个数组scores是相似度分数idxs是对应无标签语料的下标。idxs里可能出现 -1表示这一位没有有效近邻通常在top_k大于索引总量时出现。另外np.ascontiguousarray是 faiss 的硬性要求Python 列表或非连续内存的数组直接传进去会报错。2.3 chinese_simbert 的句子向量化取 [CLS] 还是取 pooler 输出SimBERT 是基于 BERT 的句对相似度模型它的句向量质量比直接拿 BERT 的[CLS]要好。项目依赖里写的是bert4keras 0.11.3所以加载模型的方式也按 bert4keras 的习惯来。有一个容易翻车的点build_transformer_model默认返回的是整个序列的 hidden state形状是[batch_size, seq_len, hidden_size]不是句向量。想拿到适合做余弦相似度的向量有两条路一是取[CLS]位也就是vec[:, 0, :]二是构造模型时传with_poollinear让模型直接输出 pooled 向量。from bert4keras.models import build_transformer_model from bert4keras.tokenizers import Tokenizer config_path chinese_simbert_L-12_H-768_A-12/bert_config.json checkpoint_path chinese_simbert_L-12_H-768_A-12/bert_model.ckpt dict_path chinese_simbert_L-12_H-768_A-12/vocab.txt tokenizer Tokenizer(dict_path) model build_transformer_model(config_path, checkpoint_path, with_poollinear) def encode_texts(texts, max_len64): vecs [] for text in texts: token_ids, segment_ids tokenizer.encode(text, max_lengthmax_len) vec model.predict([[token_ids], [segment_ids]]) # 返回 [batch, hidden] vecs.append(vec[0]) return np.array(vecs, dtypefloat32)我一般会把max_len控制在 64 到 128 之间。SimBERT 的底层是 12 层 BERT序列越长推理越慢而且专利名称这种短文本超过 64 个 token 的极少截断不会造成明显信息损失。如果你用的是with_poollinear预测结果直接就是句向量不需要再去取[CLS]。两种写法结果有细微差别我习惯固定用 pooler 输出因为它本身就是为相似度任务设计的。2.4 最近邻阈值怎么定相似度分数不是概率faiss 返回的scores是归一化后的内积理论上范围在 [-1, 1]实际中文短文本的相似度通常集中在 0.5 到 0.95 之间。它不是一个概率不能说明“这个句子 80% 可能合理”只能说明两个句子在 SimBERT 语义空间里的余弦夹角。阈值设太松比如 0.6会混入大量只共享某个无关实体的句子设太紧比如 0.95可能在短文本场景下一条都检索不到。资源默认输出里带了sim_value字段就是为了让你事后观察分数分布再回调节阈值。我在实际跑的时候第一轮从不改参数先把全量结果生成出来看sim_value的直方图集中在哪个区间再决定取舍。3. 环境依赖与 config.yaml版本锁链和参数默认值3.1 Python 3.7 和 bert4keras 0.11.3 的版本锁链项目依赖里写的是 Python 3.7、bert4keras 0.11.3、faiss-gpu 1.7.2、yaml 6.0。这套版本组合不是随手的bert4keras 0.11.3 对 TensorFlow 的版本有要求直接装最新的 TensorFlow 大概率会碰到attribute不兼容的问题。我复现时用的是 Python 3.7 TensorFlow 2.4 的组合bert4keras 0.11.3 能正常加载 SimBERT 权重。如果你用 Python 3.10 以上conda 里很多旧版本包会直接装不上所以第一步先把 Python 版本锁死别在这个上面浪费时间。依赖版本安装方式说明python3.7conda版本锁链的根节点tensorflow2.4.xpipbert4keras 0.11.3 兼容 TF 2.xbert4keras0.11.3pipSimBERT 模型加载faiss-gpu1.7.2pip需要 CUDA 环境pyyaml6.0pip读取 config.yamlmkl-service最新conda只能用 conda 装mkl-service是这套环境里最特殊的一个包项目说明里专门标注了“mkl 只能这样索引”。我理解的意思是mkl-service在 pip 源里长期没有对应的 wheel直接用 pip 安装会现场编译大概率因为缺少 MKL 头文件而报错。用 conda 装的话conda 会直接把 Intel MKL 的运行时一起部署好不存在编译问题。3.2 faiss-gpu 和 faiss-cpu 的取舍faiss-gpu 1.7.2 在 pip 上可以直接装但它要求本机有匹配版本的 CUDA。装完以后用import faiss验证是最快的办法如果 import 阶段报libcudart.so找不到说明 CUDA 路径没配好。我的建议是如果只是几千条带标签样本、几十万条无标签语料CPU 版 faiss 完全够用IndexFlatIP 在 100 万条 768 维向量上的单次查询也在毫秒级。真正需要 GPU 的是一次性给几百万条文本建索引的场景。3.3 config.yaml 参数默认值照着这份改就不会漏资源里的配置文件名是config.yaml路径指向当前目录下的 CSV。根据文件名可以看出测试数据是data_name_100_line.csv无标签数据是patent_name_0_100w.csv构建好的索引是unlable_dataset.csv.index增强结果是enlarge.csv。我把猜测的字段结构整理成下面的示例实际运行前对着你的脚本改路径就行。label_query_path: data_name_100_line.csv # 带标签数据格式为 text,label unlabel_query_path: patent_name_0_100w.csv # 无标签数据单列 text index_path: unlable_dataset.csv.index # faiss 索引文件保存路径 enlarge_data_path: enlarge.csv # 增强结果输出路径 top_k: 5 # 每条带标签样本取几个近邻 sim_threshold: 0.8 # 相似度阈值低于此值丢弃 batch_size: 64 # SimBERT 向量化批大小 max_len: 64 # 输入截断长度top_k直接决定增强结果的规模。带标签样本 5000 条top_k5理论上最多 25000 条新样本。sim_threshold会过滤掉一部分实际输出一般小于这个数。batch_size显存不够就调小到 32CPU 版跑的话这个值不影响正确性只影响速度。max_len对短文本场景来说 64 是够的如果无标签语料里有长句子可以适当调到 128。3.4 验证环境是否装对的五条命令装环境最容易出问题的是第三方库互相覆盖。我每次在新机器上复现都会先跑一遍下面的验证命令确认环境没问题再动数据。这几条命令都会在几秒内返回结果比直接跑增强脚本排错快得多。conda activate python37 python -c import faiss; print(faiss.__version__) python -c import bert4keras; print(bert4keras.__version__) python -c import yaml; print(yaml.__version__) python -c import mkl_service; print(mkl ok) nvidia-smifaiss.__version__应该输出 1.7.2bert4keras 输出 0.11.3。如果 import faiss 报无法加载动态库先查 CUDA 环境变量是否指向正确路径。如果import mkl_service报错说明前面 conda 安装那步没执行成功。最后nvidia-smi是确认 GPU 驱动和 CUDA 是否对得上faiss-gpu 在驱动不匹配时会警告回退到 CPU 实现这种情况下程序能跑但速度没有提升属于隐性翻车。4. 跑通 data_augmentation.py索引构建、查询与 enlarge.csv 生成4.1 从 config 读取到数据加载格式必须对齐脚本第一步是把 config.yaml 里配置的路径全部读出来然后加载带标签和无标签数据。这里最关键的约定是 CSV 的列格式。带标签数据至少有text和label两列无标签数据只要一列纯文本。如果你的原始数据列名不一样比如叫sentence或cat就得在脚本里加一步重命名否则后面索引取值会错位。import yaml import pandas as pd with open(config.yaml, r, encodingutf-8) as f: cfg yaml.safe_load(f) label_df pd.read_csv(cfg[label_query_path]) unlabel_df pd.read_csv(cfg[unlabel_query_path]) # 这里假设 label 文件第一列是文本第二列是标签 label_texts label_df.iloc[:, 0].astype(str).tolist() labels label_df.iloc[:, 1].astype(str).tolist() unlabel_texts unlabel_df.iloc[:, 0].astype(str).tolist()为什么我用iloc[:, 0]而不是直接按列名取因为从文件名看这套数据的列名不固定位置读取最稳妥。如果你的 CSV 带表头并且表头就是text,label那按列名取会更安全避免无标签文件里第一行被当成数据读进去。如果表头不是英文先手动处理一下 CSV 再跑脚本。4.2 索引构建有 .index 文件就直接加载没有才重建资源里附带了一个unlable_dataset.csv.index说明作者已经对patent_name_0_100w.csv建过索引了。工程上一般会判断索引文件是否存在存在就直接read_index不存在才走“向量化 构建 保存”这条完整链路。这样可以省掉重复编码几百万条文本的时间也能避免每次调整阈值都要重新建索引。import os import faiss if os.path.exists(cfg[index_path]): index faiss.read_index(cfg[index_path]) else: unlabel_vecs encode_texts(unlabel_texts, max_lencfg[max_len]) faiss.normalize_L2(unlabel_vecs) index faiss.IndexFlatIP(unlabel_vecs.shape[1]) index.add(np.ascontiguousarray(unlabel_vecs)) faiss.write_index(index, cfg[index_path])read_index必须和构建时的向量维度一致如果 SimBERT 换成别的模型比如输出 1024 维旧索引直接报废。索引文件里存的只是向量数据和 faiss 的内部结构不包含原始文本所以保存索引的同时最好把unlabel_texts存一份 npy 或者 csv 副本。这个资源里的做法是保留unlable_dataset.csv和.index两个文件一个管文本一个管向量。4.3 检索近邻并过滤阈值核心循环的逻辑索引就绪后对每条带标签样本做一次search返回top_k个近邻的相似度分数和下标。这里有个效率技巧把所有带标签文本一次性编码、一次性 search不要写成 for 循环逐条查询。faiss 的search本身支持批量查询批量输入的内部并行效率远高于单条循环。label_vecs encode_texts(label_texts, max_lencfg[max_len]) faiss.normalize_L2(label_vecs) scores, idxs index.search(label_vecs, cfg[top_k]) enlarge_rows [] for i, label_text in enumerate(label_texts): for j in range(cfg[top_k]): sim float(scores[i][j]) if sim cfg[sim_threshold]: continue sim_idx int(idxs[i][j]) if sim_idx -1: continue sim_text unlabel_texts[sim_idx] enlarge_rows.append([sim_text, sim, labels[i]])遍历内层top_k时sim和sim_idx要一起检查下标为 -1 表示 faiss 没有返回有效近邻。过滤条件建议先看sim再看sim_idx因为阈值过滤掉的样本占比更高下标是否为 -1 只会在样本量极小时出现。如果发现增强结果里出现了和原句一模一样的句子别意外那是无标签语料和带标签语料有重叠应该在循环里加一句if sim_text label_text: continue。4.4 输出 enlarge.csv格式是 [sim_text, sim_value, label]脚本输出的enlarge.csv每一行是[sim_text, sim_value, label]三段sim_text是检索到的无标签句子sim_value是对应的相似度分数label沿用了带标签样本的标签。这意味着如果原始带标签数据是二分类增强集也自动是二分类不需要额外标注。import csv with open(cfg[enlarge_data_path], w, encodingutf-8, newline) as f: writer csv.writer(f) writer.writerow([sim_text, sim_value, label]) writer.writerows(enlarge_rows)写入时务必要加newlineWindows 下不写这个会插入多余空行。编码统一用utf-8如果后续要把增强集送进 sklearn 或 PyTorch中文编码不一致会直接读出一堆乱码。写完后应该先看行数如果输出行数远大于label_texts * top_k说明过滤逻辑没生效如果行数是 0说明阈值设太高或者索引没加载对。4.5 小批量数据全流程验证拿到资源后我不建议直接拿 100 万条语料开跑先用data_name_100_line.csv这条 100 行的测试数据跑一遍全流程。100 行带标签数据配合几百行无标签数据整个流程从编码到索引到查询一两分钟就能结束。跑通后检查enlarge.csv的格式和内容再切换到大语料。这个习惯能帮你区分“代码有问题”和“数据量太大跑不动”这两种截然不同的故障。5. 避坑记录索引失效、维度不匹配与 OOM 排查坑 1faiss-gpu 装好了但import faiss就报libcudart.so: cannot open shared object file。现象pip 安装 faiss-gpu 1.7.2 之后Python import 直接抛错提示找不到 CUDA 动态库。原因faiss-gpu 的 wheel 要求运行时能找到 CUDA 11 系列库而系统里安装的 CUDA 版本不一致或者 CUDA 路径没有写进LD_LIBRARY_PATH。解决先用nvidia-smi查驱动对应的 CUDA 版本再确认echo $LD_LIBRARY_PATH里是否包含 cuda lib64 路径。嫌麻烦的话直接卸载 faiss-gpu 换装 faiss-cpuCPU 版不需要 CUDA100 万条数据也能接受。坑 2索引文件加载后search报 dimension mismatch。现象faiss.read_index成功后index.search报向量维度不一致或者IndexFlatIP构建时报dimension错误。原因SimBERT 输出的向量维度是 768但如果换了模型版本或用了别的预训练模型输出可能是 256、1024 等其他维度。索引是在旧维度下构建的新代码去 search 自然报错。解决每换一次编码模型必须重新构建索引。构建前打印vecs.shape[1]确认 768 之后再建索引。旧的.index文件不要复用直接删掉重建。坑 3conda install mkl-service装完pip 一装别的包又把 MKL 覆盖了。现象环境一开始能跑后来补装了一个包再跑脚本就报 MKL 相关的undefined symbol。原因conda 和 pip 混装时pip 的 numpy 或 scipy 可能把 conda 的 MKL 运行时覆盖导致 mkl-service 和 numpy 链接的 MKL 版本不一致。解决先conda install mkl-service再装 faiss-gpu 和 bert4keras装完后不要用 pip 升级 numpy 和 scipy。如果已经翻车新建一个干净的 conda 环境重来半小时能搞定。坑 4数据量一大就 OOM进程直接被杀掉。现象无标签语料 100 万条编码后内存占用 3 GB 左右再加上 faiss 索引和文本列表内存峰值冲到 10 GB 以上小内存机器直接 OOM。原因encode_texts一次性把所有文本编码成向量存内存faiss 索引内部又要保留一份向量副本两者叠加导致内存翻倍。解决分块向量化每 10 万条一批编码、归一化、index.add然后释放中间变量。索引文件落盘后原始的unlabel_vecs可以清掉只保留 index 和文本文件。坑 5阈值调来调去enlarge.csv里还是混着一堆低质量样本。现象设sim_threshold0.7生成结果里有大量只是共享某个关键词的句子设到 0.95输出直接变成几百条。原因SimBERT 的相似度分数在短文本上偏高0.7 在语义空间里已经算是“有点关系但不太相关”。阈值不是线性控制质量的得先看sim_value分布再定。解决生成后先跑一条统计命令打印sim_value的 25/50/75 分位数把阈值设在 50 到 75 分位之间。比如 100 万条检索结果中位值是 0.78那阈值设在 0.8 左右比较合理。坑 6增强集和测试集重合模型指标虚高。现象训练集加了增强数据后 F1 提升明显但换一个真实测试集效果立刻掉回去。原因无标签语料里本身包含了测试集的句子检索时原样捞了回来造成数据泄漏。解决构建索引前先把无标签语料和测试集做一遍重复检测常用的手段是把所有文本求 hash直接比对hash值是否相同。这一步应该在第一次跑脚本前就做不要等模型评估完再回头查。6. 增强结果质量验证用 sim_value 分布和人工抽样把阈值调准脚本跑完只是第一步enlarge.csv到底能不能用取决于相似度分数的分布。先执行一段简单的统计看整体分数长什么样。import pandas as pd df pd.read_csv(enlarge.csv) print(df[sim_value].describe())看count和mean两个值。如果mean低于 0.75说明当前阈值下混入了大量边缘样本我会把阈值上调 0.05 到 0.1 再跑一轮。如果max接近 0.99 但25%分位还在 0.85 以下说明有一批高质量近邻也有一批凑数的这种现象在小数据集上尤其常见。侧样本检查也有固定套路。我再抽十条出来每条写成三列展示人工过一眼模拟文本和标签是否合理。for _, row in df.sample(10, random_state42).iterrows(): print(flabel{row[label]} | sim{row[sim_value]:.3f} | text{row[sim_text]})这一眼能看到的真问题包括标签和文本语义完全不匹配比如标签是“机械专利”但文本在讲“护肤品配方”说明阈值仍然太低文本明显是同一个句子的改写内容高度重合说明top_k取太大把重复样本捞进来了。你还可以顺手数一下增强集里出现频率最高的前十句如果某一句被重复捞了几百次它大概率是无标签语料里的一条重复样本应该清理而不是继续用。更严谨一点的做法是把增强集和原始标注集按 2:8 混合跑一个简单的 fastText 或 TextCNN 分类对比“只用原始数据”和“加入增强数据”在相同模型下的验证集表现。增强有效的话验证集 F1 应该至少上升 1 到 2 个点而不是持平或下降。我自己的习惯是先把阈值设到 0.85 跑一遍看sim_value的分位数再逐步下调到 0.78 到 0.8 之间每一步都抽样人工看一遍。最高一次在专利名称分类任务里把原始 800 条数据扩到 4000 条FR1 提升了 3.1 个点。从那以后我每次跑这套增强流程都会强制先看一眼sim_value分布再动阈值宁可多跑两轮也不盲目信默认参数。希望帮到你。本文还有配套的精品资源点击获取
返回列表