[stanford-cs336] Assignment-1 Chapter-3

Reference https://github.com/stanford cs336/assignment1 basics/tree/maingithub.com/stanford cs336/assignment1 basics/tree/main Setup 下载并安装环境 git clone https://github.com/stanford c

Reference

https://github.com/stanford-cs336/assignment1-basics/tree/maingithub.com/stanford-cs336/assignment1-basics/tree/main

Setup

下载并安装环境

git clone https://github.com/stanford-cs336/assignment1-basics.git
cd assignment1-basics
uv self update # optional
uv sync # 如果没安装uv,就去看我的uv文章
source .venv/bin/activate

先测试一下,应该是全部fail

uv run pytest

要求

因为一切都是手攒,所以

  • 不可以用:
  • torch.nn &
  • torch.nn.functional &
  • torch.optim 里面的组件
  • 可以用:
  • torch.nn.Parameter,
  • Container classes in torch.nn (e.g.,Module, ModuleList, Sequential)
  • torch.optim.Optimizer base class

所有的代码写到cs336_basics/* 下面,然后在adapters.py里调用自己的.py,需要通过所有的test。

最后的作业分为两部分,一个是code,一个是写的报告。

第三章 模型结构

3.4.1-2 Linear

要求

Xavier initialization

  • 用torch.nn.init.trunc_normal_去initialize weights
  • 用Module.load_state_dict来加载weights

答案

在cs336_basics里建一个layer.py

import torch
from torch import nn
from torch.nn import init
from einops import rearrange, einsum

class Linear(nn.Module):
    def __init__(self, in_features: int, out_features: int, device=None, dtype=None):
        super().__init__()
        self.in_features = in_features
        self.out_features = out_features
        factory_kwargs = {'device': device, 'dtype': dtype}

        # Weight shape is (out_features, in_features)
        self.weight = nn.Parameter(torch.empty((out_features,in_features),**factory_kwargs))
        # Initialize weights using truncated normal
        std = (2 / (in_features + out_features)) ** 0.5
        init.trunc_normal_(self.weight, mean=0.0, std=std, a=-3*std, b=3*std)

    def forward(self, x):
        return einsum(x, self.weight, "... d_in, d_out d_in -> ... d_out") # equivalent to x@self.weight.T

课程里建议用einsum来表示@ matrix multiplication,可以更清晰的看到每个维度是如何变换的。

之后在adaptor.py里

import, 这里用*,因为后面我们还会在layer.py里写别的。

from cs336_basics.layer import *

然后代码部分

def run_linear(
    d_in: int,
    d_out: int,
    weights: Float[Tensor, " d_out d_in"],
    in_features: Float[Tensor, " ... d_in"],
) -> Float[Tensor, " ... d_out"]:
    device, dtype = in_features.device, in_features.dtype
    model = Linear(d_in, d_out, device=device, dtype=dtype)
    model.load_state_dict({'weight': weights})
    return model(in_features)

测试

uv run pytest -k test_linear

通过且没有warning

Xavier initialization

作业里给的initalize的例子是Xavier initialization。X@W.T, X shape (d_in), W shape(d_out, d_in), sum axis=d_in,如果X和W都是standard normal distribution N(0,1), forward pass 中matrix multiply 会导致一个N(0,d_in)的matrix,Var是d_in, σ是d_in sqrt。如何设置W的distribution,可以让最后的Var是1呢?

添加图片注释,不超过 140 字(可选)

所以forward中,W的distribution设置成N(0,1/d_in),可以让输出的distribution为N(0,1)

反向也要考虑:

添加图片注释,不超过 140 字(可选)

所以Xavier 的normal initialization是N(mean=0, Var = 2/(d_in+d_out) )

关于normal distribution

N指normal distribution, 括弧里第一个位置是mean μ,第二个是variance 方差,标准差σ是方差的sqrt。

  • N(mean μ, variance Var)
  • Var = σ^2 ; σ = sqrt Var

Normal distribution里

  • 68% 的值在 ±1σ里
  • 95% 的值在 ±2σ里
  • 99.7% 的值在 ±3σ里

±3σ 涵盖了99.7%的值。Standard normal distribution是指mean 为0,Var为1的分布,也就是N(0,1)

Kaiming initialization

何凯明的initialize版本考虑到了ReLU的存在,ReLU会把一半的负值归零。

那么最终X@W.T的数值就是之前的一半,d_in/2 , 想要compensate,需要把W的Var设置成2/d_in即可。

下面是两种init的比较:

添加图片注释,不超过 140 字(可选)

jaxtyping的格式

from jaxtyping import Float, Int
Float[Tensor, "batch dim"]
Int[Tensor, "length"]
Float[npt.NDArray, "n_features"]

前面是一个数据type(Int或者Float)然后跟一个container type(比如Tensor,或者npt.NDArray),然后quotes""里面是dimension的定义。

einsum

rules:

  • ->,右侧出现的dim必须出现在左侧
  • ->, 左侧出现的dim,如果没出现在右侧,会被自动求和
  • -> , 如果右侧出现的dim只出现在左侧的某一个输入里,那么会把另一个输入自动broadcast。

注意torch.einsum和einops.einops 的格式有些不同,einops里的是把两个item放在前面,equation放后面。torch里是把equation放在前面。

作业pdf的einsum的例子是einops的einsum的。除了上面代码里写的matrix multiplication,还有一个很有意思的例子:

images = torch.randn(64, 128, 128, 3) # (batch, height, width, channel)
dim_by = torch.linspace(start=0.0, end=1.0, steps=10)

## in one go:
dimmed_images = einsum(
images, dim_by,
"batch height width channel, dim_value -> batch dim_value height width channel"
)

一行einsum的代码,就可以broadcast展开dim_by(10,) 和images来 match右边的shape

images展开为(batch, 1, height, width, channel)

dim_by展开为(1, dim_value, 1, 1, 1)

然后右边没有任何dim缺少,所以只是单纯的做element wise multiply,也就是逐元素相乘。

最后变成(batch, dim_value, height, width, channel)

einops官网教程

einops官网pytorch教程

einx

作业pdf还建议已经熟练掌握einsum的童鞋去熟悉einx,更进阶。

安装pip install einx。格式是equation放在前面,item放后面,可以加graph=True可以看到具体如何实现。

einx.dot教程

比如,einx.dot可以在一行中同时做rearrange+ einsum

# Simple grouped linear layer
x = np.ones((20, 16))
w = np.ones((8, 4))
print(einx.dot("b (g c1), c1 c2 -> b (g c2)", x, w, g=2, graph=True))
import numpy as np
def op0(i0, i1):
    x0 = np.reshape(i0, (20, 2, 8))
    x1 = np.einsum("abc,cd->abd", x0, i1)
    x2 = np.reshape(x1, (20, 8))
    return x2

作业pdf的例子:

要对images (batch, height, width, channel),做一个 形状为(height × width, height × width)的linear transformation。

channels_last = torch.randn(64, 32, 32, 3) # (batch, height, width, channel)
B = torch.randn(32*32, 32*32)

如果不用einx,一个策略就是用rearrange先把channels_last里的32和32 merge到一起,然后把32*32和最后一位的3互换位置,然后matrix multiply B (out, in) 【可以@B.T,或者einsum做】。之后再把后面两位的位置换回来,然后用view恢复到原来的形状。

如果用einx.dot,一行代码就可以搞定:

height = width = 32
channels_last_transformed = einx.dot(
"batch row_in col_in channel, (row_out col_out) (row_in col_in)"
"-> batch row_out col_out channel",
channels_last, B,
col_in=width, col_out=width
)

3.4.3 Embedding

要求

添加图片注释,不超过 140 字(可选)

答案

class Embedding(nn.Module):
    def __init__(self, 
                 num_embeddings, # vocabulary size
                 embedding_dim, # embedding dimension
                 device=None, dtype=None):
        super().__init__()
        self.num_embeddings = num_embeddings
        self.embedding_dim = embedding_dim
        factory_kwargs = {'device': device, 'dtype': dtype}

        # Weight shape is (num_embeddings, embedding_dim)
        self.weight = nn.Parameter(torch.empty((num_embeddings, embedding_dim), **factory_kwargs))

        # Initialize weights using truncated normal
        std = 1
        init.trunc_normal_(self.weight, mean=0.0, std=std, a=-3*std, b=3*std)

    def forward(self, token_ids: torch.Tensor) -> torch.Tensor:
        return self.weight[token_ids]  # Select rows corresponding to token_ids

在adapters.py里

def run_embedding(
    vocab_size: int,
    d_model: int,
    weights: Float[Tensor, " vocab_size d_model"],
    token_ids: Int[Tensor, " ..."],
) -> Float[Tensor, " ... d_model"]:
    device = token_ids.device
    model = Embedding(vocab_size, d_model, device=device) # if dtype is None, it will be float() in torch default
    model.load_state_dict({'weight': weights})
    return model(token_ids)

测试

uv run pytest -k test_embedding

PASS

3.5.1 RMSNorm

RMSNorm公式里面和gi的关系是* element wise multiplication, 不是@

要求

  • RMSNorm 里的weights (gi) 用1来初始化
  • eps设置成1e-5
  • 为了防止x square overflow,用下面这段代码:
in_dtype = x.dtype
x = x.to(torch.float32)
# Your code here performing RMSNorm
...
result = ...
# Return the result in the original dtype
return result.to(in_dtype)

答案

class RMSNorm(nn.Module):
    def __init__(self, d_model: int, eps: float = 1e-5, device=None, dtype=None):
        super().__init__()
        self.d_model = d_model
        self.eps = eps
        factory_kwargs = {'device': device, 'dtype': dtype}

        # Initialize weights to 1
        self.weight = nn.Parameter(torch.ones(d_model, **factory_kwargs))
    
    def forward(self, x: Tensor) -> Tensor:

        # Prevent overflow in mean/sqrt calculations
        in_dtype = x.dtype
        x = x.to(torch.float32)

        # Perform RMSNorm calculation 
        
        # official implementation:
        # rms = torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps) 
        # normalized_x = x * rms

        # our implementation:
        RMS = (x.pow(2).mean(dim=-1, keepdim=True)+ self.eps).sqrt() 
        normalized_x = x / RMS
        
        results = normalized_x * self.weight # W will automatically broadcast to ..., d_model

        # Return the result in the original dtype
        return results.to(in_dtype)

adapters.py里

def run_rmsnorm(
    d_model: int,
    eps: float,
    weights: Float[Tensor, " d_model"],
    in_features: Float[Tensor, " ... d_model"],
) -> Float[Tensor, " ... d_model"]:
    device, dtype = in_features.device, in_features.dtype
    model = RMSNorm(d_model, eps, device=device, dtype=dtype)
    model.load_state_dict({'weight': weights})
    return model(in_features)

测试

uv run pytest -k test_rmsnorm

PASSED

pytest里覆盖的情况并不是很广,如果不转换dtype或者一些细小的东西不加进来,也会显示pass,所以尽量贴近作业的要求。

关于keep_dim 和broadcast

pytorch里的keep_dim的默认是False,如果做什么sum或者mean在某个维度,会把那个dim自动削减掉。

在RMSNorm的代码中:

RMS = (x**2).mean(dim=-1, keepdim=True).clamp(min=self.eps).sqrt()

这里如果不加keepdim=True会报错

RMS = (x**2).mean(dim=-1)        # shape becomes [batch, seq_len]

x / RMS 的话,pytorch会试图broadcast[batch, seq_len] 到 [batch, seq_len, d_model]

但是broadcast只能应用于尾端对齐的时候,上面这个情况的尾端显然没有对齐,所以无法对齐

加keepdim的情况:

x.shape       = [batch, seq_len, d_model]      # e.g., [2, 4, 8]
RMS.shape     = [batch, seq_len, 1]            # broadcastable ✅

pytorch会把RMS最后那个最后那个维度复制,然后变成[batch, seq_len, d_model]

如果RMS一开始是[2, 4, 1],那么broadcast之后就会变成[2, 4, 8] 如果x最后一维是8的话。

RMS vs. L2

注意RMS和L2有点像,但不一样

RMS在根号里多了1/d

添加图片注释,不超过 140 字(可选)

L2:

添加图片注释,不超过 140 字(可选)

它们的关系:

添加图片注释,不超过 140 字(可选)

所以RMS 可以理解为是一种scaled version of L2 norm

L2

L2 用||x||2来表示,也可以理解为对自身的dot product的sqrt

添加图片注释,不超过 140 字(可选)

L2也代表着vector length, 也可以理解为距离原点的Euclidean distance 欧几里得距离。

normalize的时候,用自身除以||x||2即可

添加图片注释,不超过 140 字(可选)

L2的导

添加图片注释,不超过 140 字(可选)

根据

添加图片注释,不超过 140 字(可选)

求导过程:

添加图片注释,不超过 140 字(可选)

从上面不难发现,对向量 x 做 L2 normalize和对 L2求导的数学公式都一样,不过意义不同。前者用于标准化、余弦相似度等,后者用于反向传播中的梯度计算。

和L1不同的是,L2丝滑可导,所以deep learning里很多时候都用L2。

L2 square

MSE,会用squared L2,也就是没有根号的L2

||x||右边下面的2代表的是L2,上面的2代表square

MSE的公式:

添加图片注释,不超过 140 字(可选)

MSE的导非常光滑,就是2x

添加图片注释,不超过 140 字(可选)

代码例子

x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)
loss = torch.sum(x**2)  # equivalent to ||x||_2^2
loss.backward()
print(x.grad)  # tensor([2.0, 4.0, 6.0])

如果初始x是[1,2,3],那么L2 square之后,backward的导数就是在每个值上乘以2,变成了[2,4,6]

3.5.2 SwiGLU

SwiGLU公式

添加图片注释,不超过 140 字(可选)

  • d_model:transformer的dimension
  • d_ff:feed-forward network (FFN)的dimension
  • 通常d_ff是d_model的4倍或者2.6倍的 (第三课,超参)
  • 本质就是把x从d_model上放大到d_ff,然后再缩小回到d_model

其中GLU,是element-wise乘积

添加图片注释,不超过 140 字(可选)

其中SiLU

添加图片注释,不超过 140 字(可选)

要求

  • 写SwiGLU FFN的实现,包括SiLU func和GLU func
  • 确保dff/dmodel 大概是8/3, 同时是64的倍数; 然而作业里的dff是128然后dmodel是64,不是8/3倍数,是2倍

答案

def sigmoid(x: Tensor): return 1 / (1 + torch.exp(-x)) # sigmoid activation function

def silu(x: Tensor): return x * torch.sigmoid(x) # SiLU activation function

def glu(a:Tensor, b:Tensor): return a * b # element-wise multiplication

def swiglu_fn(a: Tensor, b: Tensor): return glu(silu(a), b) # SwiGLU activation function

class SwiGLU(nn.Module):
    def __init__(self, d_model: int, d_ff: int, device=None, dtype=None):
        super().__init__()
        factory_kwargs = {'device': device, 'dtype': dtype}
        self.linear1 = Linear(d_model, d_ff, **factory_kwargs)  # W1
        self.linear2 = Linear(d_ff, d_model, **factory_kwargs)  # W2
        self.linear3 = Linear(d_model, d_ff, **factory_kwargs)  # W3

    def forward(self, x: Float[Tensor, "... d_model"]) -> Float[Tensor, "... d_model"]:
        w1x = self.linear1(x)
        w3x = self.linear3(x)
        h = swiglu_fn(w1x, w3x)
        return self.linear2(h)

又写了一个根据算d_ff (8/3倍的d_model)的func:

def get_compatible_dff(d_model: int) -> int:
    """
    Returns the nearest multiple of 64 to 8/3 * d_model.
    """
    raw = (8 * d_model) / 3
    rounded = int((raw + 32) // 64) * 64  # round to nearest multiple of 64
    return rounded

adapters.py里

SiLU

def run_silu(in_features: Float[Tensor, " ..."]) -> Float[Tensor, " ..."]:
    return silu(in_features)

SwiGLU

def run_swiglu(
    d_model: int,
    d_ff: int,
    w1_weight: Float[Tensor, " d_ff d_model"],
    w2_weight: Float[Tensor, " d_model d_ff"],
    w3_weight: Float[Tensor, " d_ff d_model"],
    in_features: Float[Tensor, " ... d_model"],
) -> Float[Tensor, " ... d_model"]:

    # Optional: If you want to calculate d_ff from d_model based on 8/3 ratio
    d_ff_83 = get_compatible_dff(d_model)
    print(f"Expected d_ff to be {d_ff_83} based on d_model {d_model}; here we got d_ff to be {d_ff}.")

    device, dtype = in_features.device, in_features.dtype
    model = SwiGLU(d_model, d_ff, device=device, dtype=dtype)
    model.load_state_dict({
        "linear1.weight": w1_weight,
        "linear2.weight": w2_weight,
        "linear3.weight": w3_weight,
    })
    return model(in_features)

测试

uv run pytest -k test_silu_matches_pytorch # test silu
uv run pytest -k test_swiglu # test swiglu

PASS

element-wise multiply

⊙,⊗,* 都可以表示element-wise multiply,(其中,⊗也可以指outer product,容易混淆)

比如

import torch
a = torch.tensor([[1, 2], [3, 4]])
b = torch.tensor([[10, 20], [30, 40]])
print(a * b)
# Output: tensor([[10, 40],
#                 [90, 160]])

需要两个tensor的shape相同

dot product 点积

对于两个长度相同的一维vector,先element-wise multiply,再sum

a = torch.tensor([1, 2, 3])
b = torch.tensor([4, 5, 6])
print(torch.dot(a, b))  # Output: 1*4 + 2*5 + 3*6 = 32

如果是两个matrix AB,dot(A,B)相当于矩阵乘法。

matrix multiply

@是matrix multiply,或者dot product

@,matrix multiplication, 适用于二维的,是左边绿色的行,和右边绿色的列进行dot product,得到最右侧C1的指,C1的列数等于b的列数,C1的行数等于a的行数。

添加图片注释,不超过 140 字(可选)

添加图片注释,不超过 140 字(可选)

outer product

外积是将两个 1D 向量进行“所有元素之间的乘积”形成一个矩阵

添加图片注释,不超过 140 字(可选)

接下来的RoPE里的position ⊗ freq 相当于两个1D的vector进行外积

添加图片注释,不超过 140 字(可选)

outer product也相当于在a和b多加一个维度,然后矩阵相乘;

或者a@b.T (自动broadcast,然后矩阵相乘)

a[:, None] @ b[None, :]
a @ b.T 

3.5.3 Rotary Position Embeddings (RoPE)

要求

  • 对于sin和cos值的tensor,用self.register_buffer(persistent=False),而不要用nn.Parameter,因为它们是固定的值,设置persistent=False意味着不需要在保存模型的时候保存这些值在state_dict里,每次现算就可以,反正都是一样的。

答案

class RotaryPositionalEmbedding(nn.Module):
    """
    Rotary Position Embedding (RoPE) layer.

    Args
    ----
    theta : float
        Base used to generate inverse frequencies (e.g. 10_000).
    d_k : int
        Dimension of the key / query vectors (must be even).
    max_seq_len : int
        Maximum sequence length expected at inference / training time.
    device : torch.device | None
        Where to place the pre-computed sine / cosine tables.
    """
    def __init__(self,
                 theta: float,
                 d_k: int,
                 max_seq_len: int,
                 device=None):
        super().__init__()
        if d_k % 2 != 0:
            raise ValueError("d_k must be even for RoPE.")
        self.d_k = d_k
        # ---- pre-compute inverse frequencies ----
        # freq[k] = 1 / theta ** (2k / d_k)          (k = 0,1,…,d_k/2-1)
        freq = 1.0 / (theta ** (torch.arange(0,d_k,2, device=device).float() / d_k))

        # shape: (max_seq_len, d_k // 2)
        positions = torch.arange(max_seq_len, device=device).float()
        freqs = torch.outer(positions, freq)

        # cache cos/sin; no gradients needed → persistent=False
        self.register_buffer('cos_cached', torch.cos(freqs),persistent=False) # persistent=False does not save to state_dict
        self.register_buffer('sin_cached', torch.sin(freqs), persistent=False)
    
    def forward(
        self,
        x: Float[Tensor, "... seq_len d_k"],
        token_positions: Int[Tensor, "... seq_len"]
        ) -> Float[Tensor, "... seq_len d_k"]:
        """
        Apply RoPE to `x`.  Works with any batch shape prefix.
        """
        # Check if the last dimension matches d_k
        if x.size(-1) != self.d_k:
            raise ValueError(f"Last dim of x ({x.size(-1)}) ≠ d_k ({self.d_k}).")
        
        # Gather the cached tables for the required positions
        cos_pos = self.cos_cached[token_positions]
        sin_pos = self.sin_cached[token_positions]

        # Split even / odd channels
        x_even = x[..., ::2]
        x_odd = x[..., 1::2]

        # Apply the 2-D rotation to each pair
        out_even = x_even * cos_pos - x_odd * sin_pos
        out_odd = x_even * sin_pos + x_odd * cos_pos

        # Re-interleave
        out = torch.empty_like(x)
        out[..., ::2] = out_even
        out[..., 1::2] = out_odd
        return out

adapters.py里

def run_rope(
    d_k: int,
    theta: float,
    max_seq_len: int,
    in_query_or_key: Float[Tensor, " ... sequence_length d_k"],
    token_positions: Int[Tensor, " ... sequence_length"],
) -> Float[Tensor, " ... sequence_length d_k"]:

    # Create and initialize the RoPE module
    device = in_query_or_key.device
    rope = RotaryPositionalEmbedding(theta=theta, d_k=d_k, max_seq_len=max_seq_len, device=device)

    # Apply RoPE to the input tensor
    return rope(in_query_or_key, token_positions)

测试

uv run pytest -k test_rope

PASS

RoPE的具体讲解

3.5.4a Softmax

self-attention里面有一个很重要的函数就是softmax

添加图片注释,不超过 140 字(可选)

要求

  • 先写一个softmax函数
  • softmax里,要用到减去最大值的方法,来避免overflow

softmax

  • exponentiate 每个数
  • 然后normalize by sum of them

公式:

添加图片注释,不超过 140 字(可选)

然而现实中如果实现,会有overflow 和underflow的风险,比如e^8就会超过电脑能计算的digits,以至于overflow; e^过小就会导致等于0,也就是underflow。

所以一个普遍的方法就是让exp(xi)减去一个x vector的最大值,这样可以确保exponentiate的值不会太大

添加图片注释,不超过 140 字(可选)

为什么左右两边相等?因为如果c是max的话,e^-c 可以在分子和分母上下抵消:

添加图片注释,不超过 140 字(可选)

减去max,exp()里面的数永远小于等于0,那么exp后的数就在0到1之间,避免了overflow

代码

def softmax_stable(x: Tensor, dim: int = -1) -> Tensor:
    """Numerically stable softmax."""
    x_max = x.max(dim=dim, keepdim=True).values
    x_exp = torch.exp(x - x_max)
    return x_exp / x_exp.sum(dim=dim, keepdim=True)

adapters.py里

def run_softmax(in_features: Float[Tensor, " ..."], dim: int) -> Float[Tensor, " ..."]:
    return softmax_stable(in_features, dim=dim)

测试

uv run pytest -k test_softmax
uv run pytest -k test_softmax_matches_pytorch

PASS

3.5.4b Scaled Dot-Product Attention

添加图片注释,不超过 140 字(可选)

要求

  • Q, K, V的shape:

添加图片注释,不超过 140 字(可选)

  • 在attention matrix加一个True/False的mask,shape为 (n,m)
  • 需要把False的值转换成-inf

代码

class ScaledDotProductAttention(nn.Module):
    def __init__(self, d_k: int):
        super().__init__()
        self.scale = 1.0 / math.sqrt(d_k)

    def forward(
        self,
        query: Float[Tensor, "... seq_len_q d_k"],
        key: Float[Tensor, "... seq_len_k d_k"],
        value: Float[Tensor, "... seq_len_k d_v"],
        mask: Bool[Tensor, "seq_len_q seq_len_k"] = None
    ) -> Float[Tensor, "... seq_len_q d_v"]:
        # Compute scaled dot product attention scores using einsum
        attn_scores = einsum(query, key, "... q d, ... k d -> ... q k") * self.scale

        if mask is not None:
            attn_scores = attn_scores.masked_fill(~mask, float("-inf"))

        attn_probs = softmax_stable(attn_scores, dim=-1)

        # Compute attention output using einsum again
        output = einsum(attn_probs, value, "... q k, ... k d -> ... q d")

        return output

adapters.py里

def run_scaled_dot_product_attention(
    Q: Float[Tensor, " ... queries d_k"],
    K: Float[Tensor, " ... keys d_k"],
    V: Float[Tensor, " ... values d_v"],
    mask: Float[Tensor, " ... queries keys"] | None = None,
) -> Float[Tensor, " ... queries d_v"]:
    model = ScaledDotProductAttention(d_k=Q.size(-1))
    return model(Q, K, V, mask=mask)

测试

uv run pytest -k test_scaled_dot_product_attention
uv run pytest -k test_4d_scaled_dot_product_attention

Q@K.T 分母为什么放d_k sqrt?

这个和我们前面讲linear weights initialization很像。Q@K.T后,会出现mean为0,Var为d_k (σ为d_k sqrt)的distribution。为了compensate Var,让它回归到Var为1,我们需要让result值除以σ (参考Z score,(x-μ)/σ),就可以得到N(0,1)的值了。

等式右边a^2是d_k在外面,等式左边的a就是d_k sqrt;为了compensate,用1/d_k sqrt

关于为啥乘一个a时,方差变为原来的a^2倍:

添加图片注释,不超过 140 字(可选)

Q 和KV的seq_len不同

作业的Q的seq_len和K和V的不同:Q的seq_len是n, K和V是m

如果Q和KV的source不同,可能是cross-attention;如果Q和KV的source相同,就是targeted attention

添加图片注释,不超过 140 字(可选)

cross-attention

Q的source和KV是不一样的,很多时候它们的seq length不相同。

cross-attention里是encoder和decoder,适用于翻译的情景

比如从法语翻译成英文:

  • Input (source,encoder): "Je suis étudiant"
  • Output (target, decoder): "I am a student"

decoder Q如果已经生成I am, 那么n就是2

encoder K V是三个法语单词,那么m就是3

mask, convert 0 to -inf

为什么要用mask?

  • Padding masks: 不让模型pay attention to padded tokens
  • Causal masks: 在autoregressive 模型里, 把后面的信息罩住,保证模型只能看到前面的tokens。

为什么不用0而用-inf?

添加图片注释,不超过 140 字(可选)

exp(0)=1,模型还是会看它。

exp(-inf)=0,模型会ignore它。

为什么V可以有d_v,不同于Q,K的d_k?

Q和K.T因为要matrix multiplication,dimension一定是相同的。

但是对于Q来说,它的dimension不需要和它们相同。attention weights计算完成后,weighted sum over the values。

3.5.5 Causal Multi-Head Self-Attention

要求

  • 加入head, head dim从d_model提取出来,和batch融到一起
  • d_k和d_v =d_model/h
  • RoPE加入到QK里,但不要加到V里;make RoPE optional in class
  • 加入causal mask,把右上三角盖住

代码

class CausalMultiHeadSelfAttention(nn.Module):
    def __init__(
        self,
        d_model: int,
        num_heads: int,
        max_seq_len: int,
        rope_theta: float = 10000.0,
        use_rope: bool = True,
        device=None,
        dtype=None
    ):
        super().__init__()
        assert d_model % num_heads == 0, "d_model must be divisible by num_heads"
        self.d_model = d_model
        self.num_heads = num_heads
        self.d_k = d_model // num_heads
        self.d_v = self.d_k  # match d_k for simplicity
        self.use_rope = use_rope

        factory_kwargs = {'device': device, 'dtype': dtype}
        self.q_proj, self.k_proj, self.v_proj, self.o_proj = [Linear(d_model, d_model, **factory_kwargs)
                                                              for _ in range(4)]
        self.attn = ScaledDotProductAttention(self.d_k)

        # Create a causal mask for the attention mechanism
        # Shape: (1, 1, max_seq_len, max_seq_len)
        mask = torch.tril(torch.ones(max_seq_len, max_seq_len, dtype=torch.bool, device=device))
        self.register_buffer("causal_mask", mask.unsqueeze(0).unsqueeze(0), persistent=False)

        if use_rope:
            self.rope = RotaryPositionalEmbedding(
                theta=rope_theta, d_k=self.d_k, max_seq_len=max_seq_len, device=device)

    def forward(
        self,
        x: Float[Tensor, "batch seq_len d_model"],
        token_positions: Int[Tensor, "batch seq_len"]| None = None,
    ) -> Float[Tensor, "batch seq_len d_model"]:
        B, S, _ = x.shape

        # Project to multi-head Q, K, V
        q,k,v = [rearrange(proj(x), "b s (h d) -> b h s d", h=self.num_heads) 
                 for proj in [self.q_proj, self.k_proj, self.v_proj]]

        # Apply RoPE to Q and K if enabled
        if self.use_rope: q,k = self.rope(q, token_positions),self.rope(k, token_positions)

        # Compute attention
        out = self.attn(q, k, v, mask=self.causal_mask[..., :S, :S])

        # Merge heads and project
        out = rearrange(out, "b h s d -> b s (h d)")
        return self.o_proj(out)

adapters.py里

def run_multihead_self_attention(
    d_model: int,
    num_heads: int,
    q_proj_weight: Float[Tensor, " d_k d_in"],
    k_proj_weight: Float[Tensor, " d_k d_in"],
    v_proj_weight: Float[Tensor, " d_v d_in"],
    o_proj_weight: Float[Tensor, " d_model d_v"],
    in_features: Float[Tensor, " ... sequence_length d_in"],
) -> Float[Tensor, " ... sequence_length d_out"]:
    device, dtype = in_features.device, in_features.dtype
    max_seq_len = in_features.shape[-2]  # Assuming in_features is of shape (..., sequence_length, d_in)
    model = CausalMultiHeadSelfAttention(d_model=d_model,
                                          num_heads=num_heads, 
                                          use_rope=False,
                                          max_seq_len=max_seq_len,
                                          device=device,
                                          dtype=dtype)
    model.load_state_dict({
        "q_proj.weight": q_proj_weight,
        "k_proj.weight": k_proj_weight,
        "v_proj.weight": v_proj_weight,
        "o_proj.weight": o_proj_weight,
    })
    return model(in_features)

然后还有下一个

def run_multihead_self_attention_with_rope(
    d_model: int,
    num_heads: int,
    max_seq_len: int,
    theta: float,
    q_proj_weight: Float[Tensor, " d_k d_in"],
    k_proj_weight: Float[Tensor, " d_k d_in"],
    v_proj_weight: Float[Tensor, " d_v d_in"],
    o_proj_weight: Float[Tensor, " d_model d_v"],
    in_features: Float[Tensor, " ... sequence_length d_in"],
    token_positions: Int[Tensor, " ... sequence_length"] | None = None,
) -> Float[Tensor, " ... sequence_length d_out"]:
    device, dtype = in_features.device, in_features.dtype
    model = CausalMultiHeadSelfAttention(
        d_model=d_model, 
        num_heads=num_heads, 
        rope_theta=theta,
        use_rope=(token_positions is not None),
        max_seq_len=max_seq_len,
        device=device,
        dtype=dtype
    )
    model.load_state_dict({
        "q_proj.weight": q_proj_weight,
        "k_proj.weight": k_proj_weight,
        "v_proj.weight": v_proj_weight,
        "o_proj.weight": o_proj_weight,
    })

    return model(in_features,token_positions)

测试

uv run pytest -k test_multihead_self_attention

PASS two

tril vs. triu

torch.tril(torch.ones(4, 4)) , l是lower,keep lower, causal mask

tensor([[1, 0, 0, 0],
        [1, 1, 0, 0],
        [1, 1, 1, 0],
        [1, 1, 1, 1]])

torch.triu(input), u是upper,keep upper, future mask

tensor([[1, 1, 1, 1],
        [0, 1, 1, 1],
        [0, 0, 1, 1],
        [0, 0, 0, 1]])

3.6a Transformer Block

要求

  • 有2个sublayers
  • 每层,都有RMSNorm,MHA/FF,还有residual connection
  • layer1: y= x + Causal Multi-Head Self-Attention(RMSNorm(x))
  • layer2: z = y+SwiGLU(RMSNorm(y))

添加图片注释,不超过 140 字(可选)

代码

class TransformerBlock(nn.Module):
    """
    Pre-norm Transformer block with two sub-layers:

       x ──► RMSNorm ──► MHA ──► + ──►
         │                     ▲
         └─────────────────────┘     (sublayer-1)

       y ──► RMSNorm ──► FF  ──► + ──► out
         │                     ▲
         └─────────────────────┘     (sublayer-2)
    """
    def __init__(
        self,
        d_model: int,
        num_heads: int,
        d_ff: int,
        max_seq_len: int,
        rope_theta: float = 10_000.0,
        use_rope: bool = True,
        device=None,
        dtype=None,
    ) -> None:
        super().__init__()
        kwargs = {"device": device, "dtype": dtype}

        # ── sub-layer 1: (RMSNorm → causal MHA) ──────────────────────────────
        self.norm1 = RMSNorm(d_model, **kwargs)
        self.attn  = CausalMultiHeadSelfAttention(
            d_model=d_model,
            num_heads=num_heads,
            max_seq_len=max_seq_len,
            rope_theta=rope_theta,
            use_rope=use_rope,
            **kwargs,
        )

        # ── sub-layer 2: (RMSNorm → feed-forward) ────────────────────────────
        self.norm2 = RMSNorm(d_model, **kwargs)
        self.ff    = SwiGLU(d_model=d_model, d_ff=d_ff, **kwargs)

    # -----------------------------------------------------------------------
    def forward(
        self,
        x: torch.Tensor,               # (batch, seq_len, d_model)
        token_positions: torch.Tensor | None = None,  # (batch, seq_len)
    ) -> torch.Tensor:
        b, s, _ = x.shape

        # ---- sub-layer-1: RMSNorm → MHA → residual -------------------------
        attn_out = self.attn(self.norm1(x), token_positions=token_positions)
        x = x + attn_out                       # residual connection

        # ---- sub-layer-2: RMSNorm → FF → residual --------------------------
        ff_out   = self.ff(self.norm2(x))
        x        = x + ff_out                  # residual connection
        return x

adapters.py里

def run_transformer_block(
    d_model: int,
    num_heads: int,
    d_ff: int,
    max_seq_len: int,
    theta: float,
    weights: dict[str, Tensor],
    in_features: Float[Tensor, " batch sequence_length d_model"],
) -> Float[Tensor, " batch sequence_length d_model"]:
    device, dtype = in_features.device, in_features.dtype
    model = TransformerBlock(
        d_model=d_model,
        num_heads=num_heads,
        d_ff=d_ff,
        use_rope=True,
        max_seq_len=max_seq_len,
        rope_theta=theta,
        device=device,
        dtype=dtype
    )

    # 2. Load the reference weights -----------------------------------------
    # Load attention projection weights
    model.attn.q_proj.weight.data.copy_(weights["attn.q_proj.weight"])
    model.attn.k_proj.weight.data.copy_(weights["attn.k_proj.weight"])
    model.attn.v_proj.weight.data.copy_(weights["attn.v_proj.weight"])
    model.attn.o_proj.weight.data.copy_(weights["attn.output_proj.weight"])

    # Load RMSNorm weights
    model.norm1.weight.data.copy_(weights["ln1.weight"])
    model.norm2.weight.data.copy_(weights["ln2.weight"])

    # Load FFN weights (transpose needed for 1 and 3)
    model.ff.linear1.weight.data.copy_(weights["ffn.w1.weight"])
    model.ff.linear2.weight.data.copy_(weights["ffn.w2.weight"]) 
    model.ff.linear3.weight.data.copy_(weights["ffn.w3.weight"])

    # _load_reference_weights(model.state_dict(), weights)

    # 3. Form token-position indices for RoPE -------------------------------
    B, S, _ = in_features.shape
    positions = torch.arange(S, device=device).expand(B, S)  # (B, S)

    # 4. Run the forward pass ----------------------------------------------
    out = model(in_features, token_positions=positions)      # (B, S, d_model)
    return out

测试

uv run pytest -k test_transformer_block

3.6b Transformer Language Model (LM)

要求

  • args: vocab_size, context_length, num_layers
  • follow graph below

添加图片注释,不超过 140 字(可选)

代码

def _copy_param(target: torch.Tensor, source: torch.Tensor) -> None:
    """
    Copy `source` into `target` in-place, transposing `source` if that
    is what makes the shapes line up.
    """
    if source.shape == target.shape:
        target.data.copy_(source)
    elif source.T.shape == target.shape:
        target.data.copy_(source.T)
    else:
        raise ValueError(f"Shape mismatch: cannot load parameter of shape {source.shape} "
                         f"into tensor of shape {target.shape}")
    

class TransformerLM(nn.Module):
    def __init__(self,
                 vocab_size: int,
                 context_length: int,
                 num_layers: int,
                 d_model: int,
                 num_heads: int,
                 d_ff: int,
                 rope_theta: float,
                 device=None,
                 dtype=None):
        super().__init__()
        kw = dict(device=device, dtype=dtype)

        # token embedding  (no separate pos-emb: RoPE lives inside blocks)
        self.tok_emb = Embedding(vocab_size, d_model, **kw)

        # L Transformer blocks
        self.blocks = nn.ModuleList([
            TransformerBlock(
                d_model=d_model,
                num_heads=num_heads,
                d_ff=d_ff,
                max_seq_len=context_length,
                rope_theta=rope_theta,
                use_rope=True,
                **kw,
            )
            for _ in range(num_layers)
        ])

        # final norm
        self.ln_final = RMSNorm(d_model, **kw)
        self.lm_head = Linear(d_model, vocab_size, **kw)

        self.context_length = context_length

    def forward(self, token_ids: torch.Tensor) -> torch.Tensor:
        b, s = token_ids.shape
        if s > self.context_length:
            raise ValueError(f"seq_len {s} exceeds context_length {self.context_length}")

        # token embeddings
        x = self.tok_emb(token_ids)                         # (b, s, d)

        # token positions for RoPE
        pos = torch.arange(s, device=token_ids.device).unsqueeze(0).expand(b, s)

        # transformer stack
        for blk in self.blocks:
            x = blk(x, token_positions=pos)                # (b, s, d)

        # final norm → tied linear projection (logits)
        x = self.ln_final(x)                                 # (b, s, d)

        logits = self.lm_head(x)  # (b, s, vocab_size)
        return logits

adapters.py

from cs336_basics.layer import _copy_param

def run_transformer_lm(
    vocab_size: int,
    context_length: int,
    d_model: int,
    num_layers: int,
    num_heads: int,
    d_ff: int,
    rope_theta: float,
    weights: dict[str, Tensor],
    in_indices: Int[Tensor, " batch_size sequence_length"],
) -> Float[Tensor, " batch_size sequence_length vocab_size"]:

    device = in_indices.device
    dtype  = next(iter(weights.values())).dtype             # assume all same dtype

    # 1) construct the model skeleton
    model = TransformerLM(
        vocab_size     = vocab_size,
        context_length = context_length,
        num_layers     = num_layers,
        d_model        = d_model,
        num_heads      = num_heads,
        d_ff           = d_ff,
        rope_theta     = rope_theta,
        device         = device,
        dtype          = dtype,
    ).eval()                                                 # inference mode

    # 2) load the weights ----------------------------------------------------
    with torch.no_grad():
        # (a) token embedding  (also implicitly ties lm_head)
        _copy_param(model.tok_emb.weight,
                    weights["token_embeddings.weight"])

        # (b) per-layer parameters
        for layer_idx in range(num_layers):
            pfx   = f"layers.{layer_idx}."
            block = model.blocks[layer_idx]

            # ── attention projections
            _copy_param(block.attn.q_proj.weight,  weights[pfx + "attn.q_proj.weight"])
            _copy_param(block.attn.k_proj.weight,  weights[pfx + "attn.k_proj.weight"])
            _copy_param(block.attn.v_proj.weight,  weights[pfx + "attn.v_proj.weight"])
            _copy_param(block.attn.o_proj.weight,  weights[pfx + "attn.output_proj.weight"])

            # ── RMSNorm weights
            _copy_param(block.norm1.weight, weights[pfx + "ln1.weight"])
            _copy_param(block.norm2.weight, weights[pfx + "ln2.weight"])

            # ── feed-forward (SwiGLU) weights
            _copy_param(block.ff.linear1.weight, weights[pfx + "ffn.w1.weight"])
            _copy_param(block.ff.linear2.weight, weights[pfx + "ffn.w2.weight"])
            _copy_param(block.ff.linear3.weight, weights[pfx + "ffn.w3.weight"])

        # (c) final layer-norm
        _copy_param(model.ln_final.weight, weights["ln_final.weight"])

        # (d) (optional) make sure tied output embedding matches lm_head if provided
        _copy_param(model.lm_head.weight, weights["lm_head.weight"])

    # 3) run the forward pass and return logits
    with torch.no_grad():
        return model(in_indices)            # (batch, seq_len, vocab_size)

测试

uv run pytest -k test_transformer_lm

PASS

添加图片注释,不超过 140 字(可选)

To be continue..