0%

本文的模型微调基于hugging face生态,但未使用Trainer API,以transcribe任务为例

论文精读

OpenAI Whisper 精读【论文精读·45】_哔哩哔哩_bilibili

现存ASR问题

像wav2vec等主流ASR模型都是使用无监督学习pre训练encoder,再使用监督学习微调一个decoder,也就是说无法避免使用监督学习与微调,而微调容易对特定label过拟合

因此作者提出可以通过加大数据量+质量略微下降(也就是weak supervise)的方法力大砖飞(680k小时数据集)

方法

清洗数据

核心思路:音频质量多样性有助于提高robust,但是文本质量没有类似好处,需要筛选

  • (主)删去机器翻译内容,避免“转录腔”。通过检测文本是否经过标准化(标点符号,大小写等)
  • (主)删去音频、文本语言不同的情况:使用CLD2(自家软件)检测音频所使用语言是否与文本对应
  • 通过一个initial model检测错误率一直特别高的数据,再检查是否有问题

模型

直接使用标准encoder+decoder的Transformer,encoder中使用2个一维卷积(大小为3)缩短mel谱时间维度,其他没有太大变化

多任务模式(核心)

实现一个结构的模型可以实现多种任务,whisper主要实现了:

  • en to en 的转录
  • any to any的转录(需对应)
  • any to en 的翻译(注意whisper的翻译可以翻译任何内容,包括混合,而不限制源语言)
  • 无语音检测

实现方法:

bert的方法是使用不同的输出层训练不同任务,而whisper是使用了类似prompt的操作,即在decoder的输入tokens前额外加入带有任务标志的token,这样做的好处就是从头到尾都可以完完全全地使用同一个模型

其他训练细节

  • 使用FP16
  • dynamic loss scaling
  • epoch为2-3
  • zero shot评估,即使用完全unseen的数据集eval

综上,whisper的主要核心就是超大规模监督训练+prompt型多任务训练,也就是证明了ASR领域也可以通过提高数据集数量实现力大砖飞,而不仅是调整模型(因为whisper基本没有改模型,直接用的Transformer)

数据集准备

本文使用hugging face上的一个阿尔巴尼亚语数据集Kushtrim/common_voice_18_sq · Datasets at Hugging Face,我是直接在网站上预先下载的parquet格式数据集,也可以直接在代码中下载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
from datasets import load_dataset, Audio
from transformers import WhisperProcessor,WhisperForConditionalGeneration
from transformers.models.whisper.tokenization_whisper import TO_LANGUAGE_CODE #可查看whisper支持语言
import soundfile as sf
import io
from typing import Any, Dict, List, Union
from dataclasses import dataclass
import torch
from torch.utils.data import DataLoader
from torch.optim import AdamW
from transformers import get_scheduler
from tqdm import tqdm


processor = WhisperProcessor.from_pretrained(
"openai/whisper-small", language="albanian", task="transcribe")

alb_dataset = load_dataset("parquet", data_files={'train': 'train.parquet', 'test': 'test.parquet'})
alb_dataset = alb_dataset.cast_column("audio", Audio(decode=False))

def prepare_dataset(example):
audio_bytes = example["audio"]['bytes']
audio, samplerate = sf.read(io.BytesIO(audio_bytes))

example = processor(audio=audio,
sampling_rate=16000,
text=example["sentence"])
example["input_length"] = len(audio) / samplerate
return example
#此处alb_dataset.column_names["train"]是返回的所有column的名字,指删去原datasetdict所有同名列,不是指删去原训练集
dataset = alb_dataset.map(prepare_dataset,remove_columns=alb_dataset.column_names["train"],
num_proc=1,
batch_size=100)
# sample = dataset["train"][1] #feature形状:(1,80,3000)的list

processor调用

此处调用了与模型配套的processor,这是一个与Whisper配套的类似tokenizer的工具箱对象,负责:

  • 将音频重采样,转为mel频谱,归一化,统一pad至30s
  • 将文本转为ID,也就是tokenizer的任务

禁用torchcodec,使用bytes读取

由于我直接读取单个数据时会出现torchcodec库的解码报错,由于我的torch版本较低,因此无法修复该报错。以下是我的解决方法:

  • alb_dataset.cast_column("audio", Audio(decode=False))禁用torchcodec解码

  • 对于可以正常解码的情况,声音波形的解码数据可以直接传入processor,而此处,通过读取声音的原始bytes复原声音的解码数据(对于抱抱脸的dataset统一数据格式可查看LLM学习 | 小董的BLOG

    1
    2
    audio_bytes = example["audio"]['bytes']
    audio, samplerate = sf.read(io.BytesIO(audio_bytes))

如果这里对sample进行debug,则可以观察到其数据格式:

为一个3key字典,其中feature为list1的原因是其被封装为了(1,80,3000)形状,80是mel滤波器数量,3000是长度

数据处理与dataloader加载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
''' 
@dataclass等同于
def __init__(self, processor):
self.processor = processor
'''
@dataclass
class DataCollatorSpeechSeq2SeqWithPadding:
processor: Any #不强制约束类型

#令对象可像函数一样直接调用
def __call__(
self, features: List[Dict[str, Union[List[int], torch.Tensor]]] #输入为一个list,内容为一个字典,键为str,值为list或tensor之一
) -> Dict[str, torch.Tensor]: #返回值为一个字典,类似C语言的函数定义

input_features = [
{"input_features": feature["input_features"][0]} for feature in features #feature["input_features"]形状为(1,80,3000),因此要取[0]
]
batch = self.processor.feature_extractor.pad(input_features, return_tensors="pt") #processor已经填充过,此处仅为转tensor
#feature_extractor.pad接口协议是List[{"input_features": (feature_dim, time)}]

#label同理,但是label是没有提前填充的
label_features = [{"input_ids": feature["labels"]} for feature in features]
labels_batch = self.processor.tokenizer.pad(label_features, return_tensors="pt")
#返回{"input_ids": Tensor(batch, max_len),"attention_mask": Tensor(batch, max_len)}
labels = labels_batch["input_ids"].masked_fill(
labels_batch.attention_mask.ne(1), -100 #pytorch 交叉熵默认-100不参与loss计算
)#用-100代替填充词元,labels_batch.attention_mask.ne(1)可以根据attention_mask生成一个bool mask,pad token为True

# 如果在之前分词时添加了 bos 词元,那就剪切掉,因为之后还会加上的
if (labels[:, 0] == self.processor.tokenizer.bos_token_id).all().cpu().item():
labels = labels[:, 1:]

batch["labels"] = labels

return batch

data_collator = DataCollatorSpeechSeq2SeqWithPadding(processor=processor)
# 定义 DataLoader
train_dataloader = DataLoader(
dataset["train"],
batch_size=2,
shuffle=True,
collate_fn=data_collator
)
eval_dataloader = DataLoader(
dataset["test"],
batch_size=2,
collate_fn=data_collator
)

DataCollatorSpeechSeq2SeqWithPadding在这里的主要作用是

  • 将特征转为tensor
  • 填充label(token ID),并转为tensor
  • 将padding token转化为-100

其他需要注意的就是@dataclass__call__和列表推导式的语法技巧,都已用注释标出。最后,由于后续训练调用Seq2SeqTrainer API时总是会出现梯度计算图的重复计算报错,而hugging face封装的密不透风的对象我实在是做不到debug,干脆就索性直接调用DataLoader,自己写训练过程了。

DataLoader天然支持hugging face风格的dataset和data_collator,可以非常方便的直接使用

简陋微调一下

因为直接调API有bug,就自己简单实现了一下训练,以下代码只实现了基本训练功能,亲测可以跑通

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
device = torch.device("cuda")
model = WhisperForConditionalGeneration.from_pretrained("openai/whisper-small")
model.to(device)

optimizer = AdamW(model.parameters(), lr=5e-5)
num_epochs = 3
num_training_steps = num_epochs * len(train_dataloader)
lr_scheduler = get_scheduler(
"linear",
optimizer=optimizer,
num_warmup_steps=0,
num_training_steps=num_training_steps
)

for epoch in range(num_epochs):
model.train()
train_loop = tqdm(enumerate(train_dataloader), total=len(train_dataloader),
desc=f'Train_Epoch {epoch + 1}/{num_epochs}', unit='batch')
for idx, batch in train_loop:
batch = {k: v.to(device) for k, v in batch.items()}


outputs = model(**batch)
loss = outputs.loss

loss.backward()

optimizer.step()
lr_scheduler.step()
optimizer.zero_grad()
train_loop.set_postfix({'loss': '{0:1.5f}'.format(loss.item())})

model.eval()
total_loss = 0
eval_loop = tqdm(enumerate(eval_dataloader), total=len(eval_dataloader),
desc=f'Eval_Epoch {epoch + 1}/{num_epochs}', unit='batch')
for idx, batch in eval_loop:
batch = {k: v.to(device) for k, v in batch.items()}
with torch.no_grad():
outputs = model(**batch)

total_loss += outputs.loss.item()

avg_loss = total_loss / len(eval_dataloader)
print(f"Evaluation Loss: {avg_loss}")

训练相关代码好像没啥好说的了,都是一些经典操作,其中self.processor.feature_extractor.pad返回的batch格式如下,其是一个类似字典的结构,需要注意数据的读取方式

文本任务的评估指标

WER 的全称是 Word Error Rate(词错误率)。它是衡量语音识别(ASR)或机器翻译系统准确性最标准、最通用的指标。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
from transformers.models.whisper.english_normalizer import BasicTextNormalizer

normalizer = BasicTextNormalizer()#文本标准化,重要


def compute_metrics(pred):
pred_ids = pred.predictions
label_ids = pred.label_ids

# 用 pad_token_id 替换 -100
label_ids[label_ids == -100] = processor.tokenizer.pad_token_id

# 我们希望在计算指标时不要组合起词元(解码结果为词元(subword),而不是完整单词)
pred_str = processor.batch_decode(pred_ids, skip_special_tokens=True)
label_str = processor.batch_decode(label_ids, skip_special_tokens=True)

# 计算普通的 WER
wer_ortho = 100 * metric.compute(predictions=pred_str, references=label_str)

# 计算标准化的 WER
pred_str_norm = [normalizer(pred) for pred in pred_str]
label_str_norm = [normalizer(label) for label in label_str]
# 过滤,从而在评估时只计算 reference 非空的样本
pred_str_norm = [
pred_str_norm[i] for i in range(len(pred_str_norm)) if len(label_str_norm[i]) > 0
]
label_str_norm = [
label_str_norm[i]
for i in range(len(label_str_norm))
if len(label_str_norm[i]) > 0
]

wer = 100 * metric.compute(predictions=pred_str_norm, references=label_str_norm)

return {"wer_ortho": wer_ortho, "wer": wer}

BasicTextNormalizer 是 Whisper 官方提供的一个文本“清洗”工具。

  • 为什么要标准化? 在阿尔巴尼亚语或英语中,同一个意思可能有不同写法(比如大小写、多余空格、标点符号)。
  • 它做了什么?
    • 将文本全部转为小写
    • 移除所有的标点符号
    • 移除多余的空格(连续空格合并为一个)。
    • 移除 Unicode 标记。
  • 结果:它让模型只关注“词是否选对了”,而不关注“标点符号是否点对了”,这能提供一个更客观的语言理解指标。

WER原理

WER 的计算基于编辑距离的概念。想象你有一行模型输出的文字(Hypothesis),通过以下三种操作将其修改为参考文本(Reference):

  1. 替换 (Substitution, S):把错误的词换成正确的词。
  2. 插入 (Insertion, I):在漏掉的地方补上缺失的词。
  3. 删除 (Deletion, D):删掉模型多出的冗余词。

2. 计算公式

WER 的计算公式如下:

其中:

  • S:替换的次数
  • D:删除的次数
  • I:插入的次数
  • N:参考文本(标准答案)的总词数

注意: WER 的值越低越好(0 表示完美匹配)。由于存在“插入”操作,WER 的值有可能超过 100%

进阶微调

调整精度

需注意,调整精度和量化是不同的概念,调整精度的目的是加速模型运算的速度,而量化则是通过改变预训练的模型权重存储格式以降低其显存占用

直接在模型加载中指定精度

大部分模型的默认精度都是FP32,我们可以将其在加载时调整至FP16,这样可以直接使显存占用几乎减半:

1
2
3
4
5
6
#对于transformers库加载模型:
model = WhisperForConditionalGeneration.from_pretrained("openai/whisper-small",
,torch_dype=torch.bfloat16)
#对于nn.Moudle对象:
model = MyModel() # 默认 FP32
model.to(torch.float16) # 整体转为 FP16

BFP16数值范围更广,不容易梯度爆炸

注:输入模型的数据也要同步调整至FP16:input_features.to(torch.float16)

自动混合精度AMP(工程常用)

在forward中使用FP16,但在反向传播更新梯度时使用FP32保证精度

需要使用 torch.cuda.amp 来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
model = WhisperForConditionalGeneration.from_pretrained("openai/whisper-small").to("cuda")
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-5)

# 初始化梯度缩放器,防止 FP16 梯度下溢
scaler = GradScaler()

for batch in dataloader:
optimizer.zero_grad()

# 1. 在 autocast 上下文中运行前向传播
with autocast(dtype=torch.float16):
outputs = model(input_features=batch["input_features"], labels=batch["labels"])
loss = outputs.loss

# 2. 使用 scaler 缩放 loss 并反向传播
scaler.scale(loss).backward()

# 3. 更新参数
scaler.step(optimizer)
scaler.update()

需要注意的是,使用autocast时会自动在forward过程中将数据精度调整至fp16,由于backward时还是fp32,所以不要手动调整输入数据精度!

LoRA

使用PEFT库

低秩适应是一种 PEFT 方法,它将一个大矩阵分解为两个较小的低秩矩阵,用于注意力层。这极大地减少了需要微调的参数数量。主要用于优化训练时间

LoraConfig

每个 PEFT 方法都由一个 PeftConfig 类定义,该类存储了构建 PeftModel 的所有重要参数。对于LoRA微调,有:

1
2
3
4
5
6
from peft import LoraConfig
config = LoraConfig(r=32,
lora_alpha=64,
target_modules=["q_proj", "v_proj"],
lora_dropout=0.05)

对于该类的详细使用可查阅官方文档:LoRA - Hugging Face 文档

其中必填的参数有:

  • r (int) — Lora 注意力维度(“秩”)
  • lora_alpha (int) — Lora 的 alpha 参数,用于缩放。
  • target_modules(Optional[Union[List[str], str]]) — 要应用适配器的模块名称。如果将其指定为“all-linear”,则选择所有线性/Conv1D 模块。虽然官网写的是Optional,但是对于大部分模型还是需要指定,对于具体名称则可以通过for name, parameter in model.named_parameters(): print(name)查看
  • lora_dropout (float) — Lora 层的 dropout 概率。

接下来我们可以查看可训练的参数占比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from peft import LoraConfig, get_peft_model

model = WhisperForConditionalGeneration.from_pretrained("openai/whisper-small")
config = LoraConfig(r=32,
lora_alpha=64,
target_modules=["q_proj", "v_proj"],
lora_dropout=0.05)

model = get_peft_model(model, config)
# 打印训练参数
model.print_trainable_parameters()

'''
结果
trainable params: 3,538,944 || all params: 245,273,856 || trainable%: 1.4429
即在微调中只有1.44%的参数参与训练
'''

接下来直接对新的peft模型进行训练即可

QLoRA(4-bit 量化微调)

使用BitsAndBytesConfig方法实现量化config,这里展示4-bit,但其实8-bit也很常用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from transformers import BitsAndBytesConfig
from peft import get_peft_model, prepare_model_for_kbit_training

# 配置 4-bit 量化
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_quant_type="nf4", # 使用正态浮点 4 位,精度更好
bnb_4bit_use_double_quant=True # 嵌套量化,进一步省显存
)

model = WhisperForConditionalGeneration.from_pretrained(
"openai/whisper-small",
quantization_config=bnb_config
)

#量化后的权重是不可训练的。如果你要微调,必须配合 LoRA 使用!!
model = prepare_model_for_kbit_training(model)
...
lora_model = get_peft_model(model, lora_config)

注意,bnb_4bit_compute_dtype指定精度最好和前文中的精度调整(如AMP)中指定的精度相同,不然训练时会频繁调整格式。

同时,常使用的4-bit是NF4正态浮点4位,而不是INT4,因为NF4的数值分布是非线性的

总结

本节使用的微调技术包括了LoRA,量化(组合起来就算QLoRA)和精度调整

  • LoRA将一个大矩阵分解为两个较小的低秩矩阵,用于注意力层,提高训练速度
  • 量化是调整预训练权重存储格式,降低显存占用
  • 精度调整是调整训练过程中的数据精度,提高训练速度

遇到的问题及解决方案

当使用QLoRA时,适当提高学习率,设置学习率预热有助于稳定高效地学习

本章是在有一定深度学习基础,熟悉attention和transformer结构的基础上写的

Transformer

attention的抽象理解

还是以”I love you“到”我爱你“的翻译任务举例:

  • encoder的自注意力:在模型逐字翻译中文的过程中,encoder只计算一次。encoder主要是为了得到英文中“I love you”的语义关系,例如主谓宾结构,或者“这是一个表达情感的语句“等
  • decoder的自注意力:当已完成”我“的翻译时,现在decoder的输入则是”我“,在自注意力计算中,decoder则会生成中文”我“的语义
  • decoder的交叉注意力:由于”我“这个中文语义中缺失谓语,因此在Q与K的计算中,”love“这个表示谓语的词语会得到更高的关注度(只是举例,实际上不只有这一个原因,但具体训练过程其实我们人类是难以理解的);将关注度进行softmax后与V相乘,最终会输出一个与”love”强相关的”I love you”的深层语义表达(可以理解为:decoder当前需要翻译“love”,并且考虑了“I”和“you”在全文中的含义)。
  • decoder尾部:线性层和softmax最后会基于之前的语义表达,输出一个与中文“爱”相似度最高的embedding

语言模型类型

语言模型通常分为三种架构类别:

  • 仅编码器模型(如 BERT):这些模型使用双向方法来理解来自两个方向的上下文。它们最适合需要深入理解文本的任务,如分类、命名实体识别和问答
  • 仅解码器模型(如 GPT、Llama):这些模型从左到右处理文本,特别擅长文本生成任务。它们可以根据提示完成句子、写文章,甚至生成代码。
  • 编码器-解码器模型(如 T5、BART):这些模型结合了两种方法,使用编码器理解输入,使用解码器生成输出。它们在序列到序列任务中表现出色,如翻译、摘要和问答

而这些模型通常有两种训练方法:

  1. 掩码语言建模(MLM):由像 BERT 这样的编码器模型使用,这种方法随机掩盖输入中的一些词元,并训练模型根据周围的上下文预测原始词元。这使得模型能够学习双向上下文(同时关注被掩盖词语之前和之后的词语)。
  2. 因果语言建模(CLM):由像 GPT 这样的解码器模型使用,这种方法根据序列中所有之前的词元来预测下一个词元。模型只能使用左侧(之前的词元)的上下文来预测下一个词元。

常规架构(encoder-decoder)

该结构常见于翻译、语音转录等需要理解+较为严格的生成要求的任务

  • encoder用于接收源输入并提取特征(例如翻译任务中的待翻译原文)

  • decoder通过encoder输出的特征和之前的输出内容,生成下一个输出

    例如,当要将”I love you”翻译为中文时,encoder接收的便是”I love you”全文,decoder则是按顺序依次生成“我”,“爱”,“你”,而当生成“爱”时,decoder会将”我”与对”I love you“的特征进行attention计算,在训练优异的情况下,”love“这个谓语会被重点关注(被认为是下一个被翻译的对象),decoder会生成一个基于”I“和”you“的”love“深层语义表达,在decoder的最后,该语义表达会与中文”爱“的匹配度最高

Whisper就是典型的该结构(whisper实现了从头到尾的全任务适用,而不需要额外训练下游网络)

Encoder-only

该结构强调对输入的理解,常用于语义理解、分类任务,本小节以BERT为例

因为强调“理解”,需要模型对文本有充分的上下文结构理解,因此该类模型通常使用MLM训练(见上节),该训练方法即实现了文本双向理解(不同于使用两个相反的RNN)

另外,BERT还添加了NSP预测任务(Next Sentence Prediction),即预测输入的两个句子是否存在上下句关系。在标注时,额外通过对所有token添加一个0or1的embedding来告诉模型当前输入的是一个句子还是两个句子(不是表示它们是不是来自于同一句子!,这是模型自己要学的东西)

另外,BERT添加这些不同训练任务(MLM和NSP)是为了增强模型的学习能力,因为BERT一般作为上游模型使用,即使用pre-train的BERT再添加下游网络进行微调从而用于特定任务

Decoder-only

该结构常用于像GPT等的生成任务模型,适用于开发式生成任务。本小节以GPT为例

仅使用decoder的结构不代表GPT就不需要对输入进行“理解”,与使用encoder进行“理解”的工作不同,GPT类模型直接使用同一个decoder进行输入的“理解”与内容的生成

prefill阶段

当用户输入一段话prompt时,decoder会将其中的每个token都计算其k,v向量,并存储到 KV Cache 中。 KV Cache 就是模型对用户输入“理解结果”的物理存储。在后续生成(Decoding)时,模型不需要重新计算 Prompt,直接通过查询这些 Cache (即将q与prompt的KV cache进行注意力计算)就能“回想起”用户到底问了什么。

另外,该阶段为并行计算其计算得到的 KV cache会直接与decoding阶段的KV cache直接拼接(append)

为了加速 Prefill 阶段,工业界采用了多种优化手段:

  • FlashAttention: 通过优化 GPU 上 SRAM 和 HBM 之间的数据传输,极大提升了计算 $O(N^2)$ 注意力矩阵的速度。
  • Chunked Prefill: 将超长的 Prompt 切分成小块(Chunks)处理,防止长文本预填充时瞬间撑爆显存。
  • Parallel Prefill & Decode: 允许模型在处理一个请求的 Prefill 时,同时处理另一个请求的 Decoding,提高吞吐量。

decoding阶段

就是正常的decoder生成流程了,现在普遍的做法都是使用KV cache,这样就可以在每次生成新token时仅使用上一个token作为decoder输入(q向量),因为之前的所有已生成token和用户输入都已经作为 KV cache被存储了

自编码器模型(Autoencoder,AE)

自编码器为transformer结构在无监督学习上的核心模型。一般为encoder+decoder的完整端到端串联结构,这与传统的transformer结构不同。其核心逻辑始终围绕着:通过“压缩-还原”的过程,学习数据最本质的特征

在自编码器模型中,encoder本质为“特征压缩器”,负责将高维输入映射到低维空间(隐向量,与数据语义高度相关);而decoder则为“特征重建器”,将压缩后的隐向量还原为原始维度,在通过解码器重建时,模型必须丢弃噪声和冗余信息——这种“信息瓶颈”(Information Bottleneck)迫使它只保留最关键的特征(例如手写数字的轮廓、纹理等)。

卷积自编码器(CAE)

用卷积层替代全连接层(编码器用Conv+Pooling,解码器用ConvTranspose反卷积)。保留图像的空间局部性特征,在图像任务中性能远超全连接自编码器。

降噪自编码器(DAE)

与MAE类似,在输入主动添加噪声(高斯噪声,随机遮挡mask等),让模型重建无噪声原始输入可直接用于去噪任务,也可单独取训练完的encoder用作上游模型

变分自编码器(VAE)

传统的AE的encoder输出的是低维隐向量z,而VAE则是输出x的概率分布参数:均值和方差(均值代表x在隐空间中的最具代表性位置,方差代表不确定性or容错范围),避免了传统z离散无约束的缺点,使隐空间符合正态分布。

重参数化

由于直接从encoder输出的分布中采样是一个随机过程,无法求导。因此VAE需要引入重参数化:

其中ε为从标准正态分布中采样的一个噪声变量,这样随机性就被转移了,便可以对均值和方差进行反向传播

损失函数

一般的AE一般只使用MSE(连续)或交叉熵(离散),VAE引入了KL散度正则项,即衡量encoder输出的分布z与先验分布(一般为标准正态分布)的差异,强制所有编码在潜在空间中靠近原点且具有一定的重叠。这保证了潜在空间的连续性和完整性。

音频Transformer

模型输入格式

  • 文本输入:通常出现在文本到语音的任务中(TTS),与原始Transformer或任何其他NLP模型的工作方式相同:首先对文本进行标记化(tokenization),得到一系列文本标记。然后将此序列通过输入嵌入层,将标记转换为512维向量。然后将这些嵌入向量传递到Transformer编码器(一般为decoder)中。
  • 波形输入:Wav2Vec2HuBERT一类的模型直接使用音频波形作为模型的输入。我们首先将原始波形标准化为零均值和单位方差的序列,这有助于标准化不同音量(振幅)的音频样本。对于这类模型,encoder前一般会用一个小型CNN进行下采样和提取局部特征,减少序列长度
  • 时频谱输入:老朋友了,通过时频域转化可以大幅缩减样本尺寸。该类模型通常也会使用一个小型CNN提取局部特征和修改尺寸

模型输出格式

  • 文本输出:和语言模型相同,将decoder输出的输出嵌入向量通过一个线性头和sofrmax转换为词汇表中文本id的概率(也就是最终输出维度=词汇表大小
  • 直接波形输出:有些模型可以直接输出波形,但较少
  • 时频谱输出:对于该类模型,由于我们最终还是需要输出一个波形,因此通常有两种做法:
    • 基于istft:如果我们输入模型的时频谱是stft得到的,也可以通过istft还原。但此时我们需要知道幅值和相位两部分的信息,而一般音频模型输入仅使用基于幅值信息的功率谱,因此需要一个额外的网络估计其相位信息(也有其他方法,跟模型输入有关)
    • 基于神经网络:直接再使用一个神经网络将decoder输出嵌入转换为波形(Vocoder声码器)

两种结构

CTC结构

CTC结构(Connectionist Temporal Classification)是一种使用Transformer编码器(encoder)结构的语音识别(ASR)模型。使用该架构的模型包括Wav2Vec2、HuBERT、M-CTC-T等等。

在CTC结构模型中,所使用的词汇表通常是小词汇表(字符、音素等)。该类模型通常将若干ms的样本切片输出为一个token,由于一个字母发音可能包含多个切片,模型便会输出多个重复字母,因为每个token必须对应一个结果。而CTC算法就是通过一个特殊标记(blank token),压缩模型输出的重复或空白内容

对于空白标记,其是通过特殊的损失函数让模型学习何时该输出空白标记的,因此CTC模型使用的loss不是标准的交叉熵。除了词汇表中添加空白标记、仅使用encoder和使用特殊的训练策略之外,该类模型就没有什么特殊的点了。

对于只考虑单字符的CTC模型,可能会输出听起来正确但拼写不正确的单词,因此可以使用额外的语言模型来提高音频的转录质量。这个语言模型实际上是作为了CTC输出的拼写检查器

Seq2seq结构

比CTC结构的模型能力更强,使用标准的transformer结构,与语言模型基本一致。因此其最终的输出和语言模型一样,都是subword,对于whisper,其使用的就是GPT2的分词器。在ASR任务中,其使用交叉熵作为损失函数。

🤗Transformers库基础🤗

pipeline

transformer库直接调用已确定具体功能的模型的函数

1
2
3
4
5
6
7
8
9
10
11
12
from transformers import pipeline

classifier = pipeline("sentiment-analysis")
classifier(
[
"I've been waiting for a HuggingFace course my whole life.",
"I hate this so much!",
]
)

>>>[{'label': 'POSITIVE', 'score': 0.9598047137260437},
>>> {'label': 'NEGATIVE', 'score': 0.9994558095932007}]

对于一个语言模型,其大致可以分为两个部分,即tokenizer和model

tokenizer

原理

tokenizer的作用就是将输入的文本转化为模型可处理的tensor(即编码encode),同时生成每句话所对应的一些额外信息,如token_type_ids和attention_mask等

基本的tokenizer可以分为三类:

  • word-based:直接以空格分隔单词,将每个单词(word)对应一个唯一ID。缺点即是需要大量的token库,以存储所有单词
  • Character-based:直接拆为单个字符,将每个字符对应唯一ID。缺点是对于一个句子,模型需要处理大量token,并且每个字符本身没有太大意义(对于英文来说)
  • subword:几乎所有大模型都在使用的分词策略,原则:常用词不应被分解为更小的子词,但罕见词应被分解为有意义的子词。例如,对于”tokenization”,则可以被分为”token”和”ization”,因为这两个subword出现更为频繁,并且这样分词也可以保留其意义

每个模型都有自己的具体tokenizer以实现subword策略

transformers例程:

1
2
3
4
5
6
7
8
from transformers import AutoTokenizer, AutoModelForSequenceClassification

checkpoint = "distilbert-base-uncased-finetuned-sst-2-english"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
model = AutoModelForSequenceClassification.from_pretrained(checkpoint)
sequences = ["I've been waiting for a HuggingFace course my whole life.", "So have I!"]

tokens = tokenizer(sequences, padding=True, truncation=True, return_tensors="pt")

token此时为一个字典,包括了编码和注意力掩码(指padding mask)

具体实现

编码分为两步,即分词+转化为ID

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#仅分词
tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")
sequence = "Using a Transformer network is simple"
tokens = tokenizer.tokenize(sequence)
>>>['Using', 'a', 'transform', '##er', 'network', 'is', 'simple']

#将分词转为ID
ids = tokenizer.convert_tokens_to_ids(tokens)
print(ids)
>>>[7993, 170, 11303, 1200, 2443, 1110, 3014]

#解码
decoded_string = tokenizer.decode([7993, 170, 11303, 1200, 2443, 1110, 3014])
print(decoded_string)
>>>'Using a Transformer network is simple'

对于不同的模型,其还要求了其他的额外输入,因此其tokenizer也具有除编码外的其他功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 将句子序列填充到最长句子的长度
model_inputs = tokenizer(sequences, padding="longest")

# 将句子序列填充到模型的最大长度
# (512 for BERT or DistilBERT)
model_inputs = tokenizer(sequences, padding="max_length")

# 将句子序列填充到指定的最大长度
model_inputs = tokenizer(sequences, padding="max_length", max_length=8)

# 将截断比模型最大长度长的句子序列
# (512 for BERT or DistilBERT)
model_inputs = tokenizer(sequences, truncation=True)

# 将截断长于指定最大长度的句子序列
model_inputs = tokenizer(sequences, max_length=8, truncation=True)

# 返回 PyTorch tensors
model_inputs = tokenizer(sequences, padding=True, return_tensors="pt")

# 返回 TensorFlow tensors
model_inputs = tokenizer(sequences, padding=True, return_tensors="tf")

# 返回 NumPy arrays
model_inputs = tokenizer(sequences, padding=True, return_tensors="np")

model

创建模型

1
2
3
4
from transformers import BertConfig, BertModel

config = BertConfig()
model = BertModel(config)

加载预训练权重

1
2
3
4
5
model = BertModel.from_pretrained("bert-base-cased")

#或是直接通过AutoModel类
checkpoint = "bert-base-cased"
model = AutoModel.from_pretrained(checkpoint)

需要注意的是,此处的模型输入需参考各模型具体要求,一般为一个字典,例如:

1
2
3
4
5
{'input_ids': tensor([[  101,  1045,  1005,  2310,  2042,  3403,  2005,  1037, 17662, 			12172,     2607,  2026,  2878,  2166,  1012,   102],
[ 101, 2061, 2031, 1045, 999, 102, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0]]),
'attention_mask': tensor([[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]])}

这些可以被模型对应的tokenizer直接生成,仅需:

1
2
3
4
5
6
model(**tokens) 
"""
python的字典解包机制,等同于model(
input_ids=tensor(...),
attention_mask=tensor(...)
"""

dataset

1
2
3
4
5
6
from datasets import load_dataset

raw_datasets = load_dataset("glue", "mrpc")
raw_train_dataset = raw_datasets["train"]
sentence1 = raw_train_dataset['sentence1']
sample = raw_train_dataset[0]

Datadict

该函数会返回一个DatasetDict数据结构,这是一个类似字典的结构

对其进行索引可以得到单个数据集结构Dataset

Dataset

对于Dataset结构,也就是raw_train_dataset,其存储方式是列式存储,对于本代码,其每一列为:

1
'sentence1', 'sentence2', 'label', 'idx'

对这些键名进行索引即可返回单个Column结构

也可以对其进行行索引,则会返回单个样本的每列信息(一个标准字典):

其中,对于本代码来说,label则是代表sentence1和sentence2是否同义

返回token的dataset

1
2
3
4
5
6
7
def tokenize_function(examples):
return tokenizer(
examples["sentence1"],
examples["sentence2"],
truncation=True
)
tokenized_datasets = raw_datasets.map(tokenize_function, batched=True)

.map方法可以将每个文本经过tokenization后的结果添加到原本的Datadict中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
DatasetDict({
train: Dataset({
features: ['attention_mask', 'idx', 'input_ids', 'label', 'sentence1', 'sentence2', 'token_type_ids'],
num_rows: 3668
})
validation: Dataset({
features: ['attention_mask', 'idx', 'input_ids', 'label', 'sentence1', 'sentence2', 'token_type_ids'],
num_rows: 408
})
test: Dataset({
features: ['attention_mask', 'idx', 'input_ids', 'label', 'sentence1', 'sentence2', 'token_type_ids'],
num_rows: 1725
})
})

当不需要其中的某些features时,可以:

1
2
samples = tokenized_datasets["train"][:]
samples = {k: v for k, v in samples.items() if k not in ["idx", "sentence1", "sentence2"]}

或者也可以调用Datasetdict的方法:

1
tokenized_datasets = tokenized_datasets.remove_columns(["sentence1", "sentence2", "idx"])

动态填充

在训练 LLM时,只需要对每个 batch 进行动态 padding(因为模型的输入必须是规则的),而不是对整个数据集进行统一 padding,因为这样会大量浪费计算资源。

1
2
3
4
5
6
from transformers import DataCollatorWithPadding

data_collator = DataCollatorWithPadding(tokenizer=tokenizer)
samples = tokenized_datasets["train"][:8]
samples = {k: v for k, v in samples.items() if k not in ["idx", "sentence1", "sentence2"]}
batch = data_collator(samples)

其中batch则是n个样本(代码中为8)所对应的经过tokenizaiton后的samples,并且其中的id全部都被padding至8个样本中最长文本id的长度(batch.item()和samples的数据结构是相同的,都是一个标准字典,仅有id发生了padding)

模型微调

调用Trainer API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from datasets import load_dataset
from transformers import AutoTokenizer, DataCollatorWithPadding, AutoModelForSequenceClassification
from transformers import TrainingArguments, Trainer


raw_datasets = load_dataset("glue", "mrpc")
checkpoint = "bert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)


def tokenize_function(example):
return tokenizer(example["sentence1"], example["sentence2"], truncation=True)


tokenized_datasets = raw_datasets.map(tokenize_function, batched=True)
data_collator = DataCollatorWithPadding(tokenizer=tokenizer)
training_args = TrainingArguments("test-trainer")
model = AutoModelForSequenceClassification.from_pretrained(checkpoint, num_labels=2)

trainer = Trainer(
model,
training_args,
train_dataset=tokenized_datasets["train"],
eval_dataset=tokenized_datasets["validation"],
data_collator=data_collator,
tokenizer=tokenizer,
)

trainer.train()

在使用AutoModelForSequenceClassification实例化模型时,会收到一个警告,这是因为 BERT 没有在句子对分类方面进行过预训练,所以预训练模型的 head 已经被丢弃,而是添加了一个适合句子序列分类的新头部。这些警告表明一些权重没有使用(对应于被放弃的预训练头的权重),而有些权重被随机初始化(对应于新 head 的权重)。

评估

对于模型的输出:

1
predictions = trainer.predict(tokenized_datasets["validation"])

其会返回一个元组:(predictions,label_ids,metrics);其中predictions为一个形状为(batchsize,cls_num)的二维张量,第二维度为每个样本的logits,其中的最大值则为预测结果;label_ids为样本真实标签,metrics为自定义评估指标,默认只返回loss

因此我们可以定义一个评估函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def compute_metrics(eval_preds):
metric = evaluate.load("glue", "mrpc")
logits, labels = eval_preds
predictions = np.argmax(logits, axis=-1)
return metric.compute(predictions=predictions, references=labels)

trainer = Trainer(
model,
training_args,
train_dataset=tokenized_datasets["train"],
eval_dataset=tokenized_datasets["validation"],
data_collator=data_collator,
tokenizer=tokenizer,
compute_metrics=compute_metrics,
)

其中.compute方法会返回准确率与f1分数

不使用Trainer

使用torch的Dataloader加载数据集

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
tokenized_datasets = raw_datasets.map(tokenize_function, batched=True)
data_collator = DataCollatorWithPadding(tokenizer=tokenizer)

#删去不需要列,将label改名为labels(模型默认输入格式)
tokenized_datasets = tokenized_datasets.remove_columns(["sentence1", "sentence2", "idx"])
tokenized_datasets = tokenized_datasets.rename_column("label", "labels")
tokenized_datasets.set_format("torch")
#>>>["attention_mask", "input_ids", "labels", "token_type_ids"]

from torch.utils.data import DataLoader
#Dataloader兼容transformers的DataCollatorWithPadding
train_dataloader = DataLoader(
tokenized_datasets["train"], shuffle=True, batch_size=8, collate_fn=data_collator
)
eval_dataloader = DataLoader(
tokenized_datasets["validation"], batch_size=8, collate_fn=data_collator
)

此时dataloader返回的每个batch为:

1
2
3
4
{'attention_mask': torch.Size([8, 65]),
'input_ids': torch.Size([8, 65]),
'labels': torch.Size([8]),
'token_type_ids': torch.Size([8, 65])}

该batch可以直接输入到AutoModelForSequenceClassification实例化的模型中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
from transformers import get_scheduler
import torch

optimizer = AdamW(model.parameters(), lr=5e-5)
num_epochs = 3
num_training_steps = num_epochs * len(train_dataloader)
lr_scheduler = get_scheduler( #学习率线性衰减
"linear",
optimizer=optimizer,
num_warmup_steps=0,
num_training_steps=num_training_steps,
)
model.to(device)

for epoch in range(num_epoch):
model.train()
for batch in train_dataloader:
batch = {k: v.to(device) for k, v in batch.items()}
outputs = model(**batch)
loss = outputs.loss
loss.backward()

optimizer.step()
lr_scheduler.step()
optimizer.zero_grad()

model.eval()
for batch in eval_loader:
batch = {k: v.to(device) for k, v in batch.items()}
with torch.no_grad():
outputs = model(**batch)
logits = outputs.logits
...

PID

原理

PID控制算法是一种线性控制器,它根据给定值(目标值)和实际输出值之间的偏差,利用比例(Proportional)、积分(Integral)、微分(Derivative)三种控制方式的组合来调整控制量,从而实现对被控对象的精确控制。

比例控制

根据偏差的大小,线性调整控制量。偏差越大,控制作用越强。

  • 优点:响应速度快,能快速对偏差做出反应。
  • 缺点:比例项较大时,会出现震荡,较小时会出现静态误差

积分控制

根据偏差的积分来调整控制量。它会累积过去的偏差,随着时间的推移,即使偏差很小,积分项也会不断积累,从而推动系统消除偏差

  • 优点:能够消除稳态误差(静差),保证系统的精度。
  • 缺点:积分作用较强时,容易导致系统超调,甚至使系统不稳定。

微分控制

是根据偏差的变化率来调整控制量。它能够预测偏差的变化趋势,提前做出调整。

  • 优点:可以抑制偏差的变化,减少系统的超调,提高系统的稳定性。
  • 缺点:对噪声比较敏感。如果测量的偏差信号中存在噪声,微分项会放大噪声的影响,导致控制量出现不必要的波动。微分项较大时也会出现震荡

常见组合

PI控制

  • 优点:将比列调节的快速反应与积分调节消除静差的特点结合;

  • 缺点:不对未来控制误差进行预测,限制了控制性能。

适用于控制通道滞后较小、负荷变化不大、 工艺参数不允许有静差的系统。

PD控制

  • 优点:对惯性较大对象,可改善控制质量,减小偏差,缩短控制时间。有利于提高系统响应速度,抑制动态偏差
  • 缺点:抗干扰能力差,一般只能应用于被调参数 变化平稳的生产过程;微分作用太强时,容易造成系统振荡

位置式与增量式

位置式输出:

增量式输出:

位置式PID控制的输出与整个过去的状态有关,用到了误差的累加值;而增量式PID的输出只与当前拍和前两拍的误差有关,因此位置式PID控制的累积误差相对更大;

由于输出为增量形式,可以减小控制器对控制量的冲击,使得系统更加平稳。

抗积分饱和

PID控制器的输出由于积分作用的不断累加而扩大,从而导致控制器输出不断增大超出正常范围进入饱和区。为了防止这种情况,需要在某些情况下限制积分的增长:

  • 积分分离:设置一个误差阈值,只有当误差较小(系统输出接近期望)时,才加入积分控制,平时则使用PD控制
  • 积分限幅:设置一个输出阈值范围,计算U(k)的时候,先判断上一时刻的控制量U(k-1)是否已经超出了限制范围。若U(k-1)>Umax,则只累加负偏差;若U(k-1)<Umin,则只累加正偏差。

串级PID

以电机控制为例,在很多时候,我们的期望是位置(电机转角),但我们最终对电机的控制量都是电流(力矩,或者说是加速度),因此在使用单位置PID时,该控制是间接的,也就是说在该情况下,速度和加速度的变化都是未知的,也就是不平滑的;最终会表现为电机经过了目标位置,然后出现持续震荡,因为仅仅使用位置pid是难以让速度和加速度都达到期望的)

因此,我们引入串级PID:(由外到内)

  • 位置环:根据上层规划算法给出的期望位置进行PID,输出期望速度
  • 速度环:根据位置环得到的期望速度进行PID,输出期望转矩/加速度/电流
  • 电流环:根据速度环输出进行PID,最终控制电机转矩

需要注意的是,位置-速度-电流这个控制顺序是我们主动给定的,给定的理由是这三者两两间存在着较为简单的映射关系,即微积分关系,对于加速度啊转矩啊电流啊,根据牛二、欧拉和电机参数都可以得到明确的对应关系,因此在最内环的控制中,我们可以将他们看作同一控制量,一般用电流的原因可能只是实际电流值要更好测量一点?

在机器人中

由于我们已经可以通过一些轨迹规划算法(模型)平滑地得到关节电机的期望角度、角速度、角加速度,再结合动力学模型便可得到期望力矩,因此在机器人控制中,上述的前馈控制已经完成了绝大部分的控制工作,在理想条件下,前馈足矣

但如果机器人在运行过程中受到了干扰呢,前馈控制给定了力矩是在最开始确定的,无法应对额外干扰,因此需要反馈控制来减弱干扰,提高系统稳定性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
joint_states = p.getJointStates(robotId,controllable_joints)
q_actual = np.array([state[0] for state in joint_states])
qd_actual = np.array([state[1] for state in joint_states])


q_e = q[n] - q_actual
qd_e = qd[n] - qd_actual


aq = qdd[n] + 400 * q_e + 40 * qd_e

tau = p.calculateInverseDynamics(robotId,list(q_actual),list(qd_actual),list(aq))
p.setJointMotorControlArray(robotId,controllable_joints,p.TORQUE_CONTROL,
forces = tau,
)

这里仅对位置环和速度环进行了比例控制,因为在实际中在外环部分添加积分(I)后,导致响应变慢,添加微分(D)容易引入噪声;而由于我们已经有期望位置、速度,因此也不需要像传统串级pid一样进行串联位置环与速度环,而是将两环同时作用于力矩控制

PID参数调节经验

  • KP:先设Ki=Kd=0,调Kp至系统出现轻微振荡;取此时Kp值的50%作为初始值
  • KI:逐步增加Ki至静差在3~5个控制周期内消除
  • KD:从Kp值的10%~20%开始调整

LQR

线性二次型调节器(Linear Quadratic Regulator),其中,线性指线性系统,二次型指目标函数

系统模型构建

首先,LQR作用于线性系统(对于非线性系统,需要先线性化),对于上图系统,其系统模型为

其中,x为当前状态的集合,对于倒立摆,其可以为{位置,速度,角度,角速度};u为我们的控制输入,A和B为系统决定的参数,系统模型建立了x(当前状态) 和 x_dot(状态随时间的变化趋势) 之间的关系

现在我们要设计一个状态反馈u=-Kx,带入系统方程中则有:

由于A和B在LQR设计阶段是已知矩阵,因此我们只需得到一个合适的增益矩阵K,即可实现期望控制

LQR控制目标

相比于PID的尽可能减小误差,LQR定义了一种代价函数,通过让该能量函数最小以实现最优控制:

其中,Q(状态权重矩阵)和R(控制输入权重矩阵)即是我们的期望闭环性能(也就是要调的参数),即当控制目标确定时,我们想要用最小的控制代价u得到最优的控制。通常,Q越大,系统相应越快;R越大,控制力惩罚越大(即力作用越平稳温柔)

现在,我们已经确定了A,B,Q,R,如何根据“代价函数最小”这个目标得到K呢?

线性二次型调节器(LQR)原理详解-CSDN博客

在假设系统稳定的情况下,我们可以通过一个riccati里卡提方程由“J最小”这个条件得到一个满足条件的辅助矩阵P,再由P得到K,对于求解里卡提方程,matlab,scipy都提供了对应函数

Q和R的选择

Q:半正定矩阵,通常选用对角矩阵,对角线元素表示各状态的权重(因此形状应为nxn,n为状态数量),因此需根据各状态的重要性分配权重;重点关注的状态应分配较大权重;如果系统响应较慢也可增加Q

R:正定矩阵,通常选用对角矩阵,对角线元素表示各控制输入的权重(因此形状应为mxm,m为控制量);初始应选择较小值;如果控制输入幅度较大则增加R,如果系统响应较慢则减小R

倒立摆中的LQR

建立动力学方程

对于每个广义坐标q_i,拉格朗日方程为:

其中,Q_i是广义坐标q_i对应广义力。在倒立摆中,q={x,θ},则Q={F,0}(因为没有非保守力直接对摆杆做功)

则分别将q_i替换为x和θ后则可得到倒立摆的动力学模型(方程组):

线性化

由于现在的动力学方程是非线性的,因此我们需要先将其线性化。而在倒立摆中,平衡点即为θ=0(cosθ=0,sinθ=θ),则线性化后有:

将其转换为线性系统标准模型:

则有:

其中:

得到A,B后,我们令Q=diag(1,1,1,1),R=1(随便给的),即可通过riccati方程得到一个1x4矩阵K,最终得到我们的控制输入:

u即为当前状态下保持平衡应施加的力F(使系统稳定在期望状态)