资讯详情

资讯详情

BERT动态词向量详解:从Word2Vec到双向编码与实战

1. 从Word2Vec到BERT为什么还需要高级词向量大概五年前如果有人说给我一组词向量我就能做个文本分类那多半说的是Word2Vec或GloVe。那时候的词向量模型思路很统一把每个词映射成一个固定维度的稠密向量然后作为神经网络的第一层输入就完事了。业内普遍的做法是用别人训练好的词向量加载进来下游任务几乎不怎么更新词向量层效果也还行。但用久了就会明显感到天花板。最典型的问题是一词多义。英文里的bank可以是银行也可以是河岸中文里的苹果既可以是水果也可以是手机品牌。Word2Vec这类静态词向量给每个词只分配一个向量不管上下文怎么变向量基本不动。这个问题的本质在于静态嵌入把词的语义锁死在一个全局位置上它压根没有能力感知这个词出现在什么语境里。所以在很多场景下明明是同一个词语义截然不同模型却只能一视同仁这就容易出现严重的歧义误判。另一个问题更加隐秘编码方式是单向的。早期基于RNN/LSTM的LM模型读句子是从左到右依次读生成的每个词的表征只依赖左侧的上下文。做语言模型时这么做无可厚非但要做文本分类、语义匹配这类需要全句理解的任务只看到半边天自然吃亏。举个例子小明打碎了花瓶被妈妈________了这种任务你说要填批评还是责骂只靠前面半句很难判断但看到后面的花瓶碎了语义方向就清楚了——这就是双向上下文的价值。BERT的出现在两个维度上同时解决了这两个问题动态表征 双向编码。它不再是查表得到词向量而是通过Transformer编码器在运行时计算每个词在特定上下文中的向量。同一个苹果在苹果公司发布了新手机里和我削了一个苹果里向量是不同位置的、语义上区别巨大的两套表示。这个词向量严格来说叫上下文表征更准确但大家习惯了继续叫它高级词向量。我在实际项目里体会最深的一点是静态词向量是死的适合做词法层面的先验知识而BERT这样的动态词向量是活的能根据上下文实时调整。两者不是替代关系而是不同层级的工具。这也是我为什么专门写这篇详解的原因——很多初学者以为BERT就是升级版Word2Vec其实完全不是一回事。把它理解为一个可以由你微调的基础编码器更接近本质。2. BERT核心机制拆解为什么双向编码这么强2.1 Transformer只是底座双向才是灵魂BERT的原论文标题就很直白Pre-training of Deep Bidirectional Transformers for Language Understanding。这里要重点澄清一个常见误解Transformer架构本身并不是BERT的独创真正核心的贡献在于怎么训练以及往什么方向训练。Transformer的Encoder本身天然就是双向的——它用Self-Attention让每个位置能看到序列里所有其他位置。但BERT之前的GPT系列用的也是Transformer的Decoder结构训练时被Mask矩阵遮住了右侧信息所以学习到的表征本质上还是单向的。BERT聪明的地方在于它直接把Transformer Encoder堆起来同时配合两个预训练任务把双向从结构上变成训练目标。用一句话概括BERT做的事情就是把一句话的所有词同时喂进去让每个词都跟序列里其他所有词做注意力交互然后根据左右两边的语境来学习这个词在当前语境下的语义。这不是简单的从左往右读一遍而是整句同时语义建模。实际任务里的差别很明显。我用BERT做的实体识别在被修饰的实体词上它既能看到前面的形容词又能看到后面的同位语从句这种全局视野带来的标注准确率比当年坑了我很久的BiLSTM-CRF方案要高出一截。虽然BiLSTM名义上也叫双向但两个方向的隐状态拼接本质上还是两个单向模型的组合而Self-Attention的每一步都是全序列交互信息融合的深度完全不同。2.2 预训练一Masked Language ModelMLMMLM的思路很直白随机把输入序列中15%的Token替换成[MASK]让模型根据剩余内容预测被遮住位置的原始Token。但我得提醒一个关键细节不是所有被选中的Token都真的变成[MASK]原文里有个80-10-10的策略。80%的概率把Token替换为[MASK]10%的概率替换成一个随机Token10%的概率保持不变这个设计的动机很多人都没搞清楚。如果只把被选的Token替换为[MASK]那模型在微调阶段就永远见不到[MASK]标记——因为下游任务数据里根本没有这个东西。为了让模型在预训练和微调之间不存在分布断裂论文设计了这套带噪声的输入。随机替换成别的Token时模型被迫学习即使你看错了某个词也要依靠上下文恢复真实语义这大大增强了表征的鲁棒性。有人会问为什么不把15%调高我实际尝试过用20%甚至30%的训练变体效果并没有显著提升反而收敛更慢。15%是覆盖率与学习信号强度之间的一个比较平衡的点。这个参数不是玄学它跟训练效率和任务难度的平衡直接相关。2.3 预训练二Next Sentence PredictionNSPNSP解决的是句间关系理解问题。给定句子A和句子B让模型判断B是不是A在原文中的下一句。这个任务看着简单实际上对原生BERT在下游任务上的表现影响深远——尤其是问答、自然语言推断这类需要跨句推理的任务。不过近两年有不少研究指出NSP任务相对较弱甚至在一些中文场景下效果不稳定。我自己在大量业务项目里的体验是对于单句分类、实体识别这类任务NSP有没有影响不大但对于文本匹配、段落问答原生BERT的句间关系判断能力还是明显好于去掉NSP训出来的变体。这背后的解释是NSP迫使模型在预训练阶段就对两个句子的逻辑连通性建模这种能力很难靠单纯的MLM任务自然涌现。2.4 输入表示Tokenization的细节决定效果BERT输入表示由三部分相加而成Token Embedding、Segment Embedding、Position Embedding。其中Segment Embedding用来区分句子A和句子B。Position Embedding不是像Transformer原论文里的三角函数公式而是可学习的——这算一个很实际的设计选择让模型在训练中自己学会位置关系比固定的正弦位置编码更灵活。Tokenization用的是WordPiece。这套算法做的核心事情是把词切分成更细粒度的子词单元。遇到未登录词时并不会像词级模型那样直接扔个UNK而是不断尝试能否切成更小的片段。比如高高兴兴可能被切成高高兴兴这样的子词组合。这个机制极大缓解了OOV问题也让模型对中文这样的语言更友好——中文这边常见的BERT用的是字级别切分也就是每个汉字就是一个Token加上词表里预置的[CLS]、[SEP]、[MASK]等特殊Token。这里有个实操细节值得记住[CLS]位最终输出的向量可以作为整个句子的语义表示。很多教程直接拿[CLS]向量做分类或匹配任务但实际效果不一定比所有Token向量做池化更好。经验是在小数据集上[CLS]往往够用在强业务场景下建议对比一下mean pooling或max pooling的效果再决定策略。3. 从模型到实战用BERT生成动态词向量的完整方案3.1 三大路线预训练、微调、特征提取很多刚入门的朋友一听到用BERT就以为必须从头预训练。实际上BERT的使用方式分为三条完全不同的路线根据你的数据规模和算力条件去选。路线一继续预训练Continue Pretraining在领域语料上继续做MLM训练然后再接入下游任务。适合数据量百万级以上的垂直领域文本比如医疗病历、金融公告。这样做出来的词向量对领域术语的语义把握会好很多。代价是需要的GPU时间不低入门阶段通常不建议直接冲。路线二微调Fine-tuning在预训练好的BERT模型上替换输出层用你的下游任务数据做监督训练。这是目前绝大多数场景的主流做法。文本分类、实体识别、语义相似度、问答抽取全部走这条线。训练成本从一张消费级显卡到几分钟就能跑通小数据集都有可能。路线三特征提取Feature Extraction不修改BERT任何参数只把句子前向传播一遍把某一层或几层的输出向量取下来做下游特征。这种方式在数据量极少、算力紧张或者需要快速验证的时候特别实用。我在个人项目中曾经分别用这三种方案做过对比实验在同一批法律文书数据上路线二的F1比路线三高出约3到5个百分点路线一在继续预训练10个epoch后又比直接微调增加1.5个百分点左右。但如果数据只有几千条路线一反而容易过拟合。所以方案选型本质上是数据和算力的匹配问题没有绝对的优劣。3.2 环境准备与模型加载以中文场景为例最常用的预训练权重是BERT-Base-Chinese。要用HuggingFace的Transformers库来加载环境准备并不复杂。建议Python 3.8以上版本PyTorch 1.10以上Transformers 4.x以上。pip install transformers torch加载模型的代码非常简单from transformers import BertTokenizer, BertModel model_name bert-base-chinese tokenizer BertTokenizer.from_pretrained(model_name) model BertModel.from_pretrained(model_name)这里要注意一个非常容易出错的点Transformers库版本不同BERT模型输出的结构会有差异。旧版本里模型前向传播返回的是tuple新版本返回的是ModelOutput对象。处理时最好统一通过last_hidden_state和pooler_output这类属性来访问不要靠索引凭空猜。3.3 一个可复现的动态词向量提取Demo下面给出一段可以直接运行的代码演示如何让BERT为苹果这个词在两种不同语境下生成各自独立的向量。import torch from transformers import BertTokenizer, BertModel model_name bert-base-chinese tokenizer BertTokenizer.from_pretrained(model_name) model BertModel.from_pretrained(model_name) model.eval() sentences [ 苹果公司发布了新款手机, 我早上吃了一个苹果 ] inputs tokenizer(sentences, paddingTrue, truncationTrue, return_tensorspt) with torch.no_grad(): outputs model(**inputs) last_hidden_states outputs.last_hidden_state print(last_hidden_states shape:, last_hidden_states.shape)输出的last_hidden_states形状是[batch_size, seq_len, hidden_size]其中hidden_size在BERT-Base里是768。要拿到苹果这个词的向量主要工作是找到它在Token序列里的索引。中文BERT是按字切分的苹果会被切成苹和果两个字也就是两个Token。取词级向量有两种做法简单做法取两个Token向量的平均当作苹果的词向量稳妥做法在用Tokenizer时把苹果作为一个整体加入自定义词表让它成为一个Token我建议先用简单做法验证效果等真正落地时再考虑要不要扩充词表。原因在于扩充词表需要同步调整模型的Embedding矩阵在某些下游微调流程里会增加不少工程复杂度。def get_token_position(input_ids, target_word, tokenizer, sentence_idx0): tokens tokenizer.convert_ids_to_tokens(input_ids[sentence_idx]) target_chars list(target_word) start None for i in range(len(tokens) - len(target_chars) 1): if tokens[i:ilen(target_chars)] target_chars: start i break return start, start len(target_chars) - 1 start_1, end_1 get_token_position(inputs[input_ids], 苹果, tokenizer, 0) start_2, end_2 get_token_position(inputs[input_ids], 苹果, tokenizer, 1) vector_1 last_hidden_states[0, start_1:end_11, :].mean(dim0) vector_2 last_hidden_states[1, start_2:end_21, :].mean(dim0) cos torch.nn.functional.cosine_similarity(vector_1, vector_2, dim0) print(两个语境下的苹果向量余弦相似度:, cos.item())实测下来这个相似度通常在0.5到0.7之间绝对低于同一语境下相同词的相似度。这说明BERT确实为同一个词生成了语境化的语义向量——这一点是Word2Vec无论如何做不到的。3.4 如何选择提取层BERT有12层Base版本。不同层输出的向量代表不同层级的语义抽象。业界一般有一个共识浅层1到4层偏向词法、句法特征更接近词的表面信息中层5到8层开始出现较强的句法和语义特征深层9到12层任务相关的语义特征最浓所以提取词向量时选哪一层取决于你的下游任务。做实体识别我个人的经验是第10层左右效果最佳做语义相似度用最后一层和倒数第二层都存在收益差别做文本分类微调模式下模型会自动学权重不用手动选层。如果你做的是特征提取路线而不是微调可以一次性把每一层的向量都存下来然后用一个外部模型去自动选择或加权融合。这种做法在Kaggle竞赛里很常见实战中也极其有效。4. 踩过的坑与排查要点BERT不是开箱即用4.1 长文本截断问题BERT的最大输入长度是512个Token。这个限制来自Position Embedding的维度设计模型结构上并不能直接吃无限长的序列。很多人在做长文本分类时直接truncationTrue强制截断前512个Token。这种做法其实在浪费信息。我处理长文本的经验是三层递进如果关键信息集中在开头和结尾就用头尾截断策略保留前128个Token和后382个Token中间丢弃如果文本结构分段明确就按段落分别过BERT再做融合表征如果算力允许用Longformer或BigBird这类改进架构但工程复杂度会上升不少4.2 [CLS]向量不等于万能句向量这是初学者最容易踩的坑。我从网上看到很多教程直接拿pooler_output当作句向量去做相似度计算结果发现效果还不如普通的TF-IDF。原因在于BERT的[CLS]向量是经过一层激活函数压缩的语义表示它针对的是预训练任务而不是通用的句子相似度任务。我自己做语义匹配的实际体会是直接拿[CLS]向量做匹配在小样本集上效果尚可但大量业务数据上不稳定用最后一层所有Token向量的mean pooling波动较小用CLS向量后接一个可学习的全连接层做分类效果通常最好4.3 未登录词与词表扩充BERT-Base-Chinese的词表大约有21128个Token覆盖常用汉字没什么问题但遇到垂直领域的专业术语WordPiece依然会切成子词甚至单字。这种情况在医学、化学、法律领域经常出现。我建议的处理方法是先统计领域语料中的高频未登录词把词频超过某个阈值的词筛选出来手动加入Tokenizer的custom_tokens里。接着用resize_token_embeddings方法调整模型Embedding大小。加载预训练权重时新加入的Token向量是随机初始化的建议在新数据上做几个epoch的继续预训练让这些向量热身起来。from transformers import BertTokenizer, BertModel tokenizer BertTokenizer.from_pretrained(bert-base-chinese) model BertModel.from_pretrained(bert-base-chinese) new_tokens [碳达峰, 量子比特, 端到端] tokenizer.add_tokens(new_tokens) model.resize_token_embeddings(len(tokenizer))这里有一条重要提醒扩词表操作必须同时保存Tokenizer和模型文件否则下次加载时两边长度不一致直接报错或者随机初始化等于前功尽弃。4.4 训练速度与显存优化BERT-Base模型参数量约1.1亿推理一张卡还好训练时batch size稍微大一点就可能显存溢出。我常用的优化手段包括开启FP16混合精度训练显存占用几乎减半速度提升明显使用梯度累积让小batch也能模拟大batch的训练过程在输入侧做动态Padding按batch内最长句子补齐而不是所有样本都补到512有条件的情况下用DeepSpeed或torch.compile训练速度还能再提一截4.5 常见问题速查表现象可能原因解决方案加载模型报错KeyError权重版本与模型类不匹配检查from_pretrained传入的模型名称是否与本地权重对应输出向量全为NANFP16训练时Loss溢出关闭AMP或调整梯度裁剪阈值[MASK]位置预测结果始终是[UNK]Tokenizer与词表不匹配重新加载Tokenizer并确认添加的Custom Token没有丢失长文本结果异常截断策略不恰当改用头尾拼接或分段处理微调时Loss不降学习率过大或过小BERT微调建议学习率在2e-5到5e-5之间明显低于常规模型4.6 微调时的学习率策略这个点值得单独说。BERT微调时不同层的学习率可以不同——底部层学到的语言知识比较通用变化幅度不应该太大顶部层与任务关系更紧密可以放开去学。实践中我常采用分层学习率底部的Embedding层和前几层Encoder学习率乘0.1到0.3中间层Encoder标准学习率顶层Pooler和分类头原学习率或略高PyTorch里可以这样实现optimizer_grouped_parameters [ {params: [p for n, p in model.named_parameters() if encoder.layer.0. in n or encoder.layer.1. in n], lr: 5e-6}, {params: [p for n, p in model.named_parameters() if encoder.layer.2. in n or encoder.layer.3. in n], lr: 1e-5}, {params: [p for n, p in model.named_parameters() if classifier in n], lr: 3e-5}, ]这种做法的逻辑是预训练阶段已经让底层形成了高质量的通用的语言表征微调时不用大幅改动顶层与任务强相关必须快速适应新任务分布。我在多个项目里验证过安稳地微调几轮比全程一个学习率的收敛速度更快。5. BERT的进阶思路种语言、领域与规模化5.1 中文场景下到底选哪个模型中文BERT的可用权重要比英文更复杂一些。BERT-Base-Chinese是基于字级别切分的参数约1.1亿对大部分中文任务够用了。但如果你做的是中文古文、文言文、方言文本原版BERT在词表覆盖上会非常吃亏这时候需要找领域预训练权重。我建议的新手选型逻辑通用文本分类/实体识别直接用BERT-Base-Chinese数据量大且领域垂直用继续预训练后的领域BERT追求极致性能且算力足够试试RoBERTa-wwm-ext或ERNIE这类强化变体只在学术实验里比较效果可以同时跑三四个模型对比差异实际项目里大多数情况下Bert-Base-Chinese已经能给出不错的基线。如果效果不够先检查数据质量再考虑换模型这个顺序很重要。5.2 预训练任务与表征能力的关系BERT在海量无标注语料上做自监督学习学到的本质上是一种语言先验。它不知道你的业务是什么但它知道语言是怎么组织的哪些词经常共现哪些词在语法上互相修饰。这种先验知识迁移到下游任务时能够为你节省大量标注数据。有个比喻很贴切Word2Vec像是把每个词贴上一张身份证写上词的基本属性BERT则是让每个词在每次出场时都重新化妆化完妆的样子跟出场语境完全相关。所以对外行解释时用语境化妆形容BERT的表征机制听的人一下就懂了。但在真实业务里这种先验也有限度。我见过有人拿BERT直接做情绪色块识别之类的特殊任务效果还不如简单的规则系统。BERT擅长的是语义理解不是每一个NLP问题都得靠它硬解。5.3 从BERT到它的后代们BERT发布之后NLP领域快速演化出一整条谱系。RoBERTa去掉了NSP任务采用更大规模的训练数据和动态Mask策略ALBERT用参数共享大幅降低内存占用ELECTRA把预训练任务改成了替换Token检测还有各类针对中文优化的变体。这些模型各有优缺点但核心的Transformer Encoder架构依然是共同的底座。我在做微调实验时经常会跑一组对照BERT-Base、RoBERTa、ERNIE在同一份数据上的表现。大多数情况下RoBERTa比BERT有微弱的提升尤其是在数据量足够时ERNIE在中文命名实体任务上的优势比较突出。最终选型还是得靠实验数据说话不要盲信任何一篇实测报告。6. 写在最后的一些实操体会折腾了这么久的BERT我最想分享的一个体会是不要把它当成一个黑盒词向量工具去用而是当成一个可编程的语义引擎去理解。词向量只是它的表层输出真正有价值的是它学习和表征语言的方式。在实际项目中我养成了一套固定工作流先用BERT提取特征快速验证可行性然后设计微调基线最后根据数据规模和业务痛点决定要不要继续预训练或者换更强模型。这个流程能让我在最短时间内判断BERT到底适不适合这个任务避免一开始就陷入大模型的算力泥潭。另外数据预处理的质量对BERT的影响比一些人想象的大得多。脏数据、错误标注、类别不均衡这类问题不会因为你用了高级词向量就自动消失。相反BERT的敏感度更高对数据噪声的反应也更加剧烈。所以在所有炫技操作之前先把数据处理扎实。最后给新人一个具体建议不要只跑通from_pretrained就觉得自己会了BERT。建议你自己动手读一读它的配置JSON文件看看num_hidden_layers是多少看看hidden_size是怎么影响显存的把最后一层向量的分布可视化出来亲手做一做苹果在不同语境下向量相似度这种小实验。把基础概念揉碎了、弄懂了再看任何新模型都不会慌。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →