Ziyu Li's Homepage

Back

CS189 Assignment 4#

项目描述#

从这个项目起基本上告别小打小闹了, 我们要用PyTorch来搭建一些较为复杂的模型, 比如说CNN, Transformer, Bert. 关于PyTorch的API用法我不会做太多解释, 和CS336笔记中一样, 我会着重说明这些线性变换的尺寸和方式, 我觉得这才是真正理解模型的重要点

学习目标如下:

— 用PyTorch搭建自己的神经网络 — 理解并实现自定义的Datasets, DataLoaders和训练循环 — 学习如何复现论文中的模型架构 — 理解ResNet架构 — 用PyTorch实现Transformer — 理解Transformer的原理

Problem 1a#

用PyTorch写一个CNN类, 非常简单, 只要照着他给出的卷积核的尺寸往里填就行了

假设我们传入的尺寸为(1, 224 ,224 ,3), 具体含义是一个batch有一张图片, 一个图片有三个图层(channel), 图片的长和宽都是224, 不过我觉得没有必要对高维张量去进行具体的想象什么维度代表什么, 因为超过三维的东西就很难想得明白, 只要记住这个二维卷积核肯定是对倒数第一和第二的维度做变换就行了

conv1上的尺寸变换#

conv1的尺寸为:

self.conv1 = nn.Conv2d(in_channels=3, out_channels=16, kernel_size=3, stride=2, padding=1)
python

先考虑对最后两维的变化, 卷积核的尺寸为3, 步长为2, 填充为1, 所以输出尺寸为:

Gauss((224 + 1 * 2 - 3) / 2 + 1) = 112
plaintext

其中Gauss()是向下取整函数, 又因为卷积核指定了out_channels = 16, 所以输出尺寸为(1, 16, 112, 112)

再解释一下这个输出通道的变化, 如图所示:

输入图片(3个图层)           每个输出通道的卷积核
┌─────────────┐
│   224 * 224   │ ──▶ 3 * 3 矩阵(处理R层)──┐
│   (R通道)   │                         │
├─────────────┤                         │
│   224 * 224   │ ──▶ 3 * 3 矩阵(处理G))──┼──▶ 求和 ──▶ 输出1个通道
│   (G通道)   │                         │
├─────────────┤                         │
│   224 * 224   │ ──▶ 3 * 3 矩阵(处理B层)──┘
│   (B通道)   │
└─────────────┘
plaintext

对于每个输出通道(1/16), 都有3个3*3的卷积核, 每个卷积核去处理输入当中的一个通道, 所以总共有16 * 3个卷积核, 每个的尺寸是3 * 3

也就是说, 输出通道变多的意思是我们有很多的卷积核分别在原图的不同层次上学习特征, 并且每次只输出一个通道

conv2上的尺寸变换#

conv2的尺寸为:

self.conv2 = nn.Conv2d(in_channels=16, out_channels=16, kernel_size=7, stride=2, padding=1)
python

先考虑对最后两维的变化, 卷积核的尺寸为7, 步长为2, 填充为1, 所以输出尺寸为:

Gauss((112 + 1 * 2 - 7) / 2 + 1) = 54
plaintext

所以输出尺寸为(1, 16, 54, 54)

Linear上的尺寸变换#

linear的尺寸为:

self.linear = nn.Linear(in_features=16*54*54, out_features=num_classes)
python

输入尺寸为(1, 16, 54, 54), 所以输出尺寸为(1, num_classes)

这里其实有两个步骤, 首先去做一个flatten操作, 把(1, 16, 54, 54)变成(1, 16 * 54 * 54), 然后再去做一个线性变换, 把(1, 16 * 54 * 54)变成(1, num_classes), 注意这一步总是可行的, 因为一个高维的东西总是可以把他的每个元素拿出来然后flatten到一维

检查输出尺寸#

运行他的检查代码, 确保我们的实现是正确的

x = torch.rand((1, 3, 224, 224))  # 1 image, 3 RGB channels, 224 x 224 pixels
print(f"Shape of input: {x.shape}")

res = CNN(num_classes=10)
out = res(x)
print(f"Shape of output: {out.shape}")
assert out.shape == torch.Size([1, 10]), f"Expected output shape (1, 10), but got {out.shape}"
python
Shape of input: torch.Size([1, 3, 224, 224])
Shape of output: torch.Size([1, 10])
plaintext

理解HF Dataset对象#

很多时候要用HuggingFace的load_dataset来加载数据集, 所以有必要看一下加载的数据集对象的数据结构, 运行示例代码即可

All classes in the ImageNet dataset: {0: 'house_finch', 1: 'robin', 2: 'triceratops', 3: 'green_mamba', 4: 'harvestman', 5: 'toucan', 6: 'goose', 7: 'jellyfish', 8: 'nematode', 9: 'king_crab', 10: 'dugong', 11: 'Walker_hound', 12: 'Ibizan_hound', 13: 'Saluki', 14: 'golden_retriever', 15: 'Gordon_setter', 16: 'komondor', 17: 'boxer', 18: 'Tibetan_mastiff', 19: 'French_bulldog', 20: 'malamute', 21: 'dalmatian', 22: 'Newfoundland', 23: 'miniature_poodle', 24: 'white_wolf', 25: 'African_hunting_dog', 26: 'Arctic_fox', 27: 'lion', 28: 'meerkat', 29: 'ladybug', 30: 'rhinoceros_beetle', 31: 'ant', 32: 'black-footed_ferret', 33: 'three-toed_sloth', 34: 'rock_beauty', 35: 'aircraft_carrier', 36: 'ashcan', 37: 'barrel', 38: 'beer_bottle', 39: 'bookshop', 40: 'cannon', 41: 'carousel', 42: 'carton', 43: 'catamaran', 44: 'chime', 45: 'clog', 46: 'cocktail_shaker', 47: 'combination_lock', 48: 'crate', 49: 'cuirass', 50: 'dishrag', 51: 'dome', 52: 'electric_guitar', 53: 'file', 54: 'fire_screen', 55: 'frying_pan', 56: 'garbage_truck', 57: 'hair_slide', 58: 'holster', 59: 'horizontal_bar', 60: 'hourglass', 61: 'iPod', 62: 'lipstick', 63: 'miniskirt', 64: 'missile', 65: 'mixing_bowl', 66: 'oboe', 67: 'organ', 68: 'parallel_bars', 69: 'pencil_box', 70: 'photocopier', 71: 'poncho', 72: 'prayer_rug', 73: 'reel', 74: 'school_bus', 75: 'scoreboard', 76: 'slot', 77: 'snorkel', 78: 'solar_dish', 79: 'spider_web', 80: 'stage', 81: 'tank', 82: 'theater_curtain', 83: 'tile_roof', 84: 'tobacco_shop', 85: 'unicycle', 86: 'upright', 87: 'vase', 88: 'wok', 89: 'worm_fence', 90: 'yawl', 91: 'street_sign', 92: 'consomme', 93: 'trifle', 94: 'hotdog', 95: 'orange', 96: 'cliff', 97: 'coral_reef', 98: 'bolete', 99: 'ear'}
Dataset({
    features: ['image', 'label'],
    num_rows: 1000
})
Dataset({
    features: ['image', 'label'],
    num_rows: 200
})
plaintext

注意到他是个嵌套的数据结构

Problem 1b#

对于得到的数据集, 我们总是希望把它写成一个PyTorch的Dataset类的子类, 好处在于可以传递给DataLoader类, 各种操作都很方便, 而且:

— 可以做批次, 打乱顺序以及并行加载 — 将数据的预处理写成标准代码, 可读性好

总之就是比自己写一个类好得多

要完成这一点我们至少要继承torch.utils.data.Dataset类并且实现以下三个方法:

__init__和__len__比较容易实现, 讲一下__getitem__, 先分析一下数据结构和他的Hints:

之前拿到的Dataset嵌套结构是:

Dataset({
    features: ['image', 'label'],
    num_rows: 1000
})
Dataset({
    features: ['image', 'label'],
    num_rows: 200
})
plaintext

如果想获得一个image和label, 应该要:

# 在__getitem__里面
# 假设self.dataset已经初始化完毕
sample = self.dataset[idx]
img = sample['image']
label = sample['label']
python

接下来要分出RGB channel, 按照Hint里面要对image用convert方法

img = img.convert("RGB")
python

查了一下API, 这个image是PIL Image对象, 有一个convert方法转化出指定的颜色模式

接下来还需要实现一个show_images方法来画图, 这个就没什么多说的, 也就是通过类别找到图像, 切割出一些index去plot

完整代码如下:

Problem 1c#

先来看看如何构造数据的变换, 这里利用的是:

import torchvision.transforms as transforms
python

从这里(https://docs.pytorch.org/vision/0.8/transforms.html ↗) 可以查到各种变换的用法, 题目里直接用他给的就行了, 为了把好几个变换做成变换序列, 需要用transforms.Compose, 或者(按文档中的说法)用torch.nn.Sequential也可以

从最后两行可以看出, 用这种继承Dataset的方式构造数据集很方便, 构造完毕后写成一个DataLoader也仅需一行代码

train_dataloader = DataLoader(train_dataset,batch_size=32,shuffle=True)
val_dataloader = DataLoader(val_dataset,batch_size=32,shuffle=True)


# Print the first batch of data and labels
for batch in train_dataloader: # each batch is a tuple of (data, labels)
    data = batch[0] # data is a tensor of shape (B, 3, 224, 224)
    labels = batch[1] # labels is a tensor of shape (B,)
    print(f"Shape of data: {data.shape}")
    print(f"Shape of labels: {labels.shape}")
    break
python

现在我们就可以通过循环去访问train_dataloader里面的batch, 再通过下标去访问数据和标签了

Problem 1d#

实现CNN的训练

训练循环的抽象#

理论上所有训练循环的抽象代码都差不多, 示例如下:

Step1: 把模型转移到device上(cpu/gpu)

model.to(device)
python

Step2: 决定num_epochs并启动外层循环, 决定要遍历数据集多少次

for epoch in range(num_epochs):
    # training logic
python

Step3: 外层循环内每次都要把model设置为train模式

model.train()
python

Step4: 启动内层循环, 遍历数据集的x和y, 这个内层循环一次处理一个batch

for x, y in train_dataloader:
    x = x.to(device, dtype = torch.float)
    y = y.to(device, dtype = torch.long)
python

Step5: 清零梯度

optimizer.zero_grad()
python

Step6: 前向传播

y_hat = model(x)
python

Step7: 计算损失

loss = criterion(y_hat, y)
python

这里的criterion是预设的损失函数, 比如说nn.CrossEntropyLoss

Step8: 反向传播

loss.backward()
python

Step9: 更新参数

optimizer.step()
python

Step10: 记录损失

完整的抽象代码如下:

在一个epoch内, 可能会有很多batch, 每个batch我们会计算一次损失并且加入到train_loss里面, 直到这个epoch结束, 用train_loss/len(train_dataloader)就是平均损失, val_loss只在一个epoch结束之后算一次, 举例如下:

假设训练集有 1000 个样本, batch_size = 100, 那么每个 epoch 有 10 个 batch。在训练过程中, 每处理一个 batch, 我们计算一次该 batch 的 loss(比如分别是 0.85, 0.72, 0.68, …, 0.55), 并累加到 train_loss 中。Epoch 结束后,用 train_loss / len(train_dataloader) 得到平均训练损失(比如 6.5 / 10 = 0.65). 而 val_loss 只有在整个 epoch 的训练结束后才会计算, 它反映模型在验证集上的表现。

完整代码如下, 依葫芦画瓢即可:

调用自定义的训练循环#

现在需要实现train函数里面的参数

实现模型实例:

cnn = CNN(num_classes=10)
python

定义循环轮次:

num_epochs = 20
python

定义优化器:

optimizer = AdamW(cnn.parameters(),lr=0.0005)
python

定义损失函数:

criterion = torch.nn.CrossEntropyLoss()
python

开始训练

cnn_train_accuracies, cnn_train_losses, cnn_val_accuracies, cnn_val_losses = train(
    model=cnn,
    optimizer=optimizer,
    criterion=criterion,
    num_epochs=num_epochs,
    train_dataloader=train_dataloader,
    val_dataloader=val_dataloader
)
python
Initial validation loss: 16.1283
Initial validation accuracy: 0.1000
Training Progress:   0%|          | 0/20 [00:00<?, ?it/s]
Epoch 1 - Training accuracy: 0.238
Epoch 1 - Training loss: 68.580
Training Progress:   5%|▌         | 1/20 [00:09<03:01,  9.53s/it, train_loss=68.580, train_acc=0.238, val_loss=14.099, val_acc=0.295]Epoch 1 - Validation accuracy: 0.295
Epoch 1 - Validation loss: 14.099

Epoch 20 - Training accuracy: 0.599
Epoch 20 - Training loss: 36.430
Training Progress: 100%|██████████| 20/20 [03:13<00:00,  9.67s/it, train_loss=36.430, train_acc=0.599, val_loss=11.613, val_acc=0.525]Epoch 20 - Validation accuracy: 0.525
Epoch 20 - Validation loss: 11.613
plaintext

Problem 1e#

写一个画图函数来画损失曲线和准确率曲线

label_plot

残差块#

如果我们的某一层神经网络需要学习的函数是f(x), 我们可以让他学习g(x) = f(x) - x, 然后在输出端再加上x即可(即输出f(x) = g(x) + x)

这有个显然的好处, 对于第一层的梯度:

∂L∂x1=∂L∂y⋅∂y∂x1=∂L∂y⋅(∂Fn∂x1+1)=∂L∂y⋅∂Fn∂xn⋅∂Fn−1∂xn−1⋅⋯⋅∂F2∂x2⋅(∂F1∂x1+1)\frac{\partial L}{\partial x_1} = \frac{\partial L}{\partial y} \cdot \frac{\partial y}{\partial x_1} = \frac{\partial L}{\partial y} \cdot \left( \frac{\partial F_n}{\partial x_1} + 1 \right) = \frac{\partial L}{\partial y} \cdot \frac{\partial F_n}{\partial x_n} \cdot \frac{\partial F_{n-1}}{\partial x_{n-1}} \cdot \cdots \cdot \frac{\partial F_2}{\partial x_2} \cdot \left( \frac{\partial F_1}{\partial x_1} + 1 \right)

最后一项的+1有效防止了梯度消失, 因为梯度为-1是不稳定的, 并不会一直出现, 所以可以保证梯度不会指数级别的衰减到0, 不过也许还是会震荡

Problem 2a#

根据给出的架构自己实现ResNet

卷积层在CNN的实现当中已经解释过了, 说一说归一化层nn.BatchNorm2d, 实际上对于输入维度[B, C, H, W], 他会对每个batch的每个channel(的所有数据点)去计算均值和方差做归一化

再说一下这里的残差连接, 当x经过主路径几个卷积层的变换后, 此时肯定是不能和原始输入x相加了, 所以需要一个1x1的卷积层来把x的通道数变成和主路径的输出通道数一样, 然后再相加

运行维度检查的代码:

x = torch.rand((1, 3, 224, 224))  # 1 image, 3 RGB channels, 224 x 224 pixels
print(f"Shape of input: {x.shape}")

res = ResidualBlock(in_channels=3, out_channels=64, kernel_size=3, initial_downsample=True, verbose=False)
resblock_out = res(x)
print(f"Shape of output: {resblock_out.shape}")
assert resblock_out.shape == torch.Size([1, 64, 112, 112]), f"Expected output shape (1, 64, 112, 112), but got {resblock_out.shape}"
python
Shape of input: torch.Size([1, 3, 224, 224])
Shape of output: torch.Size([1, 64, 112, 112])
plaintext
# Verify the shapes of the weights
conv1_shape = res.conv1.weight.shape
print(f"Shape of conv1 weight: {conv1_shape}")
assert conv1_shape == torch.Size([64, 3, 3, 3]), f"Expected Conv1 weights to have shape (64, 3, 3, 3), but got {conv1_shape}"

conv2_shape = res.conv2.weight.shape
print(f"Shape of conv2 weight: {conv2_shape}")
assert conv2_shape == torch.Size([64, 64, 3, 3]), f"Expected Conv2 weights to have shape (64, 64, 3, 3), but got {conv2_shape}"

residual_shape = res.residual_connection.weight.shape
print(f"Shape of residual weight: {residual_shape}")
assert residual_shape == torch.Size([64, 3, 1, 1]), f"Expected residual connection to have shape (64, 3, 1, 1), but got {residual_shape}"
python
Shape of conv1 weight: torch.Size([64, 3, 3, 3])
Shape of conv2 weight: torch.Size([64, 64, 3, 3])
Shape of residual weight: torch.Size([64, 3, 1, 1])
plaintext

Problem 2b#

刚刚我们已经实现了残差块, 得益于PyTorch的继承和封装机制, 我们可以很方便的再实现ResNet-18

看起来很吓人, 不过是搭积木罢了

x = torch.rand((1, 3, 224, 224))  # 1 image, 3 RGB channels, 224 x 224 pixels
print(f"Shape of input: {x.shape}")

resnet18 = ResNet18(num_classes=10, verbose=True)
resnet18_out = resnet18(x)
print(f"Shape of output: {resnet18_out.shape}")
assert resnet18_out.shape == torch.Size([1, 10]), f"Expected output shape (1, 10), but got {resnet18_out.shape}"
python
Shape of input: torch.Size([1, 3, 224, 224])
Shape of output: torch.Size([1, 10])
plaintext

Problem 2c#

直接调用之前的train函数开始训练即可

Problem 2d#

同样的, 调用plot_metrics函数画出损失曲线

plot_metrics(resnet_train_losses, resnet_val_losses, resnet_train_accuracies, resnet_val_accuracies, num_epochs=50, title="ResNet Training Metrics")
python
label_plot

Transformer#

label_plot

这里要求我们实现Transformer模型, 虽然说这个完整的架构比CS336里面那个要实现的东西多一点, 但实际上简单很多, 因为这里全程都用PyTorch实现, 而那边基本上都是手搓

和之前做ResNet一样, 先从子块开始实现, 然后再搭积木拼装回去

这一部分的更详细实现和原理参看我CS336的Assignment1 Part2的文章

Problem 3a#

要求实现softmax, 直接用torch.exp和torch.sum即可

def softmax(x: torch.Tensor):
    """
    Compute the softmax of each element in x.
    """
    x_exp=torch.exp(x)
    x_sum=torch.sum(x_exp,dim=-1,keepdim=True)
    return x_exp/x_sum
python

Problem 3b#

要求实现点积注意力机制, 本质上就是几个矩阵(Tensor)相乘而已

Problem 3c#

要求实现注意力头, 注意这里的输入有两种, 一种是输入x, 然后通过三个变换矩阵分别变换到Q,K,VQ,K,V, 另一种是在Outputs模块当中, 左边的两个输入要读取Inputs模块的输出, 右边的一个输入为x, 通过一个verbose参数去控制一下就好了

所谓的”三个线性变换到QKV空间”其实就是三个线性层

Problem 3d#

要求实现多头机制, 其实只不过是初始化若干个AttentionHead类, 然后把x传入每个类, 最后把每个类的输出拼接起来, 这里只要注意一下每个头的维度均为d_k = d_model / num_heads即可, 还有就是计算完的注意力要乘以一个W_o矩阵

Problem 3e#

实现架构图的左半部分, 非常纯粹的看图说话, 而且用torch实现这个ffn也非常简单, 不过是一个线性层加上一个ReLU而已

如果想看LayerNorm, Linear, ReLU的实现, 参看我CS336的Assignment1 Part2的文章

Problem 3f#

实现架构图的右半部分Decoder, 和上面差不多, 不再赘述了, 这里我没有写mask功能, 实际上mask功能就是构造一个上三角为True, 下三角为False的矩阵, 然后让注意力机制在计算注意力的时候, 把下三角的注意力权重设为负无穷, 这样在计算注意力权重的时候, 下三角的注意力权重就会变成0, 从而实现mask功能

具体可以看我CS336的Assignment1 Part2的文章, 那里非常详细的说了这个掩码功能

Problem 3g#

要求实现RoPE, 这里解释起来比较复杂, 建议看336的notes, 这里我直接给出代码

Problem 3h#

拓展一下encode部分, 本质上encode就三件事情: 词嵌入, RoPE, 过含有多头注意力, layernorm, ffn的EncodeLayer

Problem 3i#

拓展decode部分, 和上面一样

Problem 3j#

全部组装成Transformer类即可

接下来是”鉴赏”部分, 课程组帮我们准备好了TinyStories数据集并且做好了分词, 只不过这是个word级别的分词, 而我们在336里面实现的是byte级的

Problem 3k#

题目让我们处理一下段落, 其实也就是完成以下任务:

— 按换行把段落分开 — 每一段用之前给出的extract_full_words得到一个词列表 — 在段落的开头和结尾加上<start>和<end> — 对于段落中的每个词, 用word_to_id得到一个ID(毕竟输入模型的不能是词语) — 跳过每个比max_seq_length还短的段落 (这里不知道是不是课程组笔误了, 应该记作min_seq_length才对) — 用滑动窗口得到inputs和targets, 保证targets永远比inputs长1(预测下一个token)

Problem 3l#

用刚刚实现的数据集去构造DataLoader即可

Number of training sequences: 2000
Number of validation sequences: 200
Training batches: 63
Validation batches: 7
First 10 tokens of training input in first batch: tensor([4546, 1945, 2585, 1379, 3266, 4146,  629, 2357, 2319, 3820])
First 10 tokens of training target in first batch: tensor([1945, 2585, 1379, 3266, 4146,  629, 2357, 2319, 3820, 3896])
plaintext

Problem 3m#

开始训练

Problem 3n#

和之前一样画出损失曲线

label_plot

Problem 3o#

接下来就可以让我们的模型帮我们生成点东西了, 自由发挥即可

# TODO: Generate your own tiny story!
sample_text = "I love you"


sample_text = generate_sample(
    model=transformer,
    word_to_id=word_to_id,
    id_to_word=id_to_word,
    max_seq_len=20,
    prompt=sample_text,   
    max_length=50
)


print(f"Sample generation: {sample_text}")
python
Sample generation: shoes love you re lucky you re lucky you re lucky you re lucky you have a shield timmy looked at his shirt and saw that he was wearing his favorite superhero shirt he felt better and said thanks billy you re a good friend <end>
plaintext
UC Berkeley CS189 Assignment 4 (Part 1)
https://astro-pure.js.org/blog/cs189_assignment4_part1
Author Ziyu(Albert) Li 李子煜
Published at February 8, 2026
Comment seems to stuck. Try to refresh?✨