:核心原理與工程實踐)
1. 項目概述與設計思路1.1 為什么文本翻譯選 Transformer 而不是 LSTM前陣子我在本機搭了一套中英文本翻譯演示系統(tǒng)跑通之后最大的感觸是如果放在幾年前拿循環(huán)神經(jīng)網(wǎng)絡做同一個任務光是把代碼調成能并行訓練這一步就能磨掉我大半天的耐心?,F(xiàn)在用 Transformer 作為序列到序列翻譯模型的骨干整條鏈路從數(shù)據(jù)清洗到端到端推理邏輯清晰太多了。翻譯本質是一個序列到序列問題輸入是源語言的一句話輸出是目標語言的一句話。早年的主力方案是編碼器-解碼器結構的 LSTM編碼器把源句壓成一個固定長度的隱狀態(tài)向量解碼器從這個向量出發(fā)逐步生成目標詞。問題是句子稍微長一點固定向量根本裝不下全部語義越靠前的信息越容易被后續(xù)內容沖淡。Transformer 繞開了“順序壓縮”的思路用自注意力機制讓每個詞直接對全句其他詞做加權關聯(lián)這就等于把“逐詞理解”變成了“全局關系建?!薄3鯇W者最容易問的問題翻譯任務這么依賴上下文為什么不用更適合處理序列的循環(huán)結構答案是并行效率。LSTM 必須按時間步串行計算第 t 步依賴第 t-1 步的隱狀態(tài)GPU 等于被迫排隊跑而 Transformer 的注意力計算在單層內可以完全并行訓練速度上一個量級。模型表達力更強速度還更快自然成為主流選項。1.2 這套系統(tǒng)到底做了什么、適合誰參考這個項目最終完成的功能很實在給定一句中文系統(tǒng)輸出一句英文給定一句英文系統(tǒng)也能反向輸出中文。嚴格來說這是典型的開放詞表翻譯任務而不是那種只能翻幾十個固定句式的玩具 Demo。整個系統(tǒng)包含數(shù)據(jù)預處理、詞表構建、模型訓練、推理解碼四個模塊我用 PyTorch 復現(xiàn)了 Transformer 的標準結構編碼器六層解碼器六層注意力頭數(shù)八模型維度 512。訓練時使用 WMT 新聞語料的一個子集清洗之后保留約一百萬對中英平行句對在單卡 GPU 上跑了兩輪多。這篇博文適合兩類人一是剛接觸 NLP、想做文本翻譯但不知道從哪下手的初學者跟著把每個模塊拆開之后能建立 Transformer 的完整直覺二是已經(jīng)有工程經(jīng)驗、想快速驗證 Attention 機制實際效果的朋友可以直接參考我貼出來的關鍵實現(xiàn)和踩坑記錄。我始終覺得與其照著教程“抄代碼”不如先把設計思路想明白。1.3 把“翻譯任務”拆成四個關鍵環(huán)節(jié)如果只給一句話概括這套系統(tǒng)的工程鏈路我會寫數(shù)據(jù)清洗與句子對齊 → 子詞切分與詞表構建 → Transformer 編碼器-解碼器訓練 → 束搜索解碼輸出。這四個環(huán)節(jié)每一步都有坑每個坑都會影響最終翻譯質量。有意思的是很多人在模型結構上花大功夫調參反而忽視前兩步。實際上平行語料里中文和英文句子經(jīng)常存在數(shù)量不對齊、標點錯位、長句亂斷的問題如果輸入是“你好世界”這種中英文之間詞序相差很大的句子分詞方式又直接決定了模型能否學到詞對齊關系。我把清洗規(guī)則寫得很保守去掉長度超過 80 個 token 的長句過濾雙語長度比例超過 3:1 的對齊對統(tǒng)一把全角標點轉半角。這套規(guī)則不做特別聰明的處理但很可靠。2. 核心細節(jié)解析與實操要點2.1 輸入表示詞嵌入和位置編碼缺一不可自注意力機制本身不具備天然的順序感如果不加位置編碼句子“貓追老鼠”和“老鼠追貓”對模型來說沒有任何區(qū)別。這是 Transformer 和 RNN 最大的結構差異RNN 靠遞推順序隱式獲得位置信息Transformer 必須顯式給每個 token 注入位置信號。我采用的是原論文里的正弦位置編碼位置編碼向量直接和詞嵌入向量相加然后一起送入第一層。公式如下PE(pos, 2i) sin(pos / 10000^(2i / d_model))PE(pos, 2i1) cos(pos / 10000^(2i / d_model))其中 pos 是 token 在句子里的位置索引i 是向量維度序號。選擇正弦函數(shù)而不是學出來的位置向量主要是為了處理超長句子模型沒見過的長度也能生成對應的位置編碼不依賴訓練數(shù)據(jù)的長度分布。這里有個很容易被忽略的細節(jié)位置編碼的振幅和詞嵌入初始化標準差要保持在同一個量級。如果詞嵌入初始化成均值為 0、方差 1而位置編碼系數(shù)是固定三角函數(shù)的振幅 1兩者相加之后輸入分布會被位置信號主導。我實際實驗時把詞嵌入層先乘以 sqrt(d_model) 做縮放再疊加位置編碼訓練穩(wěn)定度明顯好很多。2.2 多頭注意力Q、K、V 的角色分工注意力機制可以類比成一個“信息檢索”過程。每個 token 生成三個向量Query 表示“我關心什么信息”Key 表示“我能提供什么信息”Value 是“我擁有的信息內容本身”。當前 token 拿 Query 去和所有 token 的 Key 做點積得到一個相似度分數(shù)經(jīng)過 softmax 歸一化成權重再去加權所有 token 的 Value完成信息融合。多頭就是把這個過程復制多份。每個頭使用獨立的投影矩陣把詞向量投影到不同的子空間讓模型分別關注不同的關系。比如一個頭學會關注句法主語和謂語的關系另一個頭關注代詞和先行詞的關系最后的輸出把所有頭的向量拼接起來再經(jīng)過一次線性變換。八個頭可以理解為八套“并行的關系透鏡”。我在項目里比較特殊的處理是把注意力分數(shù)除以 sqrt(d_k)也就是 Query 向量維度的平方根。d_k 是 64512 除以 8 個頭的數(shù)量所以除以 8。如果不縮放兩組隨機初始化向量做點積方差會隨著維度增大變得很大softmax 之后概率分布接近獨熱梯度無法有效回傳。這個縮放是整個自注意力能夠穩(wěn)定訓練的基石之一新手調參時往往忽略這個“常數(shù)”。2.3 編碼器和解碼器掩碼遮住的未來信息翻譯模型解碼時有一個關鍵約束生成第 t 個詞時模型只能看到已經(jīng)生成的 t-1 個詞絕不能偷看后面的目標詞。想像一下考試寫作文你寫到第五個字的時候當然不知道接下來要寫的字是什么——模型也一樣。因此解碼器里的自注意力必須施加“未來掩碼”把矩陣右上角的部分置為負無窮softmax 之后這些位置的注意力權重會趨近于零。編碼器和解碼器的另一個區(qū)別是解碼器中間多了一個交叉注意力層cross-attention。這個層的 Query 來自解碼器上一層Key 和 Value 來自編碼器輸出。也就是解碼器在生成詞的時候不僅參考自己已經(jīng)生成的內容還持續(xù)參考源句的全局語義。這是機器翻譯最核心的“對齊”機制每生成一個目標詞都要決定源句哪些詞貢獻最大。我在實現(xiàn)里把三種 mask 放在一起做了統(tǒng)一封裝。padding mask 把填充符位忽略look-ahead mask 防止解碼器偷看未來目標詞交叉注意力的 padding mask 去掉編碼器填充位。這塊代碼最容易被初學者寫亂尤其要小心 mask 廣播的維度。我在調試時用過一個很笨但有效的方法把 mask 值可視化生成一個上三角是深色、下三角是淺色的矩陣圖一眼就能看出未來掩碼是否生效。2.4 殘差連接、層歸一化和前饋層Transformer 每一層都在做“先變換再繞過變換”的操作。子層輸出先經(jīng)過殘差連接與輸入相加再做層歸一化。殘差連接保證了深層網(wǎng)絡的梯度不會因為層數(shù)加深而消失層歸一化則讓每一層的激活值保持穩(wěn)定的分布。在翻譯這種生成任務里訓練和推理階段最怕的就是“小波動被不斷放大”某層輸入均值和方差異常最終輸出幾乎一定會崩。LayerNorm 和 BatchNorm 的差別在于LayerNorm 是對單個樣本的所有特征維度做歸一化不依賴 batch 大小mini-batch 特別小的時候也非常穩(wěn)定。我在訓練時把 batch 壓到 32 個句子LayerNorm 依然表現(xiàn)良好換成 BatchNorm 很可能已經(jīng)無法收斂了。前饋層就是兩層的全連接網(wǎng)絡中間加 ReLU 激活維度從 512 上升到 2048再壓縮回 512。這個層位置在注意力子層之后可以理解為“注意力負責融合全局信息前饋層負責逐 token 做非線性變換”。整個 block 的堆疊過程就是 6 層編碼器 6 層解碼器層與層之間互相傳遞表征最后一層輸出再接進線性層和 softmax產(chǎn)生目標詞的概率分布。2.5 分詞與詞表BPE 子詞切分到底解決什么問題中英翻譯里一個棘手問題是未登錄詞。日常語料中經(jīng)常出現(xiàn)人名、地名、機構名如果按詞為單位建模詞表動輒幾十萬稀疏問題非常嚴重。我采用的方案是 BPEByte Pair Encoding先把語料切到字符級別再反復合并出現(xiàn)頻率最高的相鄰字符對最終得到一套規(guī)模適中、包含完整語義單元中又保留字符彈性的子詞表。子詞化的價值體現(xiàn)在翻譯英文單詞 understanding 時模型可以選擇生成 unprefix、stand 和 ing 多個片段組合出完整含義。遇到詞表外的生詞時至少能降級用字符合成避免全部變成 UNK。我的詞表規(guī)模控制在 32000 個子詞中文部分按字為單位切分這種組合在翻譯質量和小語料適配性之間取得了平衡。動手實踐的時候我建議先用現(xiàn)成的分詞工具做快速驗證比如 SentencePiece 或 HuggingFace 的 tokenizer等整體流程跑通之后再嘗試做自定義標簽化。分詞水平直接決定模型上限如果詞表里中文常用字都缺后面的 Transformer 結構再精巧也是白搭。3. 實操過程與核心環(huán)節(jié)實現(xiàn)3.1 數(shù)據(jù)準備與清洗流程這個項目的訓練語料來自公開的中英平行語料子集。這里要特別注意語料下載之后不要直接進模型要做三層清洗。第一層是格式清洗。把編碼統(tǒng)一成 UTF-8繁體中文轉成簡體英文大小寫歸一剔除包含亂碼、HTML 標簽或異常 Unicode 字符的句子。第二層是對齊質量過濾。中英句對經(jīng)常存在“一句中文對應兩句英文”的情況我按照標點切分后做兩兩組合盡量保證句對語意完整。第三層是長度過濾。過短的句子少于 4 個 token信息量小過長的句子超過 80 個 token訓練成本高且容易爆顯存直接去掉。清洗完之后我給數(shù)據(jù)做了打亂和分批。批次大小按 token 數(shù)來算而不是按句子條數(shù)。一個 batch 大約包含 2500 個源端 token這樣能避免批次內句子長短懸殊導致大量 pad浪費計算量。實際操作時我會先按長度大致排序再隨機打亂緩沖區(qū)達到類似分桶的效果。3.2 編碼器-解碼器結構的要點代碼下面是我整理的 Transformer 核心模塊的結構示意去掉了無關的工程細節(jié)保留最容易寫錯的注意力部分。整個項目代碼大約 600 行核心網(wǎng)絡模塊集中在 200 行以內。import torch import torch.nn as nn import math class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len5000): super().__init__() pe torch.zeros(max_len, d_model) pos torch.arange(0, max_len).unsqueeze(1).float() div_term torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)) pe[:, 0::2] torch.sin(pos * div_term) pe[:, 1::2] torch.cos(pos * div_term) self.register_buffer(pe, pe.unsqueeze(0)) def forward(self, x): return x self.pe[:, :x.size(1)] def attention(q, k, v, maskNone): d_k q.size(-1) scores torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(d_k) if mask is not None: scores scores.masked_fill(mask 0, float(-inf)) weights torch.softmax(scores, dim-1) return torch.matmul(weights, v) class MultiHeadAttention(nn.Module): def __init__(self, d_model, num_heads): super().__init__() self.d_model d_model self.num_heads num_heads self.d_k d_model // num_heads self.w_q nn.Linear(d_model, d_model) self.w_k nn.Linear(d_model, d_model) self.w_v nn.Linear(d_model, d_model) self.w_out nn.Linear(d_model, d_model) def forward(self, q, k, v, maskNone): batch_size q.size(0) q self.w_q(q).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2) k self.w_k(k).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2) v self.w_v(v).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2) attn attention(q, k, v, mask) attn attn.transpose(1, 2).contiguous().view(batch_size, -1, self.d_model) return self.w_out(attn)這里我故意把 mask 的維度留作了待傳參數(shù)。實際調用時編碼器自注意力傳 padding mask解碼器自注意力傳 padding mask 與 look-ahead mask 相加的結果交叉注意力傳針對編碼器輸出的 padding mask。維度不匹配是高頻報錯點有一個簡單的自查方法注意力分數(shù)矩陣的形狀一般是 (batch, heads, tgt_len, src_len)mask 必須是和這個形狀兼容的 4 維張量或者廣播后的形狀。3.3 訓練配置與損失函數(shù)選擇訓練翻譯模型我用的損失是交叉熵配合 label smoothing標簽平滑。標準的交叉熵要求模型對正確詞輸出概率為 1這會導致模型過度自信、生成譯文僵化。標簽平滑把正確詞的概率目標設置為 0.9剩余 0.1 平均分配給其他詞給模型一點容忍度。平滑系數(shù)太小沒效果太大會讓模型喪失自信、譯文反而變差我試下來 0.1 是比較穩(wěn)的默認值。優(yōu)化器選擇 Adambeta 參數(shù)為 (0.9, 0.98)epsilon 設為 1e-9。這里最需要注意的是千萬不要直接用一個固定的學習率。Transformer 論文用的是 warmup 策略前 4000 步學習率從很小線性升到峰值之后按步數(shù)的負平方根緩慢衰減。原因是訓練早期參數(shù)變化劇烈學習率太大會把注意力權重推入飽和區(qū)之后很難修正。超參數(shù)配置我整理成了表格這也是我最終跑通實驗的一組可用參數(shù)參數(shù)名數(shù)值說明d_model512嵌入維度注意力輸出維度num_layers6編碼器/解碼器層數(shù)num_heads8多頭注意力的頭數(shù)d_ff2048前饋網(wǎng)絡隱藏維度dropout0.1各子層之間的隨機失活概率batch_tokens2500每個 batch 的源端 token 上限warmup_steps4000學習率預熱步數(shù)label_smoothing0.1標簽平滑系數(shù)max_length80訓練時允許的最大 token 數(shù)訓練時我每 500 步用一個 500 句的驗證集測一次 BLEU。BLEU 值在 1 萬步以前增長緩慢屬于正?,F(xiàn)象不需要焦慮。我自己跑到約 3 萬步時驗證集 BLEU 從 7 漲到 26 左右后面越漲越慢到 6 萬步基本進入平臺期。3.4 推理解碼貪心搜索換成束搜索訓練結束之后推理階段的目標是給定源句逐步生成目標句。最簡單的貪心搜索每一步只取概率最高的詞作為當前輸出。問題是一旦第一步選錯后續(xù)全部被帶偏無法回頭修正。束搜索的思路是每一步保留概率最高的前 beam size 條路徑最終選總體概率最高的那條。我用的是 beam size4再加一個長度懲罰length penalty。模型傾向于輸出較短的句子因為短句每個詞的概率乘起來數(shù)值更大長句反而吃虧。長度懲罰項的做法是給每條候選句子的得分除以一個關于長度的懲罰系數(shù)公式近似為 score / ((5 len) / 6) 的 alpha 次方alpha 取 0.6。這個操作不用動訓練代碼只在推理 decode 時加卻能顯著改善長句譯文的完整性。推理過程中每條 beam 路徑都在按自回歸方式追加 token直到出現(xiàn)句尾符或長度達到上限。我會額外設置一個重復懲罰如果當前候選序列中某個詞出現(xiàn)次數(shù)過多就給它降權。這個技巧在翻譯新聞語料時非常管用能避免模型陷入“不斷重復最后一個詞”的死循環(huán)。4. 常見問題與排查技巧實錄4.1 訓練時顯存耗盡翻譯模型的顯存消耗大頭是注意力矩陣。假設 batch 內某句話長 80自注意力的分數(shù)矩陣是 80x80乘以 batch 大小和注意力頭數(shù)之后內存占用非常可觀。我遇到 OOM 之后的第一反應不是換顯卡而是先檢查 batch_tokens 是否設置過大以及序列是否真的被截斷到 mask 約束的 80。老手都會用梯度累積來繞過單卡顯存限制也就是每 2 到 4 個小 batch 更新一次參數(shù)等效地擴大 batch size。我把 batch_tokens 從 2500 降到 1000再用梯度累積步數(shù) 2 補回來顯存占用大幅下降收斂速度幾乎沒有損失。如果你的顯卡支持混合精度絕大多數(shù)情況下可以無腦開啟在損失不增的前提下把顯存占用再壓掉三分之一。4.2 損失不降或模型不收斂損失完全不下降十有八九是注意力 mask 寫錯了。這里有個特別隱蔽的問題padding mask 的值為 0 的位置用負無窮填充但在某些實現(xiàn)里 mask 的值反過來1 表示有效0 表示無效一旦方向寫反就相當于全部 token 都被屏蔽模型什么都學不到。排查的辦法很簡單把某一個 batch 里 mask 的值打印出來數(shù)一下有效占比是不是大致符合預期。如果損失下降但幅度極慢檢查學習率是否太小或者位置編碼是否沒有正確生效。我遇到過一種情況寫位置編碼時忘記用 register_buffer導致它被當成可訓練參數(shù)參與優(yōu)化整體參數(shù)量增加了幾十萬但損失反而不降。把位置編碼改為不可訓練常量之后訓練立刻恢復正常。4.3 訓練 BLEU 高、驗證 BLEU 低這種情況就是過擬合在百萬級別的語料上尤其常見。我的處理順序比較簡單先加 dropout把每層 dropout 從 0.1 提到 0.2如果仍然過擬合就減小 d_model 到 256最后實在不行才降低層數(shù)。很多初學者的第一反應是加更多語料但實際上先減模型容量往往更有效因為語料清洗不干凈時加數(shù)據(jù)只會把噪聲也灌進去。4.4 譯文出現(xiàn)大量重復詞重復詞問題多半出現(xiàn)在解碼時。除了前面提過的重復懲罰還可以檢查輸出序列是否有“重復片段”。這個現(xiàn)象在中文翻譯里特別明顯因為中文無明顯詞空格單字“的”很容易被反復生成。除了調懲罰項另一個取巧做法是增加驗證集里長句的比例讓模型多學會處理平穩(wěn)的長距離依賴。4.5 出現(xiàn)大量 UNK子詞詞表仍然覆蓋不到的低頻詞會成為 UNK。真正有效的解決方法是把語料重新過一遍 BPE合并閾值調低一點比如從出現(xiàn)頻率 2 降到出現(xiàn)頻率 1這樣可以保留更多低頻字符合并。另外中英翻譯中數(shù)字和日期非常容易成為 UNK我專門加了一個預處理規(guī)則把孤立數(shù)字替換為同一個保留 token解碼之后再映射回原數(shù)字。這個小技巧立竿見影比擴容詞表省事得多。下面把上述問題整理成速查表方便大家直接對照現(xiàn)象可能原因排查與處理OOMbatch token 數(shù)過大減 batch_tokens / 梯度累積 / 混合精度損失不降mask 方向錯誤 / 位置編碼可訓練打印 mask / 用 register_buffer 固定位置編碼驗證 BLEU 低過擬合提高 dropout / 減小模型容量輸出重復解碼約束不夠加重復懲罰 / 提高長度懲罰 alpha大量 UNK詞表覆蓋不足調節(jié) BPE 合并閾值 / 預替換數(shù)字符號5. 一些個人的實操心得項目做到最后我發(fā)現(xiàn)比起模型結構工程習慣對結果的影響被很多人低估了。常常有人問我“注意力頭數(shù)改成 16 會不會更好層數(shù)加到 12 是不是就能超過論文”我不否認調參能漲點但在我這套中英翻譯任務里真正把 BLEU 從 20 拉到 25 的改動反而是數(shù)據(jù)清洗得更干凈、詞表構建得更穩(wěn)、標簽平滑系數(shù)調得更準。對我個人來說還有一個印象很深的體會不要在一開始就追求在最大數(shù)據(jù)集上訓練先用一個 5 萬句的小樣本集把完整流程跑通確認代碼沒有 bug 之后再切換到大語料正式訓練。小樣本十分鐘就能出一個結果哪怕模型效果差也能快速驗證 mask、位置編碼、推理解碼這些關鍵環(huán)節(jié)都正常。否則直接上百萬句數(shù)據(jù)訓練三個小時發(fā)現(xiàn)損失不降排查成本會高得讓人想放棄。最后分享一個小技巧解碼時把注意力權重單獨存下來選幾個典型句子畫成熱力圖。你可以直觀看到“I”這個詞在翻譯“我”的時候注意力主要落在了源句的哪個位置??吹綗崃D清晰地呈現(xiàn)出中英文的詞對齊關系我那時候才真正覺得自己理解了 Transformer 翻譯的精髓。這個項目跑通之后還可以往兩個方向繼續(xù)擴展一是替換成更大規(guī)模的預訓練語言模型做初始 checkpoint用領域語料微調二是加入多模態(tài)信息比如圖片描述、語音轉錄和文本翻譯聯(lián)合訓練。每一步都是新的坑但也都很值得走。