Ziyu Li's Homepage

Back

CS189 Assignment 4#

Happy Chinese New Year!

项目介绍#

本项目是Part1的延续, 利用BERT做一些任务, 同时这次的数据集是语音, 不是之前常见的数据点矩阵

BERT和Tokenizer#

什么是BERT#

Bi-Directional Encoder Representations from Transformers, 双向的Transformer Encoder, 这个模型是双向的

BERT 中的双向编码器意味着模型一次性处理整个输入序列,同时考虑每个 token 左侧和右侧的上下文。传统的语言模型是从左到右处理序列:当预测位置 tt 的 token 时, Transformer 的解码器只能看到位置 1 到 t−1t-1 的 token(这就是为什么我们需要在解码器的自注意力中使用掩码的原因)。与传统的只从左到右读取文本的语言模型不同, BERT 的编码器使用自注意力机制同时查看所有 token, 使其能够学习更丰富的上下文感知表示。因此,每个 token 的表示都受到序列中所有其他 token 的影响,无论这些 token 位于它之前还是之后。这对于模型理解 DNA 或文本序列的完整上下文至关重要

什么是Tokenizers#

分词器(Tokenizers)是把文本序列转化为数字序列的算法, 例如在CS336当中实现的BPE分词器就是一种

* Sentence: `"I love dinosaurs"`
* Tokens: `["[CLS]", "I", "love", "dinosaurs", "[SEP]"]`
* Token IDs: `[101, 146, 1567, 4083, 102]` (example values)
plaintext

可以从上面看到这个Tokenizers的目的就是把每个Token映射到一个数字, 所以分词器的预训练是非常重要的, 显然我们不希望输入的文本当中出现不存在于分词器定义域的Token, 否则模型会无法处理

在CS336中我们自己训练的BPE分词器就容易出现上面这种情况, 因为训练用的语料极其有限(至多也就几个GB的故事集), 但是在实际的任务中我们常用的是预训练好的分词器

from transformers import AutoTokenizer, AutoModel
tokenizer = AutoTokenizer.from_pretrained("<name_of_awesome_bert_model>")
model = AutoModel.from_pretrained("<name_of_awesome_bert_model>")
python

DNABERT#

在这个lab里面用DNABERT-6, 这个模型是针对DNA序列的

Architecture#

12个Transformer Encoder layers
12个Attention Heads
plaintext
label_plot

有了Part1实验当中的经历, 图上的很多模块看起来比较熟悉了, 基本上就是如下序列处理:

输入序列 -> Tokenizers分成一个个的Token -> Token Embedding层化为向量 -> Positional Encoding -> Input Embedding -> Transformer Encoder layers -> 尾部额外的层
plaintext

Problem 4a#

加载实验给出的DNA数据, 数据列是一个sequence, 代表DNA序列, species代表物种, 这里有大猩猩, 狗和人, 我们要手动创建一个species到target的映射, 把物种映射到整数标签

还有一个class列, 代表该DNA序列在生物学上的分类, 但在本实验中我们还是预测target而非class

label_plot

查看每个物种(总共三个物种)的第一行的DNA序列的前50个碱基对

chimpanzee_dna = combined_df[combined_df['species']=='chimpanzee'].iloc[0]['sequence']
print(f"First 50 DNA tags of chimpanzee sequence: {chimpanzee_dna[:50]}...")

dog_dna = combined_df[combined_df['species']=='dog'].iloc[0]['sequence']
print(f"First 50 DNA tags of dog sequence: {dog_dna[:50]}...")

human_dna = combined_df[combined_df['species']=='human'].iloc[0]['sequence']
print(f"First 50 DNA tags of human sequence: {human_dna[:50]}...")
python
First 50 DNA tags of chimpanzee sequence: ATGGGCATGACACGGATGCTCCTGGAATGCAGTCTCAGTGACAAGTTGTG...
First 50 DNA tags of dog sequence: ATGGAGGTGCAGACAAAGAAAGTTCGAAAAGTTCCTCCAGGTTTGCCATC...
First 50 DNA tags of human sequence: ATGGAATCTGTGGTAAAGAACTGTGGCCAGACAGTTCATGATGAGGTGGC...
plaintext

这个sequence和正常的语料是不一样的, 我们需要找一个分词器来把这个sequence切成一个个的token

Problem 4b#

利用k-mers来进行切分, 这里k=6, 本质上就是个长度为k的滑动窗口

ATGCGTACTAAG
ATGCGT index 0
TGCGTA index 1
GCGTAC index 2
...
plaintext

看起来是一种很奇怪的切分方式, 这里的token排列组合只有4^6种, 看起来不太适合自然语言的处理

sequence  class     species  \
622   ATGTCTTTGGTGGACTTGGGGAAGAGGTTGCTAGAAGCAGCAAGAA...      6         dog   
2633  ATGGGAGGCCGCGTCTTTCTCGCATTCTGTGTCTGGCTGACTCTGC...      0       human   
1101  ATGAAAGCCCACCCCAAGGAGATGGTGCCTCTCATGGGCAAGAGAG...      5  chimpanzee   
294   ATGGTCAACGTCTTGAAAGGAGTGCTGATAGAATGTGACCCTGCCA...      6         dog   
48    ATGGGCTGTGTGTTCTGCAAGAAGTCGGAGCCGGGGCTCAAGGACG...      1         dog   

      target                                              kmers  
622        1  ATGTCT TGTCTT GTCTTT TCTTTG CTTTGG TTTGGT TTGG...  
2633       2  ATGGGA TGGGAG GGGAGG GGAGGC GAGGCC AGGCCG GGCC...  
1101       0  ATGAAA TGAAAG GAAAGC AAAGCC AAGCCC AGCCCA GCCC...  
294        1  ATGGTC TGGTCA GGTCAA GTCAAC TCAACG CAACGT AACG...  
48         1  ATGGGC TGGGCT GGGCTG GGCTGT GCTGTG CTGTGT TGTG...
plaintext

这样就实现了切分

接下来加载一下预训练好的DNABERT-6模型和分词器

dnabert_tokenizer = AutoTokenizer.from_pretrained("zhihan1996/DNA_bert_6", trust_remote_code=True, revision="c56e67ea5827e0ddc67ef059addcf71569b1216e")
dnabert_model = AutoModel.from_pretrained("zhihan1996/DNA_bert_6", trust_remote_code=True, revision="c56e67ea5827e0ddc67ef059addcf71569b1216e")
python

注意前面的k-mers只不过是切分成小的字符串, 我们要把它转换成数字才能送进模型, 这一步就是用Tokenizer来实现的

DNA 序列:  ATGCGTACTAAG
            ↓
生成 k-mers: ATGCGT, TGCGTA, GCGTAC, ...
            ↓
Tokenizer:  [CLS] ATG CGT TGC ... [SEP] [PAD] ...
            ↓
input_ids:  [1, 25, 30, 28, ..., 2, 0, 0, ...]
attention_mask: [1, 1, 1, 1, ..., 1, 0, 0, ...]
            ↓
DNABERT 模型
            ↓
last_hidden_state: (1, 512, 768)
            ↓
取 [CLS] 嵌入 → 用于分类或其他任务
plaintext

Step1: 拿出一行(做例子)k-mers之后了的数据送入Tokenizer里面

Step2: 用返回的input_ids和attention_mask送入模型, 得到last_hidden_state

last_hidden_state是一个特殊结构, 大概理解为模型的输出就好

Problem 4c#

现在模型输出的是一个(1, 512, 768)的tensor, 也就是所谓的last_hidden_state, 但我们最终想要得到的是整数, 也就是完成这个分类任务

为了得到整数分类, 一个很Trivial的想法自然是用Linear层去把768维映射成num_classes维, 但这里要注意, 每个token都被送到一个768维的向量了, 我们只需要提取第0个token, 即[CLS]对应的那个嵌入向量

文档中说这是因为[CLS]的嵌入向量是整个序列的总结, 所以我们要提取这个向量来做分类, 但我觉得这并不是一个很平凡的结论, 也许这是长期实践当中归纳出的经验吧

注意一下这个

cls_embeddings = last_hidden_state[:,0,:]
python

中间的0就代表第0个token

Problem 4d#

把k-mers之后数据组装成Dataset

讲一下这个__getitem__方法, 这里拿到kmers里面对应的数据之后要去进行Tokenize做映射, 原则上要返回三个元素:

'input_ids': 数字序列
'attention_mask': 掩码序列
'target': 标签
plaintext

前两个都是分词器返回的, 第三个是从类成员变量里拿的label, 注意分词器返回的要squeeze一下

其他没有什么难点, 跟着TODO走就好, 指导已经写的很明白了

Problem 4e#

从原始数据当中切分出训练集和验证集, 并且装载进刚实现的类里面

注意在train_test_split之后要把得到的数据做to_list操作, 否则会报错, 因为刚刚写的类传入的kmers和target都是list

Problem 4f#

实现训练循环, 无须多言, 都是公式化代码了

Problem 4g#

指定模型, 优化器, 损失函数以及其他超参数, 启动训练即可

Problem 4h#

画损失曲线

Problem 4i#

生成一份预测结果csv, 一键运行代码即可(只要前面的模型训练好了)

第二部分: 声音分类#

wav数据#

现在我们来处理一个更麻烦的任务, 给出一些.wav的文件, 这些声音来自不同的源, 比如说有些可能是空调的声音, 有些是狗叫, 目标是做这个分类

比较麻烦的点在于, 要先把这些.wav文件变成正常的数据格式, 这一点我们用torchaudio来实现

文件名里面有一个整数代表class_id, 这也是我们需要的label

本实验要用到交叉验证, 也就是把数据分为多个”折”(fold), 每次用n-1个折来训练, 用剩下的一个折来测试, 然后这个过程可以重复n次, 每次用一个不同的折来做测试

class_id_to_sound = {
   0: "air_conditioner",
   1: "car_horn",
   2: "children_playing",
   3: "dog_bark",
   4: "drilling",
   5: "engine_idling",
   6: "gun_shot",
   7: "jackhammer",
   8: "siren",
   9: "street_music"
}
python

课程组给了一些示例代码来播放.wav文件, 因为我是服务器环境所以听不到, 如果在本地应该是可以听的, 不过记住要配好pygame环境

频谱图#

接下来要把这个音频变成频谱图, 频谱图X轴是时间, Y轴是频率, 我们只要实例化一个torchaudio.transforms.Spectrogram就行了, 然后把这个变换应用到原始文件上去

运行他给的代码可以看到频谱图

label_plot

这本质上就是个二维图像了

Problem 5a#

组建Dataset, 几个要注意的点:

  1. 从文件名里面把正确的分类拿出来(label)返回
  2. 把频谱图的尺寸(1, H, W)通过repeat变成(3, H, W)
  3. 如果传入了额外的transform参数, 记得在repeat之后应用

这里repeat的作用就是沿着第0维复制3次把通道数变成3, 以便于适配后面的输入

Problem 5b#

把Dataset实例化后装到DataLoader里面去

公式化代码, 没什么好说的

Shape of 1 batch of data: torch.Size([32, 3, 224, 224])
Shape of 1 batch of targets: torch.Size([32])
plaintext

Problem 5c#

实现训练循环

Problem 5d#

这里用的模型是在ImageNet上预训练好的ConvNeXt模型, 他的最后一层的全连层的输出是1000维的, 我们要改成10维, 因为这是个10-分类问题

def replace_final_convnext_linear_layer(model, num_classes=10):
    # TODO: Access the classifier module of the model
    classifier = model.classifier[-1]

    # TODO: Get the input dimensions of the classifier's last linear layer
    in_features = classifier.in_features

    # TODO: replace the model's last linear layer with a new linear layer
    model.classifier[-1] = nn.Linear(in_features,num_classes)

    return model
python

预训练与微调简介#

所谓预训练就是直接用别人调好了参数的模型, 比如在ImageNet上预训练好了的resnet50

model = resnet50(weights = ResNet50_Weights.IMAGENET1K_V2)
python

当然也可以自己初始化一版模型参数

model = resnet50(weights = None)
python

微调有好几种方式:

  1. 从Scratch训练, 用随机的初始化参数

  2. 采用预训练好的参数, 并且冻结除了最后一个分类头之外的所有参数, 只训练最后那个Linear Layer

  3. 全量微调, 用预训练好的参数但是所有参数都可以再次进行训练

实验也给出了冻结某一层的参数的方式:

for name, param in frozen_backbone.named_parameters():
    if "classifier" not in name: # Freeze any non-classifier layers
        param.requires_grad = False
python

所谓”冻结”也就是是否允许参数被更改, 当然也就是设置这个梯度的bool

Problem 5e#

用全部的数据来从头训练convnext模型

Epoch 1: Training loss = 2.3848066329956055	Train accuracy = 0.1774193548387097
Epoch 1: Validation loss = 2.237703227996826	Validation accuracy = 0.20714285714285716
Epoch 2: Training loss = 2.1374161640803018	Train accuracy = 0.21505376344086022
Epoch 2: Validation loss = 2.2762171745300295	Validation accuracy = 0.2357142857142857
Epoch 3: Training loss = 2.1253870791859097	Train accuracy = 0.23297491039426524
Epoch 3: Validation loss = 2.116562104225159	Validation accuracy = 0.2
Epoch 4: Training loss = 2.0567029780811734	Train accuracy = 0.2078853046594982
Epoch 4: Validation loss = 2.0853104829788207	Validation accuracy = 0.19285714285714287
Epoch 5: Training loss = 2.0093974073727927	Train accuracy = 0.22580645161290322
Epoch 5: Validation loss = 2.036539649963379	Validation accuracy = 0.2642857142857143
==================== Final Metrics ====================
Final training loss: 2.00940	Final training accuracy = 0.22581
Final validation loss: 2.03654	Final validation accuracy = 0.26429
=======================================================
plaintext

注意这里的学习率是超参数, 可以调的

Problem 5f#

冻结除了分类头以外的层, 只训练分类头

Epoch 1: Training loss = 2.303494784567091	Train accuracy = 0.08064516129032258
Epoch 1: Validation loss = 2.2263582229614256	Validation accuracy = 0.17142857142857143
Epoch 2: Training loss = 2.220644950866699	Train accuracy = 0.18100358422939067
Epoch 2: Validation loss = 2.1564966201782227	Validation accuracy = 0.17857142857142858
Epoch 3: Training loss = 2.168804738256666	Train accuracy = 0.21863799283154123
Epoch 3: Validation loss = 2.100537633895874	Validation accuracy = 0.22857142857142856
Epoch 4: Training loss = 2.118125465181139	Train accuracy = 0.25806451612903225
Epoch 4: Validation loss = 2.0520622968673705	Validation accuracy = 0.3
Epoch 5: Training loss = 2.068689114517636	Train accuracy = 0.2974910394265233
Epoch 5: Validation loss = 2.0029944658279417	Validation accuracy = 0.32142857142857145
==================== Final Metrics ====================
Final training loss: 2.06869	Final training accuracy = 0.29749
Final validation loss: 2.00299	Final validation accuracy = 0.32143
=======================================================
plaintext
label_plot

Problem 5g#

全量微调

Epoch 1: Training loss = 1.8407021694713168	Train accuracy = 0.3888888888888889
Epoch 1: Validation loss = 1.1633551120758057	Validation accuracy = 0.7
Epoch 2: Training loss = 0.9334362381034427	Train accuracy = 0.7419354838709677
Epoch 2: Validation loss = 0.5684262037277221	Validation accuracy = 0.85
Epoch 3: Training loss = 0.4550878521468904	Train accuracy = 0.8888888888888888
Epoch 3: Validation loss = 0.4825403094291687	Validation accuracy = 0.8357142857142857
Epoch 4: Training loss = 0.3048595061732663	Train accuracy = 0.9068100358422939
Epoch 4: Validation loss = 0.3036103412508965	Validation accuracy = 0.9
Epoch 5: Training loss = 0.19161400902602407	Train accuracy = 0.946236559139785
Epoch 5: Validation loss = 0.3878093510866165	Validation accuracy = 0.8785714285714286
==================== Final Metrics ====================
Final training loss: 0.19161	Final training accuracy = 0.94624
Final validation loss: 0.38781	Final validation accuracy = 0.87857
=======================================================
plaintext
label_plot

Problem 5h#

分析一下上面三种方式(从Scratch训练, 冻结除了分类头以外的层, 全量微调)的绩效

Pretrained + Full Fine-tuning is the most effective approach because it allows the model to adapt to the new dataset and achieve the highest accuracy.

Disadvantages:
* It requires more data and training time.
* It may overfit to the new dataset if the amount of data is limited.
plaintext

Problem 5i#

推理一次, 看看预测的效果

Predicted class: dog_bark
True class: dog_bark
plaintext
UC Berkeley CS189 Assignment 4 (Part 2)
https://astro-pure.js.org/blog/cs189_assignment4_part2
Author Ziyu(Albert) Li 李子煜
Published at February 26, 2026
Comment seems to stuck. Try to refresh?✨