一、任务目标
在之前的实战项目中,我们都是直接使用预训练模型,如Bert进行文本表示,进而再训练下游模型用于预测。尽管预训练模型具备强大的zero-shot能力和领域知识迁移能力,但在一些特定领域中,预训练模型的表现依旧是不足的。那么,为了让预训练模型能够更好地适应我们当前的任务,微调就成为了一个必要途径。这里,我们将展示如何进行预训练模型的微调,并给出详细的python建模框架解读。
二、微调流程
为了更加方便地对预训练模型进行微调,我们通过pytorch来搭建一个数据集类和模型类:
1、数据集类构建
为了提高torch训练模型的效率,我们往往会自定义一个dataset类,用于对原始数据的批量处理。在数据量小的时候可以直接写循环,但是为了规范我们的代码,且为了让程序具备处理大批量数据的能力,使用Dataset和DataLoader是必要的。
from torch.utils.data import Dataset
# 自定义数据集类,并继承Dataset
class taskDataset(Dataset):
def __init__(self, texts, labels, tokenizer, max_len):
# 数据和标签
self.texts = texts
self.labels = labels
# 分词器和文本最大长度
self.tokenizer = tokenizer
self.max_len = max_len
def __len__(self):
return len(self.texts)
def __getitem__(self,idx):
text = self.texts[idx]
label = self.labels[idx]
encoding = self.tokenizer.encode_plus(
text,
add_special_tokens=True,
max_length=self.max_len,
return_token_type_ids=False,
padding='max_length',
truncation=True,
return_attention_mask=True,
return_tensors='pt',
)
return {
'text': text,
'input_ids': encoding['input_ids'].flatten(),
'attention_mask': encoding['attention_mask'].flatten(),
'labels': torch.tensor(label, dtype=torch.long)
}
taskDataset类用于后续的DataLoader数据加载,DataLoader会根据我们在taskDataset中定义的方法批量处理数据并返回相应格式的结果。其中,taskDataset必须包含__init__,__len__和__getitem__方法。除了__len__方法直接返回数据长度之外,其余两个方法的输入和输出由我们自定义,只需要确保输出的结果能够输入到预训练模型中即可(即符合预训练模型输入要求)。
2、模型类构建
定义好数据集类之后,模型类也是必不可少的。模型类必须包括__init__和forward两个类方法,而类方法的参数输入和输出则由我们定义。一般而言,__init__方法中会定义好预训练模型层以及其后的附加层(对于不同的任务和模型结构,更改此处模型层定义即可),而forward方法则获取预训练模型必要的输入以及其他可选参数,并定义好输入数据的处理步骤,例如先经过bert层,再经过fc1,最后fc2等,输出一般是logits,即最后一层的神经元输出值(对应任务输出要求,例如这里是三分类,那么forward输出的结果应当是三个神值,代表属于对应类的概率)。
import torch.nn as nn
class BertMLPClassifier(nn.Module):
def __init__(self):
super(BertMLPClassifier, self).__init__()
self.bert = BertModel.from_pretrained('bert-base-uncased')
self.dropout = nn.Dropout(p=0.3)
self.fc1 = nn.Linear(self.bert.config.hidden_size, 256)
self.fc2 = nn.Linear(256, 3)
def forward(self, input_ids, attention_mask):
outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask)
pooled_output = outputs[1] # BERT的池化输出
x = self.dropout(pooled_output)
x = torch.relu(self.fc1(x))
x = self.dropout(x)
x = self.fc2(x)
return x
3、数据准备
完成了数据集类和模型类的定义之后,我们就可以开始准备数据了。首先读取数据集,并划分训练集和测试集,然后taskDataset对数据进行处理,最后调用DataLoader实现批量数据的输出。这里,我们使用的是kaggle的情感分类数据集:
from datasets import load_dataset
from torch.utils.data import DataLoader
from transformers import BertModel, BertTokenizer
import json
from sklearn.model_selection import train_test_split
# 数据集读取
df = pd.read_csv('./data/Emotion_classify_Data.csv')
label_idx = {'anger':0, 'joy':1, 'fear':2}
df['Emotion'] = df['Emotion'].apply(lambda x: label_idx[x])
# 数据划分
train_texts, val_texts, train_labels, val_labels = train_test_split(df['Comment'].tolist(), df['Emotion'].tolist(), stratify=df['Emotion'].tolist(), test_size=0.3, random_state=2024)
print(len(train_texts))
print(len(val_texts))
# 第一步:定义BERT tokenizer
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
# 第二步:创建自定义数据集
train_dataset = taskDataset(train_texts, train_labels, tokenizer, max_len=64)
val_dataset = taskDataset(val_texts, val_labels, tokenizer, max_len=64)
# 第三步:创建数据加载器
train_loader = DataLoader(train_dataset, batch_size=16, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=16, shuffle=False)
4、定义损失函数和优化器
这里,我们根据任务的需求定义好损失函数和优化器,以及各自的参数。
import torch
# 实例化模型
model = BertMLPClassifier()
# 将模型移动到GPU(如果可用)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = model.to(device)
# 定义优化器和损失函数,这里损失函数根据任务而定,多分类使用CrossEntropyLoss,二分类使用BCELoss,聚类使用MSELoss,在此处修改即可
optimizer = torch.optim.Adam(model.parameters(), lr=2e-5)
criterion = nn.CrossEntropyLoss()
5、设置微调策略
现在到了最关键的步骤,就是设置我们的微调策略。如果不进行这一步的设置,那么默认预训练模型的所有参数都会参与到微调中,这就意味着模型可能会丢失在预训练阶段学习到的大量知识(这是由于微调数据相比起预训练数据的量级差别巨大,如果全参数进行微调很容易会改变已经学习好的参数,从而降低模型表现)。
首先,我们可以把当前我们所构建的模型中,所有可训练的参数名称及shape打印出来,看看我们要让哪一些层参与微调训练(除非我们很熟悉这个模型了)。
# 打印模型中所有参数的名称、形状和是否需要梯度
for name, param in model.named_parameters():
print(f"Name: {name}")
print(f"Shape: {param.shape}")
print(f"Requires Grad: {param.requires_grad}\n")
选定层之后,通过设置更改requires_grad参数实现层的冻结与解冻,冻结之后的参数不参与梯度更新,也就是无法训练并保持原始值。这里我们选择解冻bert的最后一层的output子层组件,以及下游的全连接层。然后,我们打印出需要梯度更新的参数看看是否符合我们的设计。
# 冻结BERT模型的前几层参数
for name, param in model.bert.named_parameters():
if 'encoder.layer.11.out' not in name and 'fc' not in name: # 只训练Bert最后一层和全连接层
param.requires_grad = False
# 再次打印模型中需要梯度的参数
print("Model parameters after freezing:")
for name, param in model.named_parameters():
if param.requires_grad==True:
print(f"Name: {name}")
print(f"Shape: {param.shape}")
打印出来可以看到我们要训练的层以及对应的参数数量:

6、编写训练和评估函数
完成参数设置之后,我们编写一个训练的函数和测试的函数。下面的训练代码几乎是通用的,大体的流程就是:
(1)设置模型为可训练模式
(2)data_loader按设定的batch_size批量输出数据
(a)模型预测当前batch的数据得到结果
(b)计算当前batch的损失
(c)损失反向传播
(d)优化器状态更新
def train_epoch(model, data_loader, criterion, optimizer, device):
model.train()
total_loss = 0
# 批量遍历数据
for batch in data_loader:
input_ids = batch['input_ids'].to(device)
attention_mask = batch['attention_mask'].to(device)
labels = batch['labels'].to(device)
# print(labels.shape)
# 获取模型输出并计算损失
outputs = model(input_ids=input_ids, attention_mask=attention_mask)
loss = criterion(outputs, labels)
total_loss += loss.item()
# 优化器梯度清零
optimizer.zero_grad()
# 反向传播损失
loss.backward()
# 优化器更新
optimizer.step()
return total_loss / len(data_loader)
测试模型的代码同样是通用的,与训练代码基本相同,只不过这时候的模型是eval()模式,同时我们要使用torch.no_grad()禁用梯度计算,以避免多余的计算资源消耗:
def eval_model(model, data_loader, criterion, device):
model.eval()
total_loss = 0
correct_predictions = 0
with torch.no_grad():
for batch in data_loader:
input_ids = batch['input_ids'].to(device)
attention_mask = batch['attention_mask'].to(device)
labels = batch['labels'].to(device)
outputs = model(input_ids=input_ids, attention_mask=attention_mask)
loss = criterion(outputs, labels)
total_loss += loss.item()
_, preds = torch.max(outputs, dim=1)
correct_predictions += torch.sum(preds == labels)
return total_loss / len(data_loader), correct_predictions.double() / len(data_loader.dataset)
7、训练模型
迭代训练模型num_epochs次,完成模型的微调,后续可存储微调后的模型以便复用。
num_epochs = 5
for epoch in range(num_epochs):
train_loss = train_epoch(model, train_loader, criterion, optimizer, device)
val_loss, val_acc = eval_model(model, val_loader, criterion, device)
print(f"Epoch {epoch+1}/{num_epochs}")
print(f"Train loss: {train_loss:.4f}")
print(f"Val loss: {val_loss:.4f}, Val accuracy: {val_acc:.4f}")
8、预测并获取输出
最后,我们写一个函数获取模型预测结果:
def get_predictions(model, texts, tokenizer, max_length, device):
model.eval()
inputs = tokenizer(texts, return_tensors="pt", max_length=max_length, truncation=True, padding="max_length")
# 将输入数据移动到GPU(如果可用)
input_ids = inputs["input_ids"].to(device)
attention_mask = inputs["attention_mask"].to(device)
# 禁用梯度计算(在评估模式下不需要计算梯度)
with torch.no_grad():
# 前向传播,获取输出
outputs = model(input_ids, attention_mask)
# 获取预测标签
predictions = torch.argmax(outputs, dim=1)
return predictions.cpu().numpy()
三、完整代码
from torch.utils.data import Dataset, DataLoader
from transformers import BertModel, BertTokenizer
import torch.nn as nn
import torch
import json
from sklearn.model_selection import train_test_split
# 自定义数据集类,并继承Dataset
# 自定义数据集类,并继承Dataset
class taskDataset(Dataset):
def __init__(self, texts, labels, tokenizer, max_len):
# 数据和标签
self.texts = texts
self.labels = labels
# 分词器和文本最大长度
self.tokenizer = tokenizer
self.max_len = max_len
def __len__(self):
return len(self.texts)
def __getitem__(self,idx):
text = self.texts[idx]
label = self.labels[idx]
encoding = self.tokenizer.encode_plus(
text,
add_special_tokens=True,
max_length=self.max_len,
return_token_type_ids=False,
padding='max_length',
truncation=True,
return_attention_mask=True,
return_tensors='pt',
)
return {
'text': text,
'input_ids': encoding['input_ids'].flatten(),
'attention_mask': encoding['attention_mask'].flatten(),
'labels': torch.tensor(label, dtype=torch.long)
}
class BertMLPClassifier(nn.Module):
def __init__(self):
super(BertMLPClassifier, self).__init__()
self.bert = BertModel.from_pretrained('bert-base-uncased')
self.dropout = nn.Dropout(p=0.3)
self.fc1 = nn.Linear(self.bert.config.hidden_size, 256)
self.fc2 = nn.Linear(256, 3)
def forward(self, input_ids, attention_mask):
outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask)
pooled_output = outputs[1] # BERT的池化输出
x = self.dropout(pooled_output)
x = torch.relu(self.fc1(x))
x = self.dropout(x)
x = self.fc2(x)
return x
# 数据集读取
df = pd.read_csv('./data/Emotion_classify_Data.csv')
label_idx = {'anger':0, 'joy':1, 'fear':2}
df['Emotion'] = df['Emotion'].apply(lambda x: label_idx[x])
# 数据划分
train_texts, val_texts, train_labels, val_labels = train_test_split(df['Comment'].tolist(), df['Emotion'].tolist(), stratify=df['Emotion'].tolist(), test_size=0.3, random_state=2024)
print(len(train_texts))
print(len(val_texts))
# 定义BERT tokenizer
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
# 创建自定义数据集
train_dataset = taskDataset(train_texts, train_labels, tokenizer, max_len=64)
val_dataset = taskDataset(val_texts, val_labels, tokenizer, max_len=64)
# 创建数据加载器
train_loader = DataLoader(train_dataset, batch_size=16, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=16, shuffle=False)
model = BertMLPClassifier(num_classes=2)
# 将模型移动到GPU(如果可用)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = model.to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=2e-5)
criterion = nn.CrossEntropyLoss()
# 打印模型中所有参数的名称、形状和是否需要梯度
for name, param in model.named_parameters():
print(f"Name: {name}")
print(f"Shape: {param.shape}")
print(f"Requires Grad: {param.requires_grad}\n")
# 冻结BERT模型的前几层参数
for name, param in model.bert.named_parameters():
if 'encoder.layer.11.out' not in name and 'fc' not in name: # 只训练Bert最后一层和全连接层
param.requires_grad = False
# 再次打印模型中需要梯度的参数
print("Model parameters after freezing:")
for name, param in model.named_parameters():
if param.requires_grad==True:
print(f"Name: {name}")
print(f"Shape: {param.shape}")
def train_epoch(model, data_loader, criterion, optimizer, device):
model.train()
total_loss = 0
for batch in data_loader:
input_ids = batch['input_ids'].to(device)
attention_mask = batch['attention_mask'].to(device)
labels = batch['labels'].to(device)
# print(labels.shape)
outputs = model(input_ids=input_ids, attention_mask=attention_mask)
loss = criterion(outputs, labels)
total_loss += loss.item()
optimizer.zero_grad()
loss.backward()
optimizer.step()
return total_loss / len(data_loader)
def eval_model(model, data_loader, criterion, device):
model.eval()
total_loss = 0
correct_predictions = 0
with torch.no_grad():
for batch in data_loader:
input_ids = batch['input_ids'].to(device)
attention_mask = batch['attention_mask'].to(device)
labels = batch['labels'].to(device)
outputs = model(input_ids=input_ids, attention_mask=attention_mask)
loss = criterion(outputs, labels)
total_loss += loss.item()
_, preds = torch.max(outputs, dim=1)
correct_predictions += torch.sum(preds == labels)
return total_loss / len(data_loader), correct_predictions.double() / len(data_loader.dataset)
num_epochs = 5
for epoch in range(num_epochs):
train_loss = train_epoch(model, train_loader, criterion, optimizer, device)
val_loss, val_acc = eval_model(model, val_loader, criterion, device)
print(f"Epoch {epoch+1}/{num_epochs}")
print(f"Train loss: {train_loss:.4f}")
print(f"Val loss: {val_loss:.4f}, Val accuracy: {val_acc:.4f}")
def get_predictions(model, texts, tokenizer, max_length, device):
model.eval()
inputs = tokenizer(texts, return_tensors="pt", max_length=max_length, truncation=True, padding="max_length")
# 将输入数据移动到GPU(如果可用)
input_ids = inputs["input_ids"].to(device)
attention_mask = inputs["attention_mask"].to(device)
# 禁用梯度计算(在评估模式下不需要计算梯度)
with torch.no_grad():
# 前向传播,获取输出
outputs = model(input_ids, attention_mask)
# 获取预测标签
predictions = torch.argmax(outputs, dim=1)
return predictions.cpu().numpy()
preds = get_predictions(model, val_texts, tokenizer, 64, device)
四、总结
从流程上来看,几乎所有预训练模型的微调流程都离不开上面所述的各个步骤,这是我们所必须要掌握的内容。理想的情况是我们能够轻松地手撕数据集类、模型类的构造代码,以及模型的训练和测试代码。只要记住了这套代码框架,不论遇到什么任务,对模型结构和损失函数稍加修改即可快速建模了。
当然,实际业务应用过程中,还有许多细节需要去雕刻,比如为了使得微调后的预训练模型取得更好的效果,我们可以先训练下游层的参数,然后再返回来解冻预训练模型的后面一到两层,与下游层共同进行新一轮的训练。这么做的理由是下游层的参数一般是随机初始化的,直接解冻预训练模型的后面几层进行梯度更新,可能在一开始会造成预训练模型后面可训练参数失效。如果先训练好下游层的参数,在下游层具备一定的性能基础之后对预训练模型进行微调,得到的模型将更加稳定可靠。此外,解冻最后的一层还是两层,还是某一层中的一部分参数,更多地需要凭我们的经验以及训练的效果来确定,所以我们往往需要分别使用不同的参数训练策略进行模型训练,最后选出最优方案。

2357

被折叠的 条评论
为什么被折叠?



