跳至主要內容

    Seq2Seq 與 Attention:翻譯為什麼需要回頭看?

    拆開固定狀態、Teacher Forcing 與每一步的注意力

    sunny 桑尼· · 32 MIN READ · 118 次瀏覽

    Seq2Seq 與 Attention:翻譯為什麼需要回頭看?
    本文目錄

    上一篇拆開了 LSTM/GRU 如何更新狀態。當任務變成翻譯,還要處理兩段長度不同的序列。一句中文翻成英文,輸出可能多幾個詞;翻成法文,動詞和主詞的位置也可能不同。若模型只能讓輸入第 1 個位置預測輸出第 1 個位置,就很難直接表達這種對應關係。

    Seq2Seq 把兩段工作拆開:Encoder 讀來源序列,Decoder 逐步產生目標序列。兩邊長度可以不同,輸出詞也不必和輸入詞逐位置對齊。真正需要釐清的是:Encoder 把讀到的資訊交給 Decoder 的方式是什麼?訓練時餵給 Decoder 的前一個詞,和生成時餵給它的詞,又差在哪裡?

    本文會用六張教材原圖、兩張逐步輸入圖表,以及一段可重跑的 CPU 小程式,分清固定狀態、Teacher Forcing、自回歸生成和 Attention 加權。數值與輸出都是手設機制例,不是訓練過的翻譯模型。

    Encoder 和 Decoder 解決不同的序列工作

    Encoder 依序讀來源 token,將每個位置轉成模型內部的表示;Decoder 則用目前可取得的資訊,一步一步預測下一個目標 token。本文用 x_1, …, x_T 表示長度為 T 的來源,用 y_1, …, y_S 表示長度為 S 的目標。一般不要求 T = S。

    序列模型要描述的,是在已知來源 x 的條件下,依序產生目標 y。概念上可寫成:

    p(y_1, …, y_S | x)
      = ∏ᵢ p(y_i | y_1, …, y_(i−1), x)
    

    每一步預測的是下一個 token;解碼器可用已產生的目標前綴和來源表示,不代表輸入第 i 個位置就要輸出來源第 i 個位置的對應詞。

    圖 1|Input 交給 Encoder,編碼結果以 State 接到 Decoder,再產生 Output。這是一般 encoder–decoder 介面示意,並不表示所有模型都只用一個固定長度狀態。來源:D2L〈The Encoder–Decoder Architecture〉,Aston Zhang、Zachary C. Lipton、Mu Li、Alexander J. Smola,CC BY-SA 4.0。本文依固定版 SVG 轉為白底 PNG,未改動圖元;改作圖同採 CC BY-SA 4.0。

    把整個流程攤開後,固定狀態版本會把 Encoder 讀完來源後的狀態傳給 Decoder。圖中的 n× 是循環層重複堆疊的結構示意,並非要求來源和目標 token 數相同;資料流先經過 embedding,再進入循環模組。

    圖 2|這個固定狀態 baseline 以一個 State 方塊作 Encoder 和 Decoder 之間的資訊橋梁。它適合用來看清基線架構,但不是「長句一定無法處理」的定理。來源:D2L〈The Bahdanau Attention Mechanism〉,Aston Zhang、Zachary C. Lipton、Mu Li、Alexander J. Smola,CC BY-SA 4.0。本文依固定版 SVG 轉為白底 PNG;改作圖同採 CC BY-SA 4.0。

    把很多來源資訊壓到有限狀態,可能成為表示與訓練上的瓶頸;但這不代表所有長序列都必然失敗。模型大小、資料、訓練方式和任務都會影響結果。後面的 Attention 會提供另一種讀取來源表示的方法,但也不能保證任意長度或翻譯品質。

    訓練會把正確的前一詞餵回 Decoder

    訓練一個自回歸 Decoder 時,常用 Teacher Forcing:要預測第 i 個目標 token,就將前一個正確 token y_(i−1) 作為這一步的輸入。第一步沒有前一個目標詞,所以用起始符 <bos>。每一步預測的標籤則是當前的 y_i。

    D2L 原圖用 They are watching . <eos> 作為來源,並示範法文目標 Ils regardent . <eos>。底下那列是右移一格的 Decoder 輸入,起點為 <bos>:

    圖 3|完整原圖先呈現來源詞、目標詞與跨步狀態;手機細節圖在完整圖下方,放大原圖右側 Decoder。跨越細節裁切邊界的 Encoder 狀態仍以完整圖為準。來源:D2L〈Sequence-to-Sequence Learning for Machine Translation〉,Aston Zhang、Zachary C. Lipton、Mu Li、Alexander J. Smola,CC BY-SA 4.0。本文依固定版 SVG 轉成白底 PNG,手機版先列完整圖,再放大右側 Decoder 區域並保留完整 BOS/EOS 標籤;沒有新增圖元。改作圖同採 CC BY-SA 4.0。
    目標步驟 iDecoder 輸入要預測的標籤 y_i
    1<bos>Ils
    2Ilsregardent
    3regardent.
    4.<eos>

    輸入與標籤錯開一格,是為了讓 Decoder 用已知前綴預測下一詞。它不是把同一位置的答案送進去,也不是使用未來 token。訓練時會把每一步的輸出 logits 和正確標籤比較,再依有效目標 token 計算 loss;補長度用的 <pad> 不該當成要預測的詞。這篇原型沒有實作或驗證 target loss masking,因此不把後面的程式當作訓練範例。

    生成時,輸入改成模型自己的上一個輸出

    實際生成時,後續正確詞還不存在,Decoder 得把前一步自己產生的 token 餵回去。這稱為 自回歸生成。下面的原型另用一個省去句點、詞彙小寫的三步例子,以固定、人工指定的 logits 作查表,刻意讓 Teacher Forcing 和自回歸兩條路走出不同輸入:

    生成步驟餵給 Decoder 的詞人工 logits 中最高分的輸出
    1<bos>nous
    2noussommes
    3sommes<eos>

    在本例中,迴圈遇到 <eos> 就停止,另設 max_steps 上限,避免沒有終止符時無限生成。實際系統會依其生成程式設定處理 EOS、長度限制或其他停止條件;不能只憑圖推定某一份 helper 的執行方式。D2L 這個 pinned 版本的 predict_step 會先跑固定步數,之後才在輸出後處理中截到 EOS,並非一輸出 EOS 就立刻離開迴圈。

    圖 4|完整圖保留來源、目標和每一步的回饋迴路;手機版先列完整圖,再放大 Decoder 區域,便於讀取上下方輸入與輸出。跨區狀態線請回到完整圖追蹤。來源:D2L〈Sequence-to-Sequence Learning for Machine Translation〉,Aston Zhang、Zachary C. Lipton、Mu Li、Alexander J. Smola,CC BY-SA 4.0。本文依固定版 SVG 轉成白底 PNG,手機版先列完整圖,再放大右側 Decoder 區域並保留完整 BOS/EOS 標籤;未改箭頭或 token。改作圖同採 CC BY-SA 4.0。

    <bos>、<eos> 和 <pad> 的作用也不同:<bos> 啟動目標生成,<eos> 表示目標序列結束,<pad> 是批次對齊時補出的空位。模型對來源端 PAD 的注意力遮罩,和計算目標端 loss 時忽略 PAD,是兩件分開處理的事。

    Attention 讓 Decoder 每一步讀取來源表示

    固定 state baseline 主要靠一個狀態把來源資訊帶給 Decoder。Attention 另外保留 Encoder 各來源位置的隱藏狀態,讓 Decoder 在每個輸出步驟,依當下的 query 對來源位置打分,再取加權和。這是在讀取已編碼的狀態;Encoder 不需要因每個目標詞而重跑一次。

    先看一般的 attention pooling 圖:query 和每個 key 進入打分函數,分數經 softmax 變成權重,再分別乘上對應 value,最後加總成 output。

    圖 5|完整圖保留五組 key/value 的打分、權重與加權聚合流程;這五列是同一次示意計算的五組 key/value,並非五個 attention head。來源:D2L〈Attention Scoring Functions〉,Aston Zhang、Zachary C. Lipton、Mu Li、Alexander J. Smola,CC BY-SA 4.0。本文依固定版 SVG 轉成白底 PNG,保留原線路與間距;改作圖同採 CC BY-SA 4.0。

    以目標輸出步驟 i、來源位置 j 為索引,令 h_j 是 Encoder 在來源位置 j 的狀態,s_(i−1) 是 Decoder 產生第 i 個目標前的狀態。Bahdanau 式 additive attention 可用下列形式描述打分:

    e_ij = vᵀ tanh(W_q s_(i−1) + W_k h_j)
    α_ij = softmax over valid source positions j of e_ij
    c_i  = Σ_j α_ij h_j
    

    e_ij 是第 i 個輸出步驟對來源位置 j 的分數,α_ij 是沿有效來源位置正規化後的權重,c_i 是本步的 context。這裡用帶權重矩陣的簡寫表示可學的 additive 打分;偏置等細節會依實作而異。每個目標步驟會有一組權重,因此 c_i 的內容可隨 query 改變;它的向量維度則維持固定。

    圖 6|完整原圖先顯示 Encoder、Attention、Aggregate 與 Decoder 的全部連線。手機細節圖聚焦 Attention 到 Decoder 的子區域;左側 Encoder 交接被裁出畫面,需依上方整圖追蹤,裁切沒有補畫連線。來源:D2L〈The Bahdanau Attention Mechanism〉,Aston Zhang、Zachary C. Lipton、Mu Li、Alexander J. Smola,CC BY-SA 4.0。本文依固定版 SVG 轉成白底 PNG;手機版先列完整圖,再放大右側 Attention/Decoder 區域。封面沿用這張完整原圖,等比縮放置於白底 1600×900 畫布,沒有裁圖或新增圖元。改作圖同採 CC BY-SA 4.0。

    在 D2L 這個 RNN attention 範例中,前一步 Decoder hidden state 作 query,Encoder 各步 hidden state 作 keys 和 values。取得的 context 會與當步目標詞的 embedding 一起送入 Decoder;Encoder 最後狀態另作 Decoder 初始化。這是帶 cross-attention 的 RNN encoder–decoder,並不是 Transformer self-attention。

    加權權重只表示這個計算把多少比重分配給每個 value。它不是翻譯正確率,也不等於已證明的因果理由;就算權重圖集中在某個來源詞,也不能只靠這件事證明模型因此做出正確決策。Attention 改變了可讀取來源表示的方式,不保證每個長距離關係都被學會。

    手算一個有 padding mask 的 query

    先用一維 query 和 keys 算分數,再以四列二維 values 看 context 如何形成。最後一列是 padding:它的 key 即使分數最高,也必須在 softmax 前遮掉。這是可逐項核算的手設例,不是從訓練資料學到的參數。

    位置KeyValue有效?
    11[1, 0]是
    20[0, 2]是
    31[2, 1]是
    42[100, 100]否,padding

    設定 query q=[1],用 scaled dot product q·k / √d_k。這裡 key depth d_k=1,所以除數 √1=1,原始分數依序為 [1, 0, 1, 2]。只對前三個有效位置做 softmax:

    α = softmax([1, 0, 1])
      = [e/(2e+1), 1/(2e+1), e/(2e+1)]
      ≈ [0.422319, 0.155362, 0.422319]
    

    padding 的權重是 0,context 為:

    c = 0.422319 × [1, 0]
      + 0.155362 × [0, 2]
      + 0.422319 × [2, 1]
      + 0 × [100, 100]
      ≈ [1.266956, 0.733044]
    

    這也說明 key 的維度與 value 的維度不需要相同:key 深度是 1,用來和 query 計分;value 深度是 2,用來生成二維 context。key 與 value 的位置數則要對齊,因為每個權重必須乘到相同位置的 value。

    實作 mask 時應遮掉無效位置的 score,再沿 key 位置做 softmax;只把 padding value 設為零,卻讓它留在 softmax 分母裡,仍會稀釋有效權重。這個小程式假設 caller 傳入有限浮點分數,以及整數型的有效前綴長度 1..K;它支援每個 batch 共用一個長度,或每個 query 指定一個長度。它會拒絕沒有任何有效 key 的 query。某些教材以有限的 −1e6 表示被遮位置,但若整列都被遮,有限分數會彼此相等,softmax 可能回傳均勻分布;全空 key 集合不能當作一個正常注意力分布。

    可重跑的機制原型

    以下完整程式用 CPU 與 float64 做手設 Q/K/V 計算,並和獨立 Python 純量計算核對。Teacher Forcing 與自回歸示範則使用人工固定 logits 查表;它沒有 encoder、學習參數、語料、訓練、target loss、BLEU 或翻譯品質評估。請將這些輸出視為機制驗算,不要解讀成學好的翻譯結果。

    """Deterministic CPU/float64 Seq2Seq attention mechanics; no model training."""
    from __future__ import annotations
    
    import json
    import math
    import platform
    
    import torch
    
    
    def masked_softmax(scores: torch.Tensor, valid_lens: torch.Tensor) -> torch.Tensor:
        """Softmax over valid keys; reject any query with no valid key.
    
        Args:
            scores: (batch, queries, keys) unnormalized attention scores.
            valid_lens: (batch,) or (batch, queries), number of valid leading keys.
        """
        if scores.ndim != 3:
            raise ValueError("scores must have shape (batch, queries, keys)")
        batch, queries, keys = scores.shape
        valid_lens = torch.as_tensor(valid_lens, dtype=torch.long, device=scores.device)
        if valid_lens.ndim == 1 and valid_lens.shape[0] == batch:
            flat_lens = valid_lens.repeat_interleave(queries)
        elif valid_lens.ndim == 2 and tuple(valid_lens.shape) == (batch, queries):
            flat_lens = valid_lens.reshape(-1)
        else:
            raise ValueError("valid_lens must have shape (batch,) or (batch, queries)")
        if torch.any(flat_lens < 1):
            raise ValueError("each query must have at least one valid key; empty key sets are rejected")
        if torch.any(flat_lens > keys):
            raise ValueError("valid key count cannot exceed the key dimension")
    
        positions = torch.arange(keys, dtype=torch.long, device=scores.device)
        keep = positions.unsqueeze(0) < flat_lens.unsqueeze(1)
        flat_scores = scores.reshape(batch * queries, keys)
        masked_scores = flat_scores.masked_fill(~keep, float("-inf"))
        return torch.softmax(masked_scores, dim=-1).reshape(batch, queries, keys)
    
    
    def rounded(values: torch.Tensor, digits: int = 12):
        if values.ndim == 0:
            return round(float(values.item()), digits)
        if values.ndim == 1:
            return [round(float(value), digits) for value in values.tolist()]
        return [rounded(row, digits) for row in values]
    
    
    def main() -> None:
        # One fixed query, three real source positions, and one padded position.
        # Key depth is one, so scaled dot product (divide by sqrt(d_k)) equals q @ k.T.
        queries = torch.tensor([[[1.0]]], dtype=torch.float64, device="cpu")
        keys = torch.tensor([[[1.0], [0.0], [1.0], [2.0]]], dtype=torch.float64, device="cpu")
        values = torch.tensor(
            [[[1.0, 0.0], [0.0, 2.0], [2.0, 1.0], [100.0, 100.0]]],
            dtype=torch.float64,
            device="cpu",
        )
        valid_lens = torch.tensor([3], dtype=torch.long, device="cpu")
        key_depth = queries.shape[-1]
        scores = torch.bmm(queries, keys.transpose(1, 2)) / math.sqrt(key_depth)
        weights = masked_softmax(scores, valid_lens)
        context = torch.bmm(weights, values)
    
        # Independent scalar calculation using the same fixed inputs.
        raw_scores = [1.0, 0.0, 1.0]
        exp_scores = [math.exp(score - max(raw_scores)) for score in raw_scores]
        py_weights = [value / sum(exp_scores) for value in exp_scores]
        py_context = [
            sum(py_weights[i] * values[0, i, dim].item() for i in range(3))
            for dim in range(values.shape[-1])
        ]
        assert max(abs(weights[0, 0, i].item() - py_weights[i]) for i in range(3)) < 1e-14
        assert weights[0, 0, 3].item() == 0.0
        assert abs(float(weights.sum().item()) - 1.0) < 1e-14
        assert max(abs(context[0, 0, i].item() - py_context[i]) for i in range(2)) < 1e-14
    
        try:
            masked_softmax(scores, torch.tensor([0], dtype=torch.long, device="cpu"))
            empty_key_result = "unexpectedly accepted"
            raise AssertionError("empty-key row must be rejected")
        except ValueError as exc:
            empty_key_result = str(exc)
    
        # Deliberate counterexample: finite -1e6 sentinels become equal logits
        # when every position is masked, so softmax returns a uniform distribution.
        # This is not a valid empty-key attention result and is not used above.
        all_masked_finite = torch.full((1, 1, 3), -1e6, dtype=torch.float64, device="cpu")
        finite_sentinel_bad_result = torch.softmax(all_masked_finite, dim=-1)
    
        # Fixed illustrative logits: not outputs of a trained model.
        vocab = ["ils", "nous", "regardent", "sommes", "<eos>"]
        toy_logits = {
            "<bos>": [0.2, 1.0, -1.0, -2.0, -3.0],
            "ils": [-2.0, -1.0, 1.2, 0.0, -3.0],
            "regardent": [-3.0, -3.0, -2.0, -2.0, 1.3],
            "nous": [-3.0, -2.0, -3.0, 1.1, -1.0],
            "sommes": [-3.0, -3.0, -3.0, -2.0, 1.2],
        }
        target = ["ils", "regardent", "<eos>"]
        teacher_inputs = ["<bos>"] + target[:-1]
        teacher_forced = []
        for step, (decoder_input, label) in enumerate(zip(teacher_inputs, target)):
            predicted = vocab[int(torch.argmax(torch.tensor(toy_logits[decoder_input], dtype=torch.float64)).item())]
            teacher_forced.append({
                "step": step,
                "decoder_input_from_gold_prefix": decoder_input,
                "target_label": label,
                "argmax_from_fixed_logits": predicted,
            })
    
        autoregressive = []
        decoder_input = "<bos>"
        stopped_on_eos = False
        max_steps = 6
        for step in range(max_steps):
            predicted = vocab[int(torch.argmax(torch.tensor(toy_logits[decoder_input], dtype=torch.float64)).item())]
            autoregressive.append({"step": step, "decoder_input_from_previous_prediction": decoder_input,
                                   "argmax_from_fixed_logits": predicted})
            if predicted == "<eos>":
                stopped_on_eos = True
                break
            decoder_input = predicted
        assert stopped_on_eos
    
        result = {
            "scope": "Hand-set one-query Q/K/V and fixed decoder-logit lookup only; no encoder, learned parameters, corpus, training, translation benchmark, or claim of trained output.",
            "environment": {"python": platform.python_version(), "torch": torch.__version__,
                            "device": str(queries.device), "dtype": str(queries.dtype)},
            "attention": {
                "shapes": {"queries": list(queries.shape), "keys": list(keys.shape),
                           "values": list(values.shape), "scores": list(scores.shape),
                           "weights": list(weights.shape), "context": list(context.shape)},
                "query": rounded(queries[0, 0]),
                "keys_including_pad": rounded(keys[0, :, 0]),
                "values_including_pad": rounded(values[0]),
                "valid_key_count": int(valid_lens[0].item()),
                "scores_scaled_dot_product": rounded(scores[0, 0]),
                "weights_after_valid_key_mask": rounded(weights[0, 0]),
                "weight_sum": round(float(weights.sum().item()), 12),
                "weighted_context": rounded(context[0, 0]),
                "pad_value_excluded": True,
                "independent_python_scalar_reference": {"valid_weights": [round(x, 12) for x in py_weights],
                                                         "context": [round(x, 12) for x in py_context],
                                                         "max_torch_difference_lt_1e-14": True},
            },
            "empty_key_boundary": {
                "valid_len_zero": "rejected",
                "error": empty_key_result,
                "finite_minus_1e6_all_masked_counterexample": rounded(finite_sentinel_bad_result[0, 0]),
                "counterexample_note": "D2L's finite sentinel makes every all-masked logit equal; softmax is uniform, not a valid empty-key attention distribution. This prototype explicitly rejects the row.",
            },
            "teacher_forcing_vs_autoregressive": {
                "gold_target_including_eos": target,
                "teacher_forcing_input_shift": teacher_inputs,
                "vocabulary_order": vocab,
                "fixed_logits_by_previous_token": toy_logits,
                "teacher_forced_steps": teacher_forced,
                "autoregressive_steps": autoregressive,
                "autoregressive_stopped_on_eos": stopped_on_eos,
                "max_steps_guard": max_steps,
                "note": "Logits are hand-written solely to demonstrate shifted gold-prefix inputs versus previous-prediction inputs. They are not from a trained Seq2Seq model. EOS ends this toy loop; PAD masking of target loss is outside this prototype.",
            },
            "checks": "PASS: scaled QK scores, mask excludes PAD key, valid weights sum to one, weighted context matches independent scalar calculation, empty valid-key row rejected, fixed-logit autoregressive loop terminates at EOS.",
        }
        print(json.dumps(result, ensure_ascii=False, indent=2, allow_nan=False))
    
    
    if __name__ == "__main__":
        main()
    

    執行環境為 Python 3.10.12、PyTorch 2.3.1+cu118,使用 CPU 和 torch.float64。命令為 python3 seq2seq_attention_mechanics.py;這個原型在上面限定的前提下,以獨立純量計算核對 softmax 權重與 context,也測試有效 key 數為 0 時會拒絕該輸入。它不是完整 Seq2Seq,也沒有學習或預測翻譯。

    原型中的手設 logits 刻意讓 Teacher Forcing 的第一步(餵 <bos>)以 nous 為最高分,但正確標籤是 ils;這不是訓練錯誤率,而是展示「餵 gold prefix」不等於「輸出就等於 gold」。自回歸則沿著模型自己前一步的輸出,走到 nous → sommes → <eos>。實際學習後的 logits 會由模型參數和輸入狀態決定;這份查表沒有那些條件。

    來源與實作邊界

    • 六張原圖取自固定版 D2L 英文教材,依該版書籍授權 CC BY-SA 4.0 提供;圖說各自標作者、章節來源、轉換/裁切與改作授權。可直接對照四個原始章節:Encoder–Decoder、Seq2Seq、Attention Scoring Functions、Bahdanau Attention。D2L 範例程式碼另有授權,不在本圖像素材範圍。
    • 原始方法可參閱 Bahdanau、Cho、Bengio 的 Neural Machine Translation by Jointly Learning to Align and Translate,以及 Sutskever、Vinyals、Le 的 Sequence to Sequence Learning with Neural Networks。D2L 教材範例和 Bahdanau 原論文不是逐項相同的網路:教材此處用單向 GRU encoder、以 Encoder 最後 state 初始化 Decoder,並用 additive attention;原論文方法的 encoder 是雙向,decoder 初始化與輸出層也有不同設計。本文不把教材圖說成原論文圖,也未重現論文翻譯實驗。
    • 本文的手算及程式走的是 scaled dot-product;例子 d_k=1,縮放因子剛好是 1。它和前述 Bahdanau additive score 公式是不同的 scoring function,不要將兩者混稱成同一段實作。
    • PyTorch 的 softmax 沿指定維度正規化,bmm 處理三維 batch matrix multiplication。程式只在有限浮點分數、shape 相符且每個 query 至少有一個有效 key 的條件下展示這個小例;沒有宣稱它是涵蓋所有 dtype、異常值、任意 sparse mask 或空 batch 的通用 attention API。
    • 對來源端 PAD 的 score mask 與目標端 PAD 的 loss ignore 是不同步驟。這份原型只實作前者;target loss mask 是教材和官方 API 的文件概念,本次沒有在原型中計算或測試。

    什麼時候這個拆解有用?

    如果你要檢查一份 Seq2Seq 程式,先對照三件事:Encoder 和 Decoder 之間傳遞什麼;訓練的 Decoder input 是否比 target labels 右移一格;生成時是否回饋自己的輸出。加入 Attention 後,再確認 query、keys、values 各自取自哪個狀態,以及 padding score 是否在 softmax 前被遮掉。

    這些檢查能說明資料怎麼流過模型、一次加權如何算出 context;它們不能單獨證明翻譯正確、權重就是因果解釋,也不能保證 Attention 一定勝過固定 state。真正的翻譯效果仍須在合適資料切分、明確評估規則與對照模型下測量。本文的手設例沒有做這些比較。

    留言0

    登入後就能留言。

    登入
    sunny 桑尼
    sunny 桑尼

    AI 資料科學 · 產品經理 · 蒙奇 D 桑尼

    專注於 AI 工具應用與資料科學教育,致力於讓每個人都能用 AI 提升工作效率。曾任職於科技業,現為全職教育創作者。

    讀更多文章 →想自己動手試?領免費 Starter Kit →