以下内容参考https://www.rethink.fun/常见任务在LLM诞生之前NLP是分成多个任务研究的常见任务有文本分类最后输出每类的概率文本回归最后输出一个连续的分数类似于一般的回归任务翻译改写经典的序列到序列任务输入输出都是序列实体识别Named Entity RecognitionNER输出和输入等长输出标记每个token属于什么实体最后一般能提取出一句话的所有名词文本蕴含输入两个序列输出一个分类结果表示两个序列是否有蕴含关系也就是逻辑上一个能否推出另一个语言模型也就是现在最火的对话式模型输出一个序列但输出并没有严格的格式限制可以模型随意发挥分词输入的自然语言想要通过计算处理它必须转化成数据这分为两步第一步是划分token第二步是把token映射到数据。第一步就是分词(tokenizer)第二步则是词嵌入(embedding)我们先来看分词对于中文除了标点一句内是没有划分标志的所以中文断句是个很大的问题。英文虽然有空格断句但是也需要分词不能直接按照空格因为英文的造词法决定了词是很多的如果每个词都单独划出一个token词典会太大比如一个词的各个时态显然可以划分成原型各个时态的标志(ing,ed)。这里介绍一个最常见的分词方式BPE(Byte Pair Encoding)分词BPE 的核心思想是“贪心自底向上合并”从字符级别开始不断找出语料库中出现频率最高的两个连续字符对把它们合并成一个新的子词直到词表达到预设的大小。简化案例假设我们的训练语料库里只有 3 个词它们出现的次数如下hug (出现 5 次)pug (出现 5 次)pun (出现 12 次)第 0 步初始化词表将所有词拆成单个字母并在结尾加上结束符 。此时基础词表为h, u, g, p, n, 。第 1 轮迭代找最高频的组合统计所有相邻字母对的频率u 和 g 组合在 hug 出现 5 次pug 出现 5 次→\rightarrow→共 10 次。u 和 n 组合在 pun 出现 12 次→\rightarrow→共 12 次。p 和 u 组合在 pug(5) 和 pun(12) 出现→\rightarrow→共 17 次。(注这里简化的最高频假设为 u 和 n) 经过统计u 和 n 连续出现的次数最多12次于是我们将它们合并为新子词 un。词表更新为h, u, g, p, n, , un第 2 轮迭代继续合并接着统计发现 p 和 un 的组合在 pun 中出现了 12 次又是最高于是合并为 pun。词表更新为h, u, g, p, n, , un, pun第 3 轮迭代接下来发现 u 和 g 组合出现了 10 次合并为 ug。词表更新为h, u, g, p, n, , un, pun, ug这个过程会一直重复比如循环 32000 次直到词表里包含了足够多的高频词和子词。这个算法的强大之处在于能划分原型词根也能划分前后缀最后的词典会包含happy这样的词根也会有ful,un这样的前后缀当前后缀和词根作为两个token连续出现可以表示单词的变形。前面我们说的是BPE的训练过程训练出来之后实际分词时还是先把序列拆成字符然后每次把当前序列里出现的词对中在字典中优先级最高的进行合并一直合并直到不存在字典中的待合并词对这类似一个链表的合并过程n个节点的话n-1个间隙有n-1个可合并词对每次选一个合并元素个数-1。字典中的优先级就是在训练过程中的合并顺序训练中出现频率越高的词对合并会越早。BPE的推理过程很像算法竞赛题这里展开说一下大概流程就是初始一个链表每个节点是一个词n个节点n-1个相邻对可合并初始一个map词典记录每个词对的优先级。构造一个优先队列保存每个相邻对的他们的优先级按照优先级排序。每次取出优先级最高的相邻对在链表上进行合并不妨设abcd我们合并bc合并后原有的相邻对(a,b),(b,c),(c,d)失效形成新的相邻对(a,bc)(bc,d)更新单调队列把失效的删除新增的相邻对如果在词典中存在则查询优先级插入优先队列。重复上述过程直到队列为空。注意token为空可能是合并到只剩一个节点了也可能是新的相邻对不在词典里没有插入队列下面是个简单的模拟实现#includeiostream#includestring#includevector#includequeue#includeunordered_map#includemap#includeiomanipusing namespace std;// 定义 BPE 规则的优先级Rank 值越小优先级越高// Key: 相邻的两个 Token 字符串对, Value: Rankmappairstring,string,intbpe_ranks;// 双向链表节点定义structNode{string token;intprev;intnext;bool is_valid;// 标记节点是否有效是否已被合并销毁};// 优先队列堆中存储的元素structPairItem{intleft_idx;intright_idx;intrank;// 小顶堆比较函数Rank 越小越优先bool operator(constPairItemother)const{returnrankother.rank;}};// 打印当前链表状态的辅助函数voidprint_current_tokens(constvectorNodelist,inthead){cout当前序列: ;intcurrhead;while(curr!-1){cout[list[curr].token] ;currlist[curr].next;}coutendl;}// BPE 推理核心函数vectorstringbpe_tokenize(conststringword){if(word.empty())return{};// 1. 初始化双向链表将单词拆成单个字符vectorNodenodes;for(size_ti0;iword.length();i){Node node;node.tokenstring(1,word[i]);node.prev(i0)?-1:(int)i-1;node.next(iword.length()-1)?-1:(int)i1;node.is_validtrue;nodes.push_back(node);}inthead0;// 链表头指针// 2. 初始化优先队列小顶堆priority_queuePairItem,vectorPairItem,greaterPairItempq;// 辅助函数尝试将相邻对推入堆中autotry_enqueue_pair[](intleft,intright){if(left-1||right-1)return;pairstring,stringp{nodes[left].token,nodes[right].token};if(bpe_ranks.count(p)){pq.push({left,right,bpe_ranks[p]});}};// 初始把所有相邻对放进堆for(size_ti0;inodes.size()-1;i){try_enqueue_pair(i,i1);}cout 开始 BPE 合并推理 endl;print_current_tokens(nodes,head);// 3. 核心循环不断合并最高优先级的对while(!pq.empty()){PairItem toppq.top();pq.pop();intltop.left_idx;intrtop.right_idx;// 延迟删除检查Lazy Deletion// 如果左节点或右节点已经失效或者它们在链表中已经不相邻了说明这个对是过期的if(!nodes[l].is_valid||!nodes[r].is_valid||nodes[l].next!r){continue;}// 命中有效合并string merged_tokennodes[l].tokennodes[r].token;cout\n [合并动作]: 命中规则 (nodes[l].token, nodes[r].token) - merged_token (Rank: top.rank)endl;// 执行合并把新内容写入左节点nodes[l].tokenmerged_token;// 销毁右节点nodes[r].is_validfalse;// 调整双向链表指针intnext_of_rnodes[r].next;nodes[l].nextnext_of_r;if(next_of_r!-1){nodes[next_of_r].prevl;}print_current_tokens(nodes,head);// 形成新的相邻对推入堆中intprev_of_lnodes[l].prev;try_enqueue_pair(prev_of_l,l);// 新的左侧邻居对try_enqueue_pair(l,next_of_r);// 新的右侧邻居对}// 4. 收集最终结果vectorstringresult;intcurrhead;while(curr!-1){result.push_back(nodes[curr].token);currnodes[curr].next;}returnresult;}intmain(){// 构造一些预训练好的 BPE 规则// 这里的数字代表 Merge 顺序Rank 越小越优先bpe_ranks[{e,s}]1;bpe_ranks[{es,t}]2;bpe_ranks[{h,i}]3;string input_wordhighest;cout输入原始字符串: input_word\nendl;vectorstringtokensbpe_tokenize(input_word);cout\n 最终 Tokenization 结果 endl;for(conststringt:tokens){cout\t\ ;}coutendl;return0;}词嵌入词嵌入这个名字看着奇怪但这是由他的英文embedding翻译来的实际上问题是我们需要把token映射到数据有很多种映射方法其中一种方法叫词嵌入one-hot编码映射有很多种方式最无脑就是one-hot词典大小为n则把每个词都映射到一个长度n的向量第i个token映射后第i位为1其余位置为0这样的好处编码简单但缺点是词典有几万个词是正常的那一个编码维度就上万太大了如果用余弦相似度衡量两个token的相似度任意两个token都是正交的没有任何关系。但有些token之间的关系显然应该更近比如西红柿和番茄。embedding词嵌入词嵌入解决了one-hot的两个问题。解决方法是降低编码维度通常就128,256维向量每个维度都可以有值这个值是训练出来的。优点是编码维度低便于计算存储训练中会使得语义上相关的token各个维度更加接近也就是token编码方式就包含了一些知识在里面词嵌入训练方法 CBOWCBOW的思想是token的含义要从上下文推断所以训练过程中每个词的嵌入计算方法为相邻token的平均值然后把这个平均值套一个线性层映射到各个token相当于一个分类任务用交叉熵计算loss反向传播更新这个token的嵌入。比如这张图就是I love natural language processing输入训练natural这个token嵌入的前向传播流程。训练时每一轮遍历所有token都做类似的操作。最后我们得到的数据是一个矩阵每一行是一个token的嵌入向量。想得到低i个token的词嵌入就用这个token的one-hot表示左乘这个矩阵如图简单示例importtorchimporttorch.nnasnnclassCBOW(nn.Module):def__init__(self,vocab_size,embedding_dim):super(CBOW,self).__init__()# 1. 词嵌入层将词索引转换为低维稠密向量self.embeddingsnn.Embedding(vocab_size,embedding_dim)# 2. 输出线性层将聚合后的上下文特征映射回词表空间self.linearnn.Linear(embedding_dim,vocab_size)defforward(self,inputs): inputs: 形状为 [batch_size, context_size] 的张量包含上下文词的索引 # 提取上下文词的 embedding - 形状: [batch_size, context_size, embedding_dim]embedsself.embeddings(inputs)# 在 context_size 维度上求平均代表“词袋”聚合 - 形状: [batch_size, embedding_dim]context_meantorch.mean(embeds,dim1)# 投影到词表分类空间 - 形状: [batch_size, vocab_size]outself.linear(context_mean)returnout# 极简前向传播测试 if__name____main__:VOCAB_SIZE1000EMBEDDING_DIM128BATCH_SIZE2CONTEXT_SIZE4# 比如窗口大小为 2左边 2 个词 右边 2 个词modelCBOW(VOCAB_SIZE,EMBEDDING_DIM)loss_fnnn.CrossEntropyLoss()# 模拟输入2 个样本每个样本有 4 个上下文词dummy_contexttorch.tensor([[3,5,12,9],[42,8,19,55]],dtypetorch.long)# 模拟标签目标中心词索引dummy_targettorch.tensor([7,23],dtypetorch.long)# 前向传播logitsmodel(dummy_context)lossloss_fn(logits,dummy_target)print(f输入形状:{dummy_context.shape})print(f输出形状 (Logits):{logits.shape})# 应该是 [2, 1000]print(f当前 Loss:{loss.item():.4f})词嵌入训练方法 Skip-gram类似的仍然基于一个词的语义要看上下文这一点。但是反过来遍历所有token用当前枚举token去更新周围上下文token。由于只用到当前token一个数据不用取平均直接把这个token表示套一个线性层还是做分类映射到每个token交叉熵训练分类。模型采样先跳过架构和训练那都是重头戏后面讲。剩下的基础知识是模型如何采样。模型现在只会输出词典中每个token出现在下一个位置的概率我们要怎么根据这个概率输出一段话贪心采样最基础的就是贪心采样每一步都选当前概率最大的token但是问题是我们想要的是生成的文本联合概率最大也就是路径上每一步选中的token概率乘积最大。这会出现贪心算法经典的问题每一步都是局部最优加起来不一定是全局最优比如上面这个例子贪心会输出我喜欢吃饭但实际联合概率最大的路径是我喜欢学习。可能的算法优化这个问题抽象出来看起来似乎就是DAG上找一条乘积最大的路径那能否DP注意到一点一个状态下一个token的概率和这个状态的序列整体有关而不是和结束token有关如果只和末尾几个token有关状态不多还可以DP但和整体有关想DP就必须把整个序列都保存下来那任何一个状态都只对应搜索树上一种方案DP退化到DFS了。而DFS状态空间几乎等于factor(词典大小)factor(词典大小)factor(词典大小)完全不可行。Beam Search 束搜索既然只能搜索那加速只能做一些搜索剪枝了。Beam搜素的思想就是BFS但每一层只保留几个概率最大的路径例如Beam2的话搜索结果如图。这在实现上就是限制队列大小使用优先队列的逐层BFS。当然由于我们是逐层的可以用排序替换优先队列常数更小#includeiostream#includevector#includecmath#includealgorithmusing namespace std;// 模拟模型预测输入当前路径返回每个 Token 的对数概率 log(p)vectordoubleget_log_probs(constvectorintpath,intvocab_size){vectordoublelog_probs(vocab_size);intlast_tokenpath.empty()?0:path.back();for(inti0;ivocab_size;i){// 简单模拟不同的概率实际中由神经网络输出doublep(sin(last_tokeni)*0.450.5);log_probs[i]log(max(p,1e-6));}returnlog_probs;}// 核心的路径表示pairvectorint,double代表一条路径{Token序列,累计得分}typedefpairvectorint,doubleBeam;intmain(){intbeam_size3;intmax_len4;intvocab_size5;// 1. 初始化池子只有一条初始路径分数为 0vectorBeamcurrent_beams{{{1},0.0}};// 假设 1 是起始 Token SOS// 2. 步步推进生成序列for(intstep0;stepmax_len;step){vectorBeamall_candidates;// 展开当前池子里的每一条路径for(constautobeam:current_beams){vectorintpathbeam.first;doublescorebeam.second;// 拿到当前路径下一步所有 Token 的 log 概率vectordoublelog_probsget_log_probs(path,vocab_size);// 每一个可能的新 Token 都拼上去形成新的候选for(inttoken0;tokenvocab_size;token){vectorintnew_pathpath;new_path.push_back(token);doublenew_scorescorelog_probs[token];// 概率对数相加all_candidates.push_back({new_path,new_score});}}// 3. 把所有扩展出来的候选按分数从大到小排序sort(all_candidates.begin(),all_candidates.end(),[](constBeama,constBeamb){returna.secondb.second;});// 4. 贪心切片只保留分数最高的前 beam_size 个作为下一轮的池子current_beams.clear();for(inti0;imin(beam_size,(int)all_candidates.size());i){current_beams.push_back(all_candidates[i]);}}// 5. 打印最终的最优前 3 个结果cout 精简版 Beam Search 结果 endl;for(inti0;ibeam_size;i){cout得分: current_beams[i].second | 路径: ;for(inttoken:current_beams[i].first)couttoken ;coutendl;}return0;}随机生成前面的贪心和束搜索都是确定策略如果希望每次生成都可能不一样就要引入随机性。有topk和topp两种随机策略。topk:选出概率最大的topk个token重新在这k个token里重新softmax计算概率然后根据概率随机抽样。topp从大到小选任意个直到概率加起来超过p。接下来的采样和topk一样这两个方法都可以附加一个参数tempurature温度作用是做softmax前先对所有logits概率除以温度这样温度越大logits被除之后越小经过softmax映射后的概率差距越小token间概率差距变小了也就是随机性变大了。如下图