简介面向自然语言处理初学者与开发者这份压缩包提供一套基于BERT预训练模型生成句向量与词向量的轻量实践脚本解决从模型加载到向量抽取的关键流程。BERT采用Transformer双向编码结构能充分捕捉词语的上下文语义这些向量可支撑文本分类、情感分析、问答、机器翻译等众多任务。包内共3个py文件整体仅4KB其中conf.py用于指定模型权重与词汇表路径extract_sen_vec.py则实现两个核心函数gen_sen_vec()以[CLS]标记的输出作为整句语义向量适用于句子级任务gen_word_vec()按序列对应位置提取每个词的向量适用于词性标注、词义消歧等细粒度场景。已有1127人学习下载适合希望快速理解BERT向量化机制并动手验证的开发者。资源不包含预训练权重需自行下载模型并配置conf.py但脚本已给出清晰的输入格式转换与特征抽取逻辑可直接复用到下游NLP项目。1. 利用BERT预训练模型生成句向量和词向量为什么直接取[CLS]会翻车做自然语言处理的人迟早都会遇到一个需求把一句话或者一个词变成一串固定长度的向量然后拿去算相似度、喂给下游分类器或者直接当作文本嵌入矩阵用。在深度预训练模型普及之前大家习惯用Word2Vec、Glove这类静态词向量句子向量则靠词向量平均效果凑合但说不出所以然。到了bert预训练模型时代这个问题看起来被“顺手解决”了——拿模型跑一遍从某个位置抽一个向量出来就说是句向量或词向量。但真正动手拆过这份资源之后你会发现问题没那么简单什么时候该用[CLS]什么时候该做平均池化词的subword切碎后词向量到底怎么对齐每一条都是坑。这篇笔记适合两类人一类是刚学BERT、想把预训练模型真正用起来的从业者另一类是把BERT当特征提取器接进自己项目、但被向量维度、序列长度、池化方式反复折磨的熟手。资源本身不大核心就三个文件但把它吃透比下载一堆大而全的框架有用得多。2. 资源拆包与conf.py配置先搞清文件在干什么再动手2.1 这个资源包到底装了什么拿到压缩包解压后顶层目录是BertModel-master里面不是那种动辄几个G还要自己下权重的空壳工程。它包含模型结构定义、配置文件、和两个关键脚本。先把文件角色的对应关系理清楚layers_keras.py是用Keras风格写的TransformerEncoder层属于模型骨架部分conf.py负责把预训练权重路径、词汇表路径、最大序列长度这些环境相关的变量集中管理extract_sen_vec.py是真正干活的脚本暴露了gen_sen_vec()和gen_word_vec()两个入口函数。这种组织方式很典型常见做法是模型结构代码独立成模块配置集中在一个文件里对外只暴露特征提取接口。好处是你不需要每次调用都去翻模型初始化的细节只要改conf.py里的路径就能在另一台机器上跑起来。但也正是这种“懒人式”设计容易让人忽略一个关键问题——这个BertModel-master到底是哪一代BERT实现因为不同版本对[CLS]向量、subword切分、padding mask 的处理方式有细微差别直接决定你后面提取的向量靠不靠谱。2.2conf.py配置项逐个拆解conf.py里最重要的就是模型路径相关的三个配置预训练权重文件位置、词汇表文件位置、以及最大序列长度。权重文件通常是bert_model.ckpt或者pytorch_model.bin格式词汇表是vocab.txt这两个路径错了脚本一启动就报找不到文件。我一般会建议把路径写成绝对路径或者用os.path.dirname(os.path.abspath(__file__))拼出相对路径避免在别的机器上跑的时候因为当前工作目录不同而翻车血泪经验。# conf.py 关键配置示意 import os BASE_DIR os.path.dirname(os.path.abspath(__file__)) # 预训练模型权重文件路径 bert_model_dir os.path.join(BASE_DIR, chinese_L-12_H-768_A-12) bert_model_file os.path.join(bert_model_dir, bert_model.ckpt) bert_vocab_file os.path.join(bert_model_dir, vocab.txt) # 序列长度上限越长越吃显存但过短会截断语义 max_seq_len 128 # 是否启用大小写敏感中文一般不敏感 do_lower_case True逻辑说明bert_model_dir指向存放预训练文件的目录bert_model_file和bert_vocab_file分别指定权重和词表。max_seq_len是后面所有特征提取共用的上限超过这个长度的文本会被截断。do_lower_case对英文有用中文场景通常保持默认即可。参数说明max_seq_len的选择要看你的下游任务。做短文本分类64到128就够了做长文本匹配、阅读理解可以拉到256甚至512但显存占用会成倍上涨一定要结合实际显卡情况权衡。千万不要为了省显存把长度压到32以下否则大部分句子被拦腰截断提取出的句向量质量会明显劣化。2.3 运行环境与版本匹配问题这份资源的代码风格偏 TF1.x / Keras和现在主流的transformers接口不太一样。如果你直接用最新的transformers库去加载权重大概率会遇到变量名对不上、ckpt读取失败这类问题。常见做法是先建一个干净的Python 3.6到3.8环境安装tensorflow-gpu1.14或1.15再装keras2.2.4这类和资源配套的版本。提示项目里出现的layers_keras.py是核心实现extract_sen_vec.py里的函数名才是你要调的入口。conf.py的优先级最高先把路径改对再谈后面。3. 句向量提取的两层机制从[CLS]到逐位置输出的差异3.1 BERT的两种输出模式pooler_output 与隐藏层序列Transformer编码器跑完一个句子会输出两种常见的东西一种是[CLS]位置经过额外全连接层变换后的向量也就是pooler_output另一种是每个 token 位对应的隐藏状态序列形状是(batch, seq_len, hidden_size)。资源里的gen_sen_vec()走的是前者也就是把输入按[CLS] ... [SEP]组织跑完模型后取[CLS]位置的向量作为整句话的语义表示。这种做法的理论依据是预训练阶段[CLS]位置被训练成聚合全句信息的入口。在下一句预测任务里模型需要根据[CLS]的输出来判断第二个句子是不是第一个句子的后续所以这个位置的向量天然携带了句子级别的关系信息。但要注意它和“所有token向量的平均”不一定等价前者更偏“分类导向”后者更偏“全局语义覆盖”。# extract_sen_vec.py 中句向量生成的核心流程伪代码 def gen_sen_vec(texts, model, tokenizer, max_len128): # 对每个句子做 tokenize input_ids [] segment_ids [] input_masks [] for text in texts: tokens tokenizer.tokenize(text) # 插入 [CLS] 和 [SEP] tokens [[CLS]] tokens[:max_len - 2] [[SEP]] ids tokenizer.convert_tokens_to_ids(tokens) mask [1] * len(ids) # padding 到固定长度 padding_len max_len - len(ids) ids [0] * padding_len mask [0] * padding_len segment_ids.append([0] * max_len) input_ids.append(ids) input_masks.append(mask) # 喂给 BERT取 [CLS] 位置索引0的输出 outputs model.predict([input_ids, segment_ids, input_masks]) # 假设 outputs[1] 是 [CLS] 向量batch, hidden_size sentence_vecs outputs[1] return sentence_vecs逻辑说明tokenizer.tokenize先把文本切成 tokenmax_len - 2是给[CLS]和[SEP]留位置。input_masks用来标记哪些是真实 token、哪些是 padding虽然取[CLS]向量时 mask 不直接影响这个向量本身但如果不传 mask 或者传错模型前向计算时注意力会覆盖到 padding 区域导致句向量被无意义的 0 向量污染。segment_ids在单句输入时全 0 即可。参数说明max_len要和conf.py里保持一致否则输入维度对不上模型Keras 层会直接报 shape 不匹配的错。outputs[1]的索引具体取哪个取决于你用的layers_keras.py怎么设计返回值有的实现返回(last_hidden, pooler)有的只返回 hidden states这一步务必打印出来确认形状。3.2 词向量提取与subword切碎问题gen_word_vec()面临的核心难题是BERT用的是WordPiece切词一个词可能被切成多个子词。比如“自然语言”里的“自然”可能是一个 token但某些领域生僻词会被切成两三个子词。这时候如果你想拿“整词”的向量直接取第一个子词的隐藏状态会丢失后半个词的信息取所有子词的平均又会被词频和切分方式干扰。def gen_word_vec(text, model, tokenizer, max_len128): tokens tokenizer.tokenize(text) # 记录原始token与子词位置的映射 token_to_subword {} start_idx 1 # 跳过 [CLS] # 这里需要知道 tokenizer 的切分结果才能做映射 # 常见做法基于 tokenizer.vocab 和 tokenize 结果的边界 input_ids tokenizer.convert_tokens_to_ids( [[CLS]] tokens[:max_len - 2] [[SEP]] ) # 前向得到每个 token 的向量 outputs model.predict([input_ids, ...]) hidden_states outputs[0] # (batch, seq_len, hidden) # 通过映射关系聚合子词向量生成词向量 word_vec aggregate_subwords(hidden_states[0], token_to_subword) return word_vec逻辑说明这里最难的是token_to_subword的构建它要求你把原始字符串按空格或者分词工具先切成“词”再逐个词送进tokenizer.tokenize看会被切成几个子词然后记录每个子词在序列中的起止索引。aggregate_subwords可以是最简单的平均也可以是加权平均权重用子词在词表里的出现频率的倒数之类。参数说明max_len在这个函数里同样重要因为如果一个句子太长后半部分的词直接不参与向量计算。另外hidden_states[0]取的是 batch 中第一个样本的所有 token 向量如果你一次传入多个词要按 batch 索引分开取别把所有词混在一起这是最容易犯错的地方——看起来没报错但向量全串位了。3.3 为什么[CLS]向量和平均池化结果不一样实践里你会发现一个现象用[CLS]向量做句子相似度计算和用所有 token 输出的平均向量做相似度计算结果排序经常不一致。原因在于[CLS]向量经过了一个额外的全连接变换BERT中的pooler它被训练用于句子级别的判别任务天然偏向“区分性”而平均池化保留了每个词的局部语义信息更偏向“内容概括”。所以做句子分类时多用[CLS]做语义相似度、文本匹配时平均池化往往更稳。4. 向量后处理与模型适配从裸向量到能用的特征4.1 为什么直接输出会带形状噪点raw BERT输出有个杀伤力很大的特性它不是一个干净的语义空间。(batch, seq_len, 768)的输出里padding 位置的向量不是全零而是模型给定一个残差向量。如果下游任务直接把它 flatten 或者 average 掉相当于把大量噪声引入特征。所以提取之后必须做一步“mask-aware 处理”常见做法是用input_masks把 padding 位置的向量置零再做 mean pooling分母只算真实 token 个数。def masked_mean_pooling(hidden_states, input_masks): # hidden_states: (batch, seq_len, hidden) # input_masks: (batch, seq_len) 值为0/1 mask_expanded np.expand_dims(input_masks, axis-1) # 把 padding 位置变成0 masked_states hidden_states * mask_expanded # 每个样本的真实 token 数 sum_masks np.sum(input_masks, axis1, keepdimsTrue) # 防止除以0 sum_masks np.clip(sum_masks, 1e-9, None) pooled np.sum(masked_states, axis1) / sum_masks return pooled逻辑说明mask_expanded把 mask 从(batch, seq_len)扩成(batch, seq_len, 1)才能和hidden_states逐元素相乘。sum_masks是每个序列真实 token 数量作为归一化分母。这里用np.clip防止某个长度为0的极端输入导致除零。参数说明input_masks必须是和输入序列长度一致、且与真实token位置对应的 0/1 数组不能全部为 1。如果某句话被截断了截断位置后面的 token 虽然不是 padding但原始语义已经残缺这种向量只能当作截断近似无法和完整句子向量做严格比对。4.2 从 keras 层到 keras 模型参数冻结与微调边界layers_keras.py大概率把 BERT 的主干封装成了一个 Keras 层。用这种层搭建模型时有两条路一条是“特征提取模式”加载权重后把层参数的trainable设为 False只做前向推理输出作为特征另一条是“微调模式”让 BERT 层的参数跟着下游任务一起更新效果往往更好但显存开销翻倍。def build_feature_extractor(bert_layer, max_len128): input_ids tf.keras.Input(shape(max_len,), dtypetf.int32) input_masks tf.keras.Input(shape(max_len,), dtypetf.int32) segment_ids tf.keras.Input(shape(max_len,), dtypetf.int32) # 冻结 BERT 层参数 bert_layer.trainable False outputs bert_layer([input_ids, input_masks, segment_ids]) model tf.keras.Model(inputs[input_ids, input_masks, segment_ids], outputsoutputs) return model逻辑说明bert_layer.trainable False让预训练参数在反向传播中不再更新显存占用显著下降只保留前向计算图。如果你想做微调把这一行改成True但注意此时的build_feature_extractor就不再是“提取器”而是一个可训练的骨干网络了。参数说明这层冻结只影响梯度不影响model.predict的输出。你依然可以在冻结状态下拿到 BERT 的隐藏状态。如果想冻结全部 BatchNorm——如果代码里用了 BatchNorm——需要注意某些实现里trainableFalse并不会阻止 BatchNorm 使用全局统计量但 TF1.x 的 BatchNorm 在不同阶段的统计量更新逻辑有区别容易踩到“训练和预测时输出不一致”的坑我一般会在冻结参数后打印每一层的trainable状态做双重确认。4.3 和 transformers 库配合时的适配思路如果你不想用资源自带的layers_keras.py想换成 Hugging Face 的transformers加载同样的权重注意模型的 return_dict 设置。BertModel默认返回一个BaseModelOutputWithPooling结构里面有last_hidden_state和pooler_output前者对应所有 token 向量后者对应[CLS]变换后的向量。和资源里的实现对照gen_sen_vec取的是pooler_outputgen_word_vec取的是last_hidden_state的特定位置。5. 避坑与常见问题排查跑通容易跑对很难5.1 加载权重报错 Key mismatch现象用load_weights载入bert_model.ckpt时提示大量变量名不匹配或者直接 KeyError。原因预训练权重里有些变量是 Adam 优化器状态不是模型参数有些变量名是bert/encoder/layer_0/attention/self/query/kernel这种带前缀的而你的 Keras 层变量名可能没加bert/前缀。解决在load_weights之前先打印model.weights的变量名列表和ckpt文件里的变量名做一次 grep 对比。常见的做法是写一个映射函数把权重里的bert/前缀加到你的层名上或者反过来去掉。如果实在对不上就改用transformers库的from_pretrained加载再用model.bert的输出接入你的层。5.2 输出的向量长度被 padding 拉偏现象把一句长度 5 的词和一句长度 100 的词的句向量做相似度计算结果总是短的被判定为和谁都接近。原因句子短意味着[CLS]后面紧跟[SEP]和大量 padding如果input_masks没传对模型在注意力计算时会把 padding 位置也当成有效 token导致短句向量被 padding 的零点分布稀释。解决强制调试input_masks的正确性最简单的方法是打印mask.sum(axis1)看是否等于每个句子的真实 token 数。不等于就是 tokenize 和 padding 的索引没对齐一个个修。5.3 词向量切分后位置对不齐现象gen_word_vec输入的词在输出向量里找不到对应位置或者取出来的向量明显是另一个词的。原因BERT 的tokenizer.tokenize结果和convert_tokens_to_ids的结果长度一致但如果你先对原始句子做了自己的分词比如 jieba再对每个词单独tokenize词的索引就和你从句子整体 tokenize 得到的索引不一致。解决不要分开处理词。整句先tokenize拿到完整的 token 序列然后再根据tokenizer的分词结果反推每个原始词对应几个 token 位置。如果原始文本是按空格分隔的英文可以直接利用tokenizer的return_offsets_mappingTruetransformers 的 tokenizer 支持拿到每个 token 在原始字符串里的起止位置。5.4 Keras 模型 predict 时报 shape 不匹配现象前向调用model.predict时抛出维度错误说input_ids的 shape 不是期望的(None, 128)。原因conf.py里改了max_seq_len但extract_sen_vec.py或layers_keras.py里用了写死的数值或者构建模型时用的是max_len变量但没从conf引入。解决全局搜索所有max_len、input_shape、sequence_length相关常量统一改为从conf.py导入。还有一个隐蔽点model.predict的输入必须是一个列表顺序和构建模型时的输入列表一致不能少传segment_ids少了就报错。5.5 冻结层后成绩反而下降现象把 BERT 层trainableFalse后测试集指标比随机初始化还差退回去不用 BERT 用 Glove 反而更稳。原因特征提取模式下 BERT 输出的向量不能直接拿去给线性分类器用因为原始语义空间和分类边界不在一个量纲上。某些任务里直接[CLS]向量并不携带足够信息尤其是短文本。解决先做一步向量标准化L2 normalize再尝试多种池化方式[CLS]、mean pooling、max pooling甚至 concat 多种。也可以用[CLS]向量加所有 token 的last_hidden_state的 mean pooling拼成(768 768)维特征再进分类器效果经常比单用一种好。6. 把句向量和词向量用起来相似度检索与多标签分类的实战技巧拿到gen_sen_vec的输出之后最常见的落地场景是语义检索和文本分类。语义检索的通用流程是先对库里的所有文本批量生成句向量存到内存或向量数据库查询时对查询句生成向量再做余弦相似度排序。这里有个容易忽视的点gen_sen_vec输出的向量有没有做归一化直接决定相似度的数值范围不归一化的话余弦相似度计算会受向量模长影响这属于玄学问题里少数能准确定位的一类。def build_faiss_index(vectors): # vectors: (num_samples, hidden_size) float32 numpy array import faiss dim vectors.shape[1] index faiss.IndexFlatIP(dim) # 内积索引配合归一化等价于余弦相似度 faiss.normalize_L2(vectors) # 原地 L2 归一化 index.add(vectors) return index def search(query_vec, index, top_k5): query_norm query_vec.reshape(1, -1).astype(float32) faiss.normalize_L2(query_norm) scores, idx index.search(query_norm, top_k) return idx[0], scores[0]逻辑说明IndexFlatIP是内积索引在向量都做了 L2 归一化之后内积就等于余弦相似度。faiss.normalize_L2是原地操作会直接修改传入的数组所以传入之前要确认这个向量后续不再使用原始值。idx返回的是满足条件的样本下标scores是对应的相似度分数。参数说明dim必须和gen_sen_vec输出的维度一致BERT-base 通常是 768如果你用的是中文 L-12-H-768-A-12 模型输出维度就是 768。如果用的是其他规模的模型比如 H-1024维度要跟着改否则faiss建索引时直接报维度错误。多标签分类场景里句向量只是第一步。常见做法是把gen_sen_vec输出的向量接到一个tf.keras.Sequential分类头上输出层用 sigmoid 激活损失函数用 binary crossentropy。如果句子数量不大、标签数很多直接在gen_sen_vec后面接一个全连接层可能会过拟合我一般会先加一层 256 维的中间层和 Dropout(0.3)再做输出。def build_multilabel_classifier(bert_vectors, num_labels): inputs tf.keras.Input(shape(bert_vectors.shape[1],)) x tf.keras.layers.Dense(256, activationrelu)(inputs) x tf.keras.layers.Dropout(0.3)(x) outputs tf.keras.layers.Dense(num_labels, activationsigmoid)(x) model tf.keras.Model(inputs, outputs) model.compile(optimizeradam, lossbinary_crossentropy) return model逻辑说明num_labels是标签总数sigmoid 每个输出节点独立判断“属于/不属于”适合一篇文章同时命中多个标签的场景。Dropout只在训练时生效predict 阶段自动关闭。参数说明bert_vectors.shape[1]表示特征维度建议先校验和gen_sen_vec的输出维度一致。256是隐层节点数标签很少可以降到 128标签超过一百就保持 256 或调高。0.3的 dropout 比例在中小样本下比较稳如果训练集上千条可以把 dropout 降到 0.2 增加拟合能力。另一个值得试的技巧是把词向量和句向量串联。比如先对一句话生成句向量 A再对句中的关键词生成词向量把两个向量拼接喂给分类模型。这种做法在短文本分类、意图识别里经常能涨点因为词向量补充了句向量里被平均掉的局部重点信息。实现时注意拼接顺序要保持一致不能今天向量在前、明天词向量在前。做完这些之后最好留一步自检对推理接口做一次 regression test输入同一句话两次看输出向量是否完全一致。如果前后不一致大概率是模型层没有被冻结或者 BatchNorm 在不同阶段统计量更新导致。从那以后我每次写完提取脚本都会强制走一遍这个检查再把自己手头几条典型的测试数据短句、长句、生僻词、全英文、全数字的向量打印出来人工瞄一眼。这一步能拦住一大半的隐性 bug。希望这篇拆解能帮你在BERT向量提取这条路上少踩几个坑把有限的时间花在真正有价值的调优上。本文还有配套的精品资源点击获取