ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

BERT句向量与词向量提取工具包:池化策略、批量推理与缓存实践

BERT句向量与词向量提取工具包:池化策略、批量推理与缓存实践

简介:这份资源面向自然语言处理初学者与需要快速上手BERT的开发者,提供利用BERT预训练模型生成句向量与词向量的实践代码,解决文本向量化环节中模型加载、配置与特征提取的落地问题。压缩包共3个文件,均为Python脚本,整体约4KB,涵盖模型配置、Keras层实现以及句向量与词向量提取逻辑,结构轻量,便于直接阅读与二次修改。资源围绕两个核心函数展开:一个将输入句子经特殊标记与多层Transformer编码后,在[CLS]位置提取固定长度向量,用于句子相似度计算或分类任务;另一个对单词或子词单元输出上下文相关的词向量,可服务于词性标注、词义消歧等场景。目前已有1127人学习下载,适合希望理解BERT输出层用法、构建文本特征并应用于分类、情感分析或问答系统的读者参考。

1. 拆开这个 BERT 向量化工具包:它到底能帮你省掉哪段重复劳动

如果你做过文本相似度、语义检索或者聚类,大概率写过一段“加载 BERT、取[CLS]、算余弦”的代码。写一次不累,问题是每个项目都要重写一遍,而且每次都会在同一个地方翻车——池化策略选错、tokenizer 和权重版本对不上、长文本直接截断丢信息。这个利用bert预训练模型生成句向量或词向量.zip就是冲着这段重复劳动来的:它把句向量和词向量的提取逻辑封装成两个函数,配一个conf.py管路径,再加一个layers_keras.py处理 Keras 侧的层定义。核心文件就四个:BertModel-master目录、layers_keras.py、conf.py、extract_sen_vec.py。适合谁?手上已经有预训练权重、想快速把文本转成向量喂给下游模型的人;也适合想搞清楚[CLS]和 token 级输出到底差在哪的 NLP 从业者。它不训练模型,只做推理侧的向量抽取,所以对显存的要求取决于你选的 BERT 规格,base 版在 8G 显存上跑 batch 推理基本够用。

2. 环境与配置:把 conf.py 和权重路径先对齐

2.1 依赖选型:PyTorch 还是 TensorFlow,先看 layers_keras.py 的脸色

这个包里出现了layers_keras.py,说明作者至少留了一条 Keras/TensorFlow 的路径。但extract_sen_vec.py里调 BERT 的方式,常见做法是走 Hugging Face 的transformers,而transformers同时支持 PyTorch 和 TensorFlow 两个后端。所以第一步不是急着pip install,而是先打开layers_keras.py看它import的是tensorflow.keras还是keras,再决定后端。

我一般会先建一个干净环境,避免和系统里已有的 TF 版本打架:

conda create -n bert_vec python=3.8 -y conda activate bert_vec # 先装 PyTorch,版本按你的 CUDA 选,这里以 cu118 为例 pip install torch==2.0.1 torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118 # 再装 transformers 和配套的 tokenizers pip install transformers==4.30.2 pip install numpy scipy scikit-learn

这里锁transformers==4.30.2不是玄学,是因为 BERT 的BertModel输出结构在 4.x 早期和 4.3x 之后基本稳定,但tokenizer的return_tensors行为在个别小版本有过变动。如果你装最新版发现extract_sen_vec.py报KeyError: 'input_ids',先降到这个版本试。参数上,python=3.8是保守选择,3.9/3.10 也能跑,但 3.11 以上部分旧版 TF 轮子不全,容易在layers_keras.py导入时卡住。

2.2 conf.py 里真正要改的只有三行

conf.py通常长这样,不同版本变量名可能略有差异,但核心就三个路径:

# conf.py 典型结构 import os # 预训练权重目录,里面要有 pytorch_model.bin 或 tf_model.h5 BERT_MODEL_PATH = "/data/pretrained/bert-base-chinese" # 词表文件,一般和权重同目录,但有些包会单独放 VOCAB_FILE = os.path.join(BERT_MODEL_PATH, "vocab.txt") # 配置文件,决定 hidden_size、层数、注意力头数 CONFIG_FILE = os.path.join(BERT_MODEL_PATH, "config.json") # 最大序列长度,超过这个值的 token 会被截断 MAX_SEQ_LEN = 128 # 是否用 GPU USE_CUDA = True

要改的只有BERT_MODEL_PATH、MAX_SEQ_LEN、USE_CUDA。VOCAB_FILE和CONFIG_FILE一般跟着权重目录走,不用动。MAX_SEQ_LEN设 128 是句向量任务的常见起点,因为[CLS]的语义在 128 个 token 内基本能覆盖短句和段落首句;如果你要处理长文档,调到 512 会让显存占用翻倍,batch size 就得从 32 降到 8 左右。USE_CUDA设 False 时,推理速度会慢一个数量级,但如果你只是调试gen_sen_vec()的输出维度,CPU 也够用。

提示:权重目录里如果只有pytorch_model.bin没有tf_model.h5,那layers_keras.py那条路走不通,直接用 PyTorch 后端调extract_sen_vec.py更省事。

2.3 验证配置是否生效的最小脚本

改完conf.py别急着跑主脚本,先用三行代码确认路径能加载:

from transformers import BertTokenizer, BertModel import conf tokenizer = BertTokenizer.from_pretrained(conf.BERT_MODEL_PATH) model = BertModel.from_pretrained(conf.BERT_MODEL_PATH) print("vocab size:", tokenizer.vocab_size) print("hidden size:", model.config.hidden_size)

如果vocab size打出来是 21128,说明加载的是中文 base 版;hidden size应该是 768。这两个数对不上,后面gen_sen_vec()出来的向量维度就会和你的下游模型对不上,训练时直接报 shape mismatch。这一步花 10 秒,能省掉后面半小时的排查。

3. 句向量生成:gen_sen_vec() 的池化策略与 batch 推理

3.1 [CLS] 不是唯一解,但它是这个包的默认解

gen_sen_vec()的核心逻辑是:把句子用[CLS]开头、[SEP]结尾包起来,过 BERT,取最后一层[CLS]位置的输出作为句向量。这个做法在 BERT 原论文里就被验证过,[CLS]在预训练阶段被用来做下一句预测,所以它确实聚合了整句信息。但要注意,[CLS]向量在未微调的 BERT 上做相似度,效果不如mean pooling(对所有 token 输出取平均)稳定,这是很多人在语义相似度任务上翻车的原因。

这个包默认用[CLS],好处是取向量快、维度固定 768;坏处是如果你直接拿它算余弦相似度,短句之间的区分度可能不够。我一般会在gen_sen_vec()外面再包一层,把[CLS]和mean pooling都算出来对比:

import torch import numpy as np from transformers import BertTokenizer, BertModel import conf tokenizer = BertTokenizer.from_pretrained(conf.BERT_MODEL_PATH) model = BertModel.from_pretrained(conf.BERT_MODEL_PATH) model.eval() if conf.USE_CUDA: model = model.cuda() def gen_sen_vec(sentences, pool_type="cls"): """ sentences: list of str pool_type: "cls" 取 [CLS],"mean" 对所有 token 取平均 返回: numpy array, shape (len(sentences), 768) """ encoded = tokenizer( sentences, padding=True, truncation=True, max_length=conf.MAX_SEQ_LEN, return_tensors="pt" ) if conf.USE_CUDA: encoded = {k: v.cuda() for k, v in encoded.items()} with torch.no_grad(): outputs = model(**encoded) last_hidden = outputs.last_hidden_state # (batch, seq_len, 768) attention_mask = encoded["attention_mask"] # (batch, seq_len) if pool_type == "cls": vec = last_hidden[:, 0, :] # 取 [CLS] elif pool_type == "mean": # 把 padding 位置的向量排除掉再平均 mask = attention_mask.unsqueeze(-1).float() vec = (last_hidden * mask).sum(dim=1) / mask.sum(dim=1) else: raise ValueError("pool_type must be 'cls' or 'mean'") return vec.cpu().numpy()

逻辑说明:tokenizer的padding=True保证一个 batch 里所有句子长度对齐,truncation=True配合max_length做截断。attention_mask里 1 表示真实 token,0 表示 padding。mean pooling那一步用 mask 乘一遍再除以真实长度,避免 padding 的向量拉低均值。参数上,pool_type默认"cls"是为了和原包行为一致,但如果你要做语义检索,建议改成"mean"再跑一次对比。batch size没有显式参数,由你传入的sentences列表长度决定,一次传 32 条在 8G 显存上比较稳。

3.2 批量推理时显存不够,先降 batch 再降长度

gen_sen_vec()如果一次传几百条句子,encoded的 tensor 会撑爆显存。常见做法是外面套一个循环,每 32 条调一次:

def batch_gen_sen_vec(sentences, batch_size=32, pool_type="cls"): all_vecs = [] for i in range(0, len(sentences), batch_size): batch = sentences[i:i+batch_size] vecs = gen_sen_vec(batch, pool_type=pool_type) all_vecs.append(vecs) return np.vstack(all_vecs)

这里batch_size=32是 base 版在 8G 显存上的经验值,如果你用的是 large 版(hidden size 1024),降到 8 或 16。MAX_SEQ_LEN从 128 调到 256,显存占用大约增加 40%,batch size 要相应下调。如果显存还是不够,先确认model.eval()和torch.no_grad()都加了,这两个不加,显存会多占一倍。

3.3 句向量的下游用法:余弦相似度和归一化

拿到(N, 768)的矩阵后,算相似度前先做 L2 归一化,否则余弦值会受向量模长影响:

from sklearn.preprocessing import normalize vecs = batch_gen_sen_vec(["今天天气不错", "明天可能下雨", "股票市场大涨"]) vecs_norm = normalize(vecs, axis=1) sim_matrix = vecs_norm @ vecs_norm.T print(sim_matrix)

normalize默认做 L2 归一化,axis=1表示按行处理。sim_matrix对角线是 1,非对角线是两两余弦相似度。如果你发现“今天天气不错”和“明天可能下雨”的相似度高于 0.9,那大概率是[CLS]向量区分度不够,换成mean pooling再试。这个对比步骤我每次接新权重都会跑一遍,算是血泪经验——不同预训练权重在[CLS]上的表现差异比想象中大。

4. 词向量生成:gen_word_vec() 的对齐问题与子词处理

4.1 BERT 给的是子词向量,不是词向量

gen_word_vec()的目标是拿到每个单词的向量,但 BERT 的 tokenizer 用的是 WordPiece,一个中文词可能被切成多个子词,英文单词也可能被切成##ing这种形式。所以直接取last_hidden_state的对应位置,拿到的是子词向量,不是词向量。这个包里的gen_word_vec()常见做法是:对每个词,把它对应的所有子词向量取平均,作为该词的向量。

def gen_word_vec(sentence, words): """ sentence: 一个句子字符串 words: 需要抽取向量的词列表,比如 ["自然语言", "处理"] 返回: dict, {word: vector} """ encoded = tokenizer(sentence, return_tensors="pt", truncation=True, max_length=conf.MAX_SEQ_LEN) if conf.USE_CUDA: encoded = {k: v.cuda() for k, v in encoded.items()} with torch.no_grad(): outputs = model(**encoded) last_hidden = outputs.last_hidden_state[0] # (seq_len, 768) tokens = tokenizer.convert_ids_to_tokens(encoded["input_ids"][0]) word_vecs = {} for word in words: # 找到 word 在 tokens 里的起始位置 sub_tokens = tokenizer.tokenize(word) indices = [] for i, tok in enumerate(tokens): if tok == sub_tokens[0]: # 检查后续子词是否匹配 if tokens[i:i+len(sub_tokens)] == sub_tokens: indices = list(range(i, i+len(sub_tokens))) break if indices: vec = last_hidden[indices].mean(dim=0) word_vecs[word] = vec.cpu().numpy() else: word_vecs[word] = None # 该词不在句子中 return word_vecs

逻辑说明:tokenizer.tokenize(word)先把目标词切成子词序列,然后在整句的 token 列表里做子序列匹配。匹配到之后,取对应位置的 hidden state 求平均。indices为空说明这个词没出现在句子里,返回None。参数上,words列表里的词必须是句子中真实出现的,否则匹配不到。这个函数一次只处理一个句子,因为词和句子的对齐关系是句级的,批量处理反而容易乱。

4.2 中文词被切碎时,先看 tokenizer 的切分结果

中文 BERT 的 tokenizer 对未登录词会按字切分,比如“自然语言”可能被切成“自”、“然”、“语”、“言”四个 token。这时候gen_word_vec()取平均,得到的向量其实是四个字向量的均值,语义上还算合理,但和英文单词的子词平均不是一回事。我一般会先打印一下切分结果:

print(tokenizer.tokenize("自然语言处理")) # 可能输出: ['自', '然', '语', '言', '处', '理']

如果输出是单字列表,说明这个词在词表里没有整体条目,取平均是唯一选择。如果你想要更接近“词”的向量,可以考虑用bert-base-chinese的 whole word masking 版本,但那个需要对应的权重,这个包里不一定带。常见做法是接受单字平均,因为下游任务(比如词性标注)对子词粒度的向量也能用。

4.3 词向量和句向量的维度对齐

gen_sen_vec()返回(N, 768),gen_word_vec()返回每个词一个(768,)向量,两者维度一致,可以直接拼接或做注意力。但要注意,句向量取的是[CLS],词向量取的是 token 位置,它们来自同一层last_hidden_state,所以语义空间是共享的。如果你把句向量和词向量做点积,得到的是[CLS]和某个词的相似度,这个值在未微调模型上通常偏低,因为[CLS]和 token 的表示在预训练目标里没有直接对齐。这个坑我在做关键词抽取时踩过,后来改成用mean pooling的句向量和词向量算相似度,效果才正常。

5. 避坑与排查:权重、显存、版本这三类问题最耗时间

5.1 现象:加载权重时报OSError: Can't load weights

原因:conf.py里的BERT_MODEL_PATH指向的目录里没有pytorch_model.bin,或者文件名不对(有些包会叫bert_model.bin或pytorch_model.bin.index.json)。解决:进目录ls一下,确认有pytorch_model.bin或tf_model.h5,以及config.json和vocab.txt。如果只有pytorch_model.bin.index.json,说明是分片权重,需要所有pytorch_model-00001-of-0000X.bin都在同一目录。

5.2 现象:gen_sen_vec()输出全是一样或接近的向量

原因:模型没切到eval()模式,dropout 还在生效,导致每次推理结果有随机性;或者[CLS]向量在未微调模型上本身区分度低。解决:先加model.eval(),再换mean pooling对比。如果还是接近,检查MAX_SEQ_LEN是不是设得太小,句子被截断到只剩[CLS]和[SEP],那向量自然没信息。

5.3 现象:CUDA out of memory,但 batch size 已经降到 1

原因:MAX_SEQ_LEN设了 512,且model还在 GPU 上,encoded的 tensor 也占显存。解决:先把MAX_SEQ_LEN降到 128,再确认torch.no_grad()加了。如果还爆,用torch.cuda.empty_cache()清一下缓存,或者把model切到 CPU 跑小批量调试。

5.4 现象:gen_word_vec()返回的None比预期多

原因:目标词在句子里的形式和你传入的words不一致,比如句子里是“自然语言处理”,你传的是“自然语言”,tokenizer 切分后子词序列对不上。解决:先用tokenizer.tokenize()打印句子和词的切分结果,确认子词序列能匹配。如果词是句子的一部分但切分不同,考虑用tokenizer.encode()的 offset 映射,而不是字符串匹配。

5.5 现象:layers_keras.py导入报ImportError: cannot import name 'BertModel'

原因:这个文件可能依赖keras-bert或bert4keras这类第三方包,而不是 Hugging Face 的transformers。解决:打开文件看import来源,如果是from bert4keras.models import build_transformer_model,那就pip install bert4keras,但要注意bert4keras和tensorflow版本有绑定关系,常见组合是bert4keras==0.11.5+tensorflow==2.5.0。如果不想折腾 TF,直接忽略这个文件,用extract_sen_vec.py的 PyTorch 路径。

6. 进阶技巧:把向量抽取接进检索流水线的一个小改动

这个包默认是“给句子,出向量”,但实际检索场景里,你往往需要把向量存下来、建索引、再查。我一般会在gen_sen_vec()外面加一层缓存,把句子和向量的映射存成npz,避免每次查询都重新过 BERT:

import numpy as np import hashlib def sen_vec_with_cache(sentences, cache_path="vec_cache.npz", pool_type="mean"): # 用句子内容的 md5 做 key,避免重复计算 keys = [hashlib.md5(s.encode("utf-8")).hexdigest() for s in sentences] try: cache = dict(np.load(cache_path, allow_pickle=True)) except FileNotFoundError: cache = {} todo = [(k, s) for k, s in zip(keys, sentences) if k not in cache] if todo: new_vecs = batch_gen_sen_vec([s for _, s in todo], pool_type=pool_type) for (k, _), v in zip(todo, new_vecs): cache[k] = v np.savez(cache_path, **cache) return np.vstack([cache[k] for k in keys])

逻辑说明:hashlib.md5把句子转成固定长度的 key,np.load读缓存,np.savez写回。allow_pickle=True是为了兼容 numpy 的 object 数组,但如果你只存 float 向量,可以去掉。这个改动的价值在于:当你的语料有几千条时,第一次跑完缓存,后续查询直接读npz,响应时间从秒级降到毫秒级。参数上,cache_path建议放在 SSD 上,npz文件大小约等于句子数 × 768 × 4 字节,一万条句子大约 30MB,完全可控。

验证缓存是否生效,可以跑两次同样的句子,第二次看耗时:

import time sents = ["测试句子一", "测试句子二"] * 500 t0 = time.time() v1 = sen_vec_with_cache(sents) t1 = time.time() v2 = sen_vec_with_cache(sents) t2 = time.time() print("first run:", t1-t0, "second run:", t2-t1)

第二次耗时应该明显低于第一次。如果没降,检查cache_path是否可写,或者np.savez是否被异常吞掉了。这个缓存层我后来在好几个项目里都保留了,因为 BERT 推理的耗时和句子数量是线性关系,缓存是唯一能把重复查询成本压到接近零的手段。从那以后我每次接向量抽取任务,都强制先加缓存再跑全量,希望帮到你。

本文还有配套的精品资源,点击获取

返回列表