1 引言
各位朋友大家好,欢迎来到月来客栈,我是掌柜空字符。
1 引言2 数据预处理2.1 输入介绍2.2 语料介绍2.3 数据集预览2.4 数据集构建3 加载预训练模型3.1 查看模型参数3.2 载入模型配置3.3 载入并初始化4 文本分类4.1 工程结构4.2 文本分类前向传播4.3 模型训练5 总结引用
经过前面两篇文章[1] 和 [2]的介绍,相信大家对于BERT的原理以及实现过程已经有了比较清晰的理解。同时,我们都知道BERT是一个强大的预训练模型,它可以基于谷歌发布的预训练参数在各个下游任务中进行微调。因此,在本篇文章中,掌柜将会介绍第一个下游微调场景,即如何在文本分类场景中基于BERT预训练模型进行微调。
总的来说,基于BERT的文本分类模型就是在原始的BERT模型后再加上一个分类层即可,类似的结构掌柜在文章This post is all you need(基于Transformer的分类模型)[7]中也介绍过,大家可以去看一下。同时,对于分类层的输入(也就是原始BERT的输出),默认情况下取BERT输出结果中[CLS]
位置对于的向量即可,当然也可以修改为其它方式,例如所有位置向量的均值等。因此,对于基于BERT的文本分类模型来说其输入就是BERT的输入,输出则是每个类别对应的logits值。接下来,掌柜首先就来介绍如何构造文本分类的数据集。
以下所有完整示例代码均可从仓库 https://github.com/moon-hotel/BertWithPretrained 中获取!
2 数据预处理
2.1 输入介绍
在构建数据集之前,我们首先需要知道的是模型到底应该接收什么样的输入,然后才能构建出正确的数据形式。在上面我们说到,基于BERT的文本分类模型的输入就等价于BERT模型的输入,同时根据文章[2]的介绍可以知道BERT模型的输入如图1所示:
由于对于文本分类这个场景来说其输入只有一个序列,所以在构建数据集的时候并不需要构造Segment Embedding的输入,直接默认使用全为0即可(可见文章[9] 第2.2.4节);同时,对于Position Embedding来说在任何场景下都不需要对其指定输入,因为我们在代码实现时已经做了相应默认时的处理(同样见文章[9] 第2.2.4节)。
因此,对于文本分类这个场景来说,只需要构造原始文本对应的Token序列,并在首位分别再加上一个[CLS]
符和[SEP]
符作为输入即可。
2.2 语料介绍
在这里,我们使用到的数据集是今日头条开放的一个新闻分类数据集[3],一共包含有382688条数据,15个类别。同时掌柜已近将其进行了格式化处理,以7:2:1的比例划分成了训练集、验证集和测试集三个部分。如下所示便是部分示例数据:
xxxxxxxxxx
41千万不要乱申请网贷,否则后果很严重_!_4
210年前的今天,纪念5.12汶川大地震10周年_!_11
3怎么看待杨毅在一NBA直播比赛中说詹姆斯的球场统治力已经超过乔丹、伯德和科比?_!_3
4戴安娜王妃的车祸有什么谜团?_!_2
其中_!_
左边为新闻标题,也就是后面需要用到的分类文本,右边为类别标签。
后台回复“数据集”即可获取网盘链接!
2.3 数据集预览
同样,在正式介绍如何构建数据集之前我们先通过一张图来了解一下整个构建的流程,以便做到心中有数,不会迷路。假如我们现在有两个样本构成了一个batch,那么其整个数据的处理过程则如图2所示。
如图2所示,第1步需要将原始的数据样本进行分字(tokenize)处理;第2步再根据tokenize后的结果构造一个字典,不过在使用BERT预训练时并不需要我们自己来构造这个字典,直接载入谷歌开源的vocab.txt
文件构造字典即可,因为只有vocab.txt
中每个字的索引顺序才与开源模型中每个字的Embedding向量一一对应的。第3步则是根据字典将tokenize后的文本序列转换为Token序列,同时在Token序列的首尾分别加上[CLS]
和[SEP]
符号,并进行Padding。第4步则是根据第3步处理后的结果生成对应的Padding Mask向量。
最后,在模型训练时只需要将第3步和第4步处理后的结果一起喂给模型即可。
2.4 数据集构建
第1步:定义tokenize
第1步需要完成的就是将输入进来的文本序列tokenize到字符级别。对于中文语料来说就是将每个字和标点符号都给切分开。在这里,我们可以借用transformers
包中的BertTokenizer
方法来完成,如下所示:
xxxxxxxxxx
81if __name__ == '__main__':
2 model_config = ModelConfig()
3 tokenizer = BertTokenizer.from_pretrained(model_config.pretrained_model_dir).tokenize
4 print(tokenizer("青山不改,绿水长流,我们月来客栈见!"))
5 print(tokenizer("10年前的今天,纪念5.12汶川大地震10周年"))
6
7# ['青', '山', '不', '改', ',', '绿', '水', '长', '流', ',', '我', '们', '月', '来', '客', '栈', '见', '!']
8# ['10', '年', '前', '的', '今', '天', ',', '纪', '念', '5', '.', '12', '汶', '川', '大', '地', '震', '10', '周', '年']
在上述代码中,第2-3行就是根据指定的路径(BERT预训练模型的路径)来载入一个分字模型;第7-8行便是tokenize后的结果。
第2步:建立词表
由于BERT预训练模型中已经有了一个给定的词表(vocab.txt
),因此我们并不需要根据自己的语料来建立一个词表。当然,也不能够根据自己的语料来建立词表,因为相同的字在我们自己构建的词表中和vocab.txt
中的索引顺序肯定会不一样,而这就会导致后面根据token id 取出来的向量是错误的。
进一步,我们只需要将vocab.txt
中的内容读取进来形成一个词表即可,代码如下:
xxxxxxxxxx
161class Vocab:
2 UNK = '[UNK]'
3 def __init__(self, vocab_path):
4 self.stoi = {}
5 self.itos = []
6 with open(vocab_path, 'r', encoding='utf-8') as f:
7 for i, word in enumerate(f):
8 w = word.strip('\n')
9 self.stoi[w] = i
10 self.itos.append(w)
11
12 def __getitem__(self, token):
13 return self.stoi.get(token, self.stoi.get(Vocab.UNK))
14
15 def __len__(self):
16 return len(self.itos)
接着便可以定义一个方法来实例化一个词表:
xxxxxxxxxx
51def build_vocab(vocab_path):
2 return Vocab(vocab_path)
3
4if __name__ == '__main__':
5 vocab = build_vocab()
在经过上述代码处理后,我们便能够通过vocab.itos
得到一个列表,返回词表中的每一个词;通过vocab.itos[2]
返回得到词表中对应索引位置上的词;通过vocab.stoi
得到一个字典,返回词表中每个词的索引;通过vocab.stoi['月']
返回得到词表中对应词的索引;通过len(vocab)
来返回词表的长度。如下便是建立后的词表:
xxxxxxxxxx
11{'[PAD]': 0, '[unused1]': 1, '[unused2]': 2, '[unused3]': 3, '[unused4]': 4, '[unused5]': 5, '[unused6]': 6, '[unused7]': 7, '[unused8]': 8, '[unused9]': 9, '[unused10]': 10, '[unused11]': 11, '[unused12]': 12, '[unused13]': 13, '[unused14]': 14, '[unused15]': 15, '[unused16]': 16, '[unused17]': 17, '[unused18]': 18, '[unused19]': 19, '[unused20]': 20, '[unused21]': 21, '[unused22]': 22, '[unused23]': 23, '[unused24]': 24, '[unused25]': 25, '[unused26]': 26, '[unused27]': 27, '[unused28]': 28, '[unused29]': 29, '[unused30]': 30, '[unused31]': 31, '[unused32]': 32, '[unused33]': 33, '[unused34]': 34, '[unused35]': 35, '[unused36]': 36, '[unused37]': 37, '[unused38]': 38, '[unused39]': 39, '[unused40]': 40, '[unused41]': 41, '[unused42]': 42, ....'乐': 727, '乒': 728, '乓': 729, '乔': 730, '乖': 731, '乗': 732, '乘': 733, '乙': 734, '乜': 735, '九': 736, '乞': 737, '也': 738, '习': 739, '乡': 740, '书': 741, '乩': 742, '买': 743, '乱': 744, '乳': 745, ....}
此时,我们就需要定义一个类,并在类的初始化过程中根据训练语料完成字典的构建等工作,代码如下:
xxxxxxxxxx
221class LoadSingleSentenceClassificationDataset:
2 def __init__(self,
3 vocab_path='./vocab.txt', #
4 tokenizer=None,
5 batch_size=32,
6 max_sen_len=None,
7 split_sep='\n',
8 max_position_embeddings=512,
9 pad_index=0,
10 is_sample_shuffle=True):
11 self.tokenizer = tokenizer
12 self.vocab = build_vocab(vocab_path)
13 self.PAD_IDX = pad_index
14 self.SEP_IDX = self.vocab['[SEP]']
15 self.CLS_IDX = self.vocab['[CLS]']
16 self.batch_size = batch_size
17 self.split_sep = split_sep
18 self.max_position_embeddings = max_position_embeddings
19 if isinstance(max_sen_len, int) and max_sen_len > max_position_embeddings:
20 max_sen_len = max_position_embeddings
21 self.max_sen_len = max_sen_len
22 self.is_sample_shuffle = is_sample_shuffle
在上述代码中,vocab_path
表示本地词表的路径。max_sen_len
表示最大样本长度,当max_sen_len = None
时,即以每个batch中最长样本长度为标准,对其它进行padding;当max_sen_len = 'same'
时,以整个数据集中最长样本为标准,对其它进行padding;当max_sen_len = 50
, 表示以某个固定长度符样本进行padding,多余的截掉。split_sep
表示样本与标签之间的分隔符。is_sample_shuffle
表示是否打乱数据集。第14-15行为建立词表并取对应特殊字符的索引;第18行中max_position_embeddings
为最大样本长度,最大为512。第19-21行则是用来判断传入的最大样本长度。
第3步:转换为Token序列
在得到构建的字典后,便可以通过如下函数来将训练集、验证集和测试集转换成Token序列:
xxxxxxxxxx
161 def data_process(self, filepath):
2 raw_iter = open(filepath, encoding="utf8").readlines()
3 data = []
4 max_len = 0
5 for raw in tqdm(raw_iter, ncols=80):
6 line = raw.rstrip("\n").split(self.split_sep)
7 s, l = line[0], line[1]
8 tmp = [self.CLS_IDX] + [self.vocab[token] for token in self.tokenizer(s)]
9 if len(tmp) > self.max_position_embeddings - 1:
10 tmp = tmp[:self.max_position_embeddings - 1] # BERT预训练模型只取前512个字符
11 tmp += [self.SEP_IDX]
12 tensor_ = torch.tensor(tmp, dtype=torch.long)
13 l = torch.tensor(int(l), dtype=torch.long)
14 max_len = max(max_len, tensor_.size(0))
15 data.append((tensor_, l))
16 return data, max_len
在上述代码中,第6-7行便是用来取得文本和标签;第8行则是首先对序列进行tokenize,然后转换成Token序列并在最前面加上分类标志位[CLS]
。第9-11行则是用来对Token序列进行截取,最长为max_position_embeddings
个字符即512,并同时在末尾加上[SEP]
符号。不过掌柜认为其实不加应该也不会有影响,因为这本来是单个序列的分类。第14行则是用来保存最长序列的长度。在处理完成后,2.2节中的4个样本将会被转换成如下形式:
xxxxxxxxxx
171tensor([[ 101, 1283, 674, 679, 6206, 744, 4509, 6435, 5381, 6587, 8024, 1415,
2 1156, 1400, 3362, 2523, 698, 7028, 102, 0, 0, 0, 0, 0,
3 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
4 0, 0, 0],
5 [ 101, 8108, 2399, 1184, 4638, 791, 2399, 8024, 5279, 2573, 126, 119,
6 8110, 3746, 2335, 1920, 1765, 7448, 8108, 1453, 2399, 102, 0, 0,
7 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
8 0, 0, 0],
9 [ 101, 2582, 720, 4692, 2521, 3342, 3675, 1762, 671, 8391, 4684, 3064,
10 3683, 6612, 704, 6432, 6285, 1990, 3172, 4638, 4413, 1767, 5320, 3780,
11 1213, 2347, 5307, 6631, 6814, 730, 710, 510, 843, 2548, 1469, 4906,
12 3683, 8043, 102],
13 [ 101, 2785, 2128, 2025, 4374, 1964, 4638, 6756, 4877, 3300, 784, 720,
14 6466, 1730, 8043, 102, 0, 0, 0, 0, 0, 0, 0, 0,
15 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
16 0, 0, 0]])
17torch.Size([39, 4])
从上面的输出结果可以看出,101就是[CLS]
在词表中的索引位置,102则是[SEP]
在词表中的索引;其它非0值就是tokenize后的文本序列转换成的Token序列。同时可以看出,这里的结果是以第3个样本的长度39对其它样本进行padding的,并且padding的Token ID为0。因此,下面我们就来介绍样本的padding处理。
第4步:padding处理与mask
从第3步的输出结果看出,在对原始文本序列tokenize转换为Token ID后还需要对其进行padding处理。对于这一处理过程可以通过如下代码来完成:
xxxxxxxxxx
151def pad_sequence(sequences, batch_first=False, max_len=None, padding_value=0):
2 if max_len is None:
3 max_len = max([s.size(0) for s in sequences])
4 out_tensors = []
5 for tensor in sequences:
6 if tensor.size(0) < max_len:
7 tensor = torch.cat([tensor, torch.tensor(
8 [padding_value] * (max_len - tensor.size(0)))], dim=0)
9 else:
10 tensor = tensor[:max_len]
11 out_tensors.append(tensor)
12 out_tensors = torch.stack(out_tensors, dim=1)
13 if batch_first:
14 return out_tensors.transpose(0, 1)
15 return out_tensors
在上述代码中,sequences
为待padding的序列所构成的列表,其中的每一个元素为一个样本的Token序列;batch_first
表示是否将batch_size
这个维度放在第1个;max_len
表示指定最大序列长度,当max_len = 50
时,表示以某个固定长度对样本进行padding多余的截掉,当max_len=None
时表示以当前batch中最长样本的长度对其它进行padding。第2-3行用来获取padding的长度;第5-11行则是遍历每一个Token序列,根据max_len
来进行padding。
xxxxxxxxxx
61if __name__ == '__main__':
2 a = torch.tensor([1, 2, 3])
3 b = torch.tensor([4, 5, 6, 7, 8])
4 c = torch.tensor([9, 10])
5 d = pad_sequence([a, b, c], max_len=None).size()
6 # torch.Size([5, 3])
进一步,我们需要定义一个函数来对每个batch的Token序列进行padding处理:
xxxxxxxxxx
111 def generate_batch(self, data_batch):
2 batch_sentence, batch_label = [], []
3 for (sen, label) in data_batch: # 开始对一个batch中的每一个样本进行处理。
4 batch_sentence.append(sen)
5 batch_label.append(label)
6 batch_sentence = pad_sequence(batch_sentence, # [batch_size,max_len]
7 padding_value=self.PAD_IDX,
8 batch_first=False,
9 max_len=self.max_sen_len)
10 batch_label = torch.tensor(batch_label, dtype=torch.long)
11 return batch_sentence, batch_label
上述代码的作用就是对每个batch的Token序列进行padding处理。
最后,对于每一序列的attention_mask
向量,我们只需要判断其是否等于padding_value
便可以得到这一结果,可见第5步中的使用示例。
第5步:构造DataLoade
与使用示例
经过前面4步的操作,整个数据集的构建就算是已经基本完成了,只需要再构造一个DataLoader
迭代器即可,代码如下:
xxxxxxxxxx
181 def load_train_val_test_data(self, train_file_path=None,
2 val_file_path=None,
3 test_file_path=None,
4 only_test=False):
5 test_data, _ = self.data_process(test_file_path)
6 test_iter = DataLoader(test_data, batch_size=self.batch_size,
7 shuffle=False, collate_fn=self.generate_batch)
8 if only_test:
9 return test_iter
10 train_data, max_sen_len = self.data_process(train_file_path) # 得到处理好的所有样本
11 if self.max_sen_len == 'same':
12 self.max_sen_len = max_sen_len
13 val_data, _ = self.data_process(val_file_path)
14 train_iter = DataLoader(train_data, batch_size=self.batch_size, # 构造DataLoader
15 shuffle=self.is_sample_shuffle, collate_fn=self.generate_batch)
16 val_iter = DataLoader(val_data, batch_size=self.batch_size,
17 shuffle=False, collate_fn=self.generate_batch)
18 return train_iter, test_iter, val_iter
在上述代码中,第5-7行用来得到预处理后的数据并构造对应的DataLoader,其中generate_batch
将作为一个参数传入来对每个batch的样本进行处理;第8-9行则判断是否只返回测试集;同理,第10-18行则是用来构造相应的训练集和验证集。在完成类LoadSingleSentenceClassificationDataset
所有的编码过程后,便可以通过如下形式进行使用:
xxxxxxxxxx
251from Tasks.TaskForSingleSentenceClassification import ModelConfig
2from utils.data_helpers import LoadSingleSentenceClassificationDataset
3from transformers import BertTokenizer
4if __name__ == '__main__':
5 model_config = ModelConfig()
6 load_dataset = LoadSingleSentenceClassificationDataset(
7 vocab_path=model_config.vocab_path,
8 tokenizer=BertTokenizer.from_pretrained(model_config.pretrained_model_dir).tokenize,
9 batch_size=model_config.batch_size,
10 max_sen_len=model_config.max_sen_len,
11 split_sep=model_config.split_sep,
12 max_position_embeddings=model_config.max_position_embeddings,
13 pad_index=model_config.pad_token_id,
14 is_sample_shuffle=model_config.is_sample_shuffle)
15 train_iter, test_iter, val_iter = \
16 load_dataset.load_train_val_test_data(model_config.train_file_path,
17 model_config.val_file_path,
18 model_config.test_file_path)
19 for sample, label in train_iter:
20 print(sample.shape) # [seq_len,batch_size]
21 print(sample.transpose(0, 1))
22 padding_mask = (sample == load_dataset.PAD_IDX).transpose(0, 1)
23 print(padding_mask)
24 print(label)
25 break
执行完上述代码后便可以得到如下所示的结果:
xxxxxxxxxx
141torch.Size([39, 4])
2tensor([[ 101, 1283, 674, 679, 6206, 744, 4509, 6435, 5381, 6587, 8024, 1415,
3 1156, 1400, 3362, 2523, 698, 7028, 102, 0, 0, 0, 0, 0,
4 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
5 0, 0, 0],
6 ...
7 ])
8tensor([[False, False, False, False, False, False, False, False, False, False,
9 False, False, False, False, False, False, False, False, False, True,
10 True, True, True, True, True, True, True, True, True, True,
11 True, True, True, True, True, True, True, True, True],
12 ...
13 ])
14tensor([ 4,...])
到此,对于整个数据集构建部分的内容就算是介绍完了,接下来我们再来看如何加载预训练模型进行微调。
3 加载预训练模型
在介绍模型微调之前,我们先来看看当我们拿到一个开源的模型参数后怎么读取以及分析。下面掌柜就以huggingface开源的PyTorch训练的bert-base-chinese模型参数[4]为例进行介绍。
3.1 查看模型参数
在前一篇文章[5]中,尽管掌柜已经大致介绍了如何通过PyTorch来读取和加载模型参数,但是这里仍旧有必要以bert-base-chinese
参数为例再进行一次详细的介绍。通常,对于一个通过PyTorch框架保存的模型参数,我们可以通过如下方式来进行载入:
xxxxxxxxxx
61import torch
2
3loaded_paras = torch.load('./pytorch_model.bin')
4print(type(loaded_paras))
5print(len(list(loaded_paras.keys())))
6print(list(loaded_paras.keys()))
执行完上述代码后,便可以得到如下输出结果:
xxxxxxxxxx
31<class 'collections.OrderedDict'>
2207
3['bert.embeddings.word_embeddings.weight', 'bert.embeddings.position_embeddings.weight', 'bert.embeddings.token_type_embeddings.weight', .....'bert.encoder.layer.11.output.dense.bias', 'bert.encoder.layer.11.output.LayerNorm.gamma', 'bert.encoder.layer.11.output.LayerNorm.beta',.....]
从上面的输出结果可以看到,参数pytorch_model.bin
被载入后变成了一个有序的字典OrderedDict
,并且其中一共有207个参数,其名字分别就是列表中的各个元素。进一步,我们还可以将各个参数的形状打印出来看一看:
xxxxxxxxxx
221for name in loaded_paras.keys():
2 print(f"### 参数名:{name},形状:{loaded_paras[name].size()}")
3
4### 参数名:bert.embeddings.word_embeddings.weight,形状:torch.Size([21128, 768])
5### 参数名:bert.embeddings.position_embeddings.weight,形状:torch.Size([512, 768])
6### 参数名:bert.embeddings.token_type_embeddings.weight,形状:torch.Size([2, 768])
7### 参数名:bert.embeddings.LayerNorm.gamma,形状:torch.Size([768])
8......
9### 参数名:bert.encoder.layer.11.output.dense.weight,形状:torch.Size([768, 3072])
10### 参数名:bert.encoder.layer.11.output.dense.bias,形状:torch.Size([768])
11### 参数名:bert.encoder.layer.11.output.LayerNorm.gamma,形状:torch.Size([768])
12### 参数名:bert.encoder.layer.11.output.LayerNorm.beta,形状:torch.Size([768])
13### 参数名:bert.pooler.dense.weight,形状:torch.Size([768, 768])
14### 参数名:bert.pooler.dense.bias,形状:torch.Size([768])
15### 参数名:cls.predictions.bias,形状:torch.Size([21128])
16### 参数名:cls.predictions.transform.dense.weight,形状:torch.Size([768, 768])
17### 参数名:cls.predictions.transform.dense.bias,形状:torch.Size([768])
18### 参数名:cls.predictions.transform.LayerNorm.gamma,形状:torch.Size([768])
19### 参数名:cls.predictions.transform.LayerNorm.beta,形状:torch.Size([768])
20### 参数名:cls.predictions.decoder.weight,形状:torch.Size([21128, 768])
21### 参数名:cls.seq_relationship.weight,形状:torch.Size([2, 768])
22### 参数名:cls.seq_relationship.bias,形状:torch.Size([2])
同样,我们还可以直接打印出某个参数具体的值。
到此,对于本地的模型参数我们就算是看明白了。不过想要将它迁移到自己所搭建的模型上还要进一步的来分析自己所搭建的模型。
3.2 载入模型配置
在继续往下介绍之前,我们先来看看如何载入本地的BERT配置参数config.json
这个文件。通常情况下我们都会定义一个配置类,然将这些参数从config.json
载入后来实例化这个配置类,代码如下所示:
xxxxxxxxxx
451class BertConfig(object):
2 """Configuration for `BertModel`."""
3
4 def __init__(self,
5 vocab_size=21128,
6 hidden_size=768,
7 num_hidden_layers=12,
8 num_attention_heads=12,
9 intermediate_size=3072,
10 pad_token_id=0,
11 hidden_act="gelu",
12 hidden_dropout_prob=0.1,
13 attention_probs_dropout_prob=0.1,
14 max_position_embeddings=512,
15 type_vocab_size=2,
16 initializer_range=0.02):
17 self.vocab_size = vocab_size
18 self.hidden_size = hidden_size
19 self.num_hidden_layers = num_hidden_layers
20 self.num_attention_heads = num_attention_heads
21 self.hidden_act = hidden_act
22 self.intermediate_size = intermediate_size
23 self.pad_token_id = pad_token_id
24 self.hidden_dropout_prob = hidden_dropout_prob
25 self.attention_probs_dropout_prob = attention_probs_dropout_prob
26 self.max_position_embeddings = max_position_embeddings
27 self.type_vocab_size = type_vocab_size
28 self.initializer_range = initializer_range
29
30
31 def from_dict(cls, json_object):
32 """Constructs a `BertConfig` from a Python dictionary of parameters."""
33 config = BertConfig(vocab_size=None)
34 for (key, value) in six.iteritems(json_object):
35 config.__dict__[key] = value
36 return config
37
38
39 def from_json_file(cls, json_file):
40 """Constructs a `BertConfig` from a json file of parameters."""
41 """从json配置文件读取配置信息"""
42 with open(json_file, 'r') as reader:
43 text = reader.read()
44 logging.info(f"成功导入BERT配置文件 {json_file}")
45 return cls.from_dict(json.loads(text))
在上述代码中,第5-29行是通过指定参数值来实例化BertConfig
这个类;第40行的from_json_file()
则是通过指定的路径来载入本地的config.json
配置文件(如下所示),其中的cls
表示未经实例化的BertConfig
类,是Python语法中@classmethod
的用法。
xxxxxxxxxx
101{
2 "attention_probs_dropout_prob": 0.1,
3 "directionality": "bidi",
4 "hidden_act": "gelu",
5 "hidden_dropout_prob": 0.1,
6 "hidden_size": 768,
7 "initializer_range": 0.02,
8 "intermediate_size": 3072
9 ......
10}
定义完成后便可以通过如下的方式来载入config.json
配置文件。
xxxxxxxxxx
21json_file = '../bert_base_chinese/config.json'
2config = BertConfig.from_json_file(json_file)
接下里便可以以访问类成员的方式config.hidden_size
来使用这些参数。
3.3 载入并初始化
在上一篇文章[2]中,掌柜已经详细地介绍了如何实现整个BERT模型,但是对于如何载入已有参数来初始化网络中的参数还并未介绍。在将本地参数迁移到一个新的模型之前,除了像2.1节那样分析本地参数之外,我们还需要将网络的参数信息也打印出来看一下,以便将两者一一对应上。
xxxxxxxxxx
71json_file = '../bert_base_chinese/config.json'
2config = BertConfig.from_json_file(json_file)
3bert_model = BertModel(config)
4print("\n ======= BertMolde 参数: ========")
5print(len(bert_model.state_dict()))
6for param_tensor in bert_model.state_dict():
7 print(param_tensor, "\t", bert_model.state_dict()[param_tensor].size())
在执行完上述代码后,便可以得到如下输出结果:
xxxxxxxxxx
131 ======= BertMolde 参数: ========
2200
3### bert_embeddings.position_ids torch.Size([1, 512])
4### bert_embeddings.word_embeddings.embedding.weight torch.Size([21128, 768])
5### bert_embeddings.position_embeddings.embedding.weight torch.Size([512, 768])
6### bert_embeddings.token_type_embeddings.embedding.weight torch.Size([2, 768])
7......
8### bert_encoder.bert_layers.11.bert_output.dense.weight torch.Size([768, 3072])
9### bert_encoder.bert_layers.11.bert_output.dense.bias torch.Size([768])
10### bert_encoder.bert_layers.11.bert_output.LayerNorm.weight torch.Size([768])
11### bert_encoder.bert_layers.11.bert_output.LayerNorm.bias torch.Size([768])
12### bert_pooler.dense.weight torch.Size([768, 768])
13### bert_pooler.dense.bias torch.Size([768])
从上面的输出结果可以发现,BertMolde
一共有200个参数,而bert-base-chinese
一共有207个参数。这里需要注意的是BertMolde
模型中的position_ids
这个参数并不是模型中需要训练的参数,只是一个默认的初始值。最后,经分析(两者一一进行对比)后发现bert-base-chinese
中除了最后的8个参数以外,其余的199个参数和BertMolde
模型中的199个参数一样且顺序也一样。
因此,最后我们可以通过在BertMolde
类(Bert.py
文件中)中再加入一个如下所示的函数来用bert-base-chinese
中的参数初始化BertMolde
中的参数:
xxxxxxxxxx
131
2 def from_pretrained(cls, config, pretrained_model_dir=None):
3 model = cls(config) # 初始化模型,cls为未实例化的对象,即一个未实例化的BertModel对象
4 pretrained_model_path = os.path.join(pretrained_model_dir, "pytorch_model.bin")
5 loaded_paras = torch.load(pretrained_model_path)
6 state_dict = deepcopy(model.state_dict())
7 loaded_paras_names = list(loaded_paras.keys())[:-8]
8 model_paras_names = list(state_dict.keys())[1:]
9 for i in range(len(loaded_paras_names)):
10 state_dict[model_paras_names[i]] = loaded_paras[loaded_paras_names[i]]
11 logging.info(f"成功将参数{loaded_paras_names[i]}赋值给{model_paras_names[i]}")
12 model.load_state_dict(state_dict)
13 return model
在上述代码中,第4-5行用来载入本地的bert-base-chinese
参数;第6行用来拷贝一份BertModel
中的网络参数,这是因为我们无法直接修改里面的值;第7-10行则是根据我们上面的分析,将bert-base-chinese
中的参数赋值到state_dict
中;第12行是用state_dict
中的参数来初始化BertModel
中的参数。
最后,我们只需要通过如下方式便可以返回一个通过bert-base-chinese
初始化的BERT模型:
xxxxxxxxxx
11bert = BertModel.from_pretrained(config, bert_pretrained_model_dir)
当然,如果你需要冻结其中某些层的参数不参与模型训练,那么可以通过类似如下所示的代码来进行设置:
xxxxxxxxxx
31for para in bert_model.parameters():
2 if xxxx:
3 para.requires_grad = False
到此,对于整个预训练模型的加载过程就介绍完了,接下来让我们正式进入到基于BERT预训练模型的文本分类场景中。
4 文本分类
4.1 工程结构
为了使得大家对于整个工程有着清晰的认识,掌柜这里先来介绍一下整个项目的目录结构。
如图3所示,就是我们这个实战项目的工程结构,其中:
bert_base_chinese
目录里面是原始中文BERT的配置文件和模型参数;cache
目录用来保存微调后保存的模型;data
里面存放了各类下游任务需要用到的数据集;logs
目录用来保存日志;model
目录里面是实现的模型代码:BasicBert
目录里面是实现整个BERT模型,包括加载预训练参数所用到的代码;DownstreamTasks
目录里面实现的是各类基于BERT结构所实现的模型代码,例如BertForSentenceClassification.py
中实现的便是基于BERT的文本分类模型,后续我们还会在同级目录中来新建其它场景下基于BERT的模型代码;
Tasks
目录里面实现的则是model
目录中对应下游场景任务的训练代码,例如TaskForSingleSentenceClassification.py
实现的则是基于单文本进行分类的训练代码,后续也会添加其它场景下的训练代码;test
目录中是各个类的测试文件;utils
目录中是一些工具类文件,例如数据集载入和日志打印工具等。
4.2 文本分类前向传播
在介绍完如何将bert-base-chinese
中的参数赋值到BERT模型中以及整个工程的目录结构后,接下来我们首先要做的就是实现文本分类的前向传播过程。在图3中的BertForSentenceClassification.py
文件中,我们通过定义如下一个类来完成整个前向传播的过程:
xxxxxxxxxx
121from ..BasicBert.Bert import BertModel
2import torch.nn as nn
3class BertForSentenceClassification(nn.Module):
4 def __init__(self, config, bert_pretrained_model_dir=None):
5 super(BertForSentenceClassification, self).__init__()
6 self.num_labels = config.num_labels
7 if bert_pretrained_model_dir is not None:
8 self.bert = BertModel.from_pretrained(config, bert_pretrained_model_dir)
9 else:
10 self.bert = BertModel(config)
11 self.dropout = nn.Dropout(config.hidden_dropout_prob)
12 self.classifier = nn.Linear(config.hidden_size, self.num_labels)
在上述代码中,第4行代码分别就是用来指定模型配置、分类的标签数量以及预训练模型的路径;第7-10行代码则是用来定义一个BERT模型,可以看到如果预训练模型的路径存在则会返回一个由bert-base-chinese
参数初始化后的BERT模型,否则则会返回一个随机初始化参数的BERT模型;第12行则是定义最后的分类层。
最后,整个前向传播的实现代码如下所示:
xxxxxxxxxx
171 def forward(self, input_ids, # [src_len, batch_size]
2 attention_mask=None, # [batch_size, src_len]
3 token_type_ids=None, # [src_len, batch_size] 单句分类时为None
4 position_ids=None, # [1,src_len]
5 labels=None): # [batch_size,]
6 pooled_output, _ = self.bert(input_ids=input_ids,
7 attention_mask=attention_mask,
8 token_type_ids=token_type_ids,
9 position_ids=position_ids) # [batch_size,hidden_size]
10 pooled_output = self.dropout(pooled_output)
11 logits = self.classifier(pooled_output) # [batch_size, num_label]
12 if labels is not None:
13 loss_fct = nn.CrossEntropyLoss()
14 loss = loss_fct(logits.view(-1, self.num_labels), labels.view(-1))
15 return loss, logits
16 else:
17 return logits
在上述代码中,第6-9行返回的就是原始BERT网络的输出,其中pooled_output
为BERT第一个位置的向量经过一个全连接层后的结果,第二个参数是BERT中所有位置的向量(具体可以参见文章[2]的第2.6节内容);第10-11行便是用来进行文本分类的分类层;第12-17行则是用来判断返回损失值还是返回logits
值。
4.3 模型训练
如图3所示,我们将在Task
目录下新建一个名为TaskForSingleSentenceClassification
的模块来完成分类模型的微调训练任务。
首先,我们需要定义一个ModelConfig
类来对分类模型中的超参数进行管理,代码如下所示:
xxxxxxxxxx
331class ModelConfig:
2 def __init__(self):
3 self.project_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
4 self.dataset_dir = os.path.join(self.project_dir, 'data', 'SingleSentenceClassification')
5 self.pretrained_model_dir = os.path.join(self.project_dir, "bert_base_chinese")
6 self.vocab_path = os.path.join(self.pretrained_model_dir, 'vocab.txt')
7 self.device = torch.device('cuda:0' if torch.cuda.is_available() else 'cpu')
8 self.train_file_path = os.path.join(self.dataset_dir, 'toutiao_train.txt')
9 self.val_file_path = os.path.join(self.dataset_dir, 'toutiao_val.txt')
10 self.test_file_path = os.path.join(self.dataset_dir, 'toutiao_test.txt')
11 self.model_save_dir = os.path.join(self.project_dir, 'cache')
12 self.logs_save_dir = os.path.join(self.project_dir, 'logs')
13 self.split_sep = '_!_'
14 self.is_sample_shuffle = True
15 self.batch_size = 64
16 self.max_sen_len = None
17 self.num_labels = 15
18 self.epochs = 10
19 self.model_val_per_epoch = 2
20 logger_init(log_file_name='single', log_level=logging.INFO,
21 log_dir=self.logs_save_dir)
22 if not os.path.exists(self.model_save_dir):
23 os.makedirs(self.model_save_dir)
24
25 # 把原始bert中的配置参数也导入进来
26 bert_config_path = os.path.join(self.pretrained_model_dir, "config.json")
27 bert_config = BertConfig.from_json_file(bert_config_path)
28 for key, value in bert_config.__dict__.items():
29 self.__dict__[key] = value
30 # 将当前配置打印到日志文件中
31 logging.info(" ### 将当前配置打印到日志文件中 ")
32 for key, value in self.__dict__.items():
33 logging.info(f"### {key} = {value}")
在上述代码中,第2-23行则是分别用来定义模型中的一些数据集目录、超参数和初始化日志打印类等;第25-29行则是将原始bert_base_chinese
配置文件,即config.json
中的参数也导入到类ModelConfig
中;第31-33行则是将所有的超参数配置情况一同打印到日志文件中方便后续分析,更多关于日志的内容可以参加文章训练模型时如何便捷保存训练日志[8]。
最后,我们只需要再定义一个train()
函数来完成模型的训练即可,代码如下:
xxxxxxxxxx
401def train(config):
2 model = BertForSentenceClassification(config,
3 config.pretrained_model_dir)
4 #......
5 optimizer = torch.optim.Adam(model.parameters(), lr=5e-5)
6 model.train()
7 bert_tokenize = BertTokenizer.from_pretrained(
8 config.pretrained_model_dir).tokenize
9 data_loader = LoadSingleSentenceClassificationDataset(vocab_path=config.vocab_path,
10 tokenizer=bert_tokenize,
11 batch_size=config.batch_size,
12 max_sen_len=config.max_sen_len,
13 split_sep=config.split_sep,
14 max_position_embeddings=config.max_position_embeddings,
15 pad_index=config.pad_token_id)
16 train_iter, test_iter, val_iter =
17 data_loader.load_train_val_test_data(config.train_file_path,
18 config.val_file_path,
19 config.test_file_path)
20 max_acc = 0
21 for epoch in range(config.epochs):
22 losses = 0
23 start_time = time.time()
24 for idx, (sample, label) in enumerate(train_iter):
25 sample = sample.to(config.device) # [src_len, batch_size]
26 label = label.to(config.device)
27 padding_mask = (sample == data_loader.PAD_IDX).transpose(0, 1)
28 loss, logits = model(
29 input_ids=sample,
30 attention_mask=padding_mask,
31 token_type_ids=None,
32 position_ids=None,
33 labels=label)
34 #.........
35 acc = (logits.argmax(1) == label).float().mean()
36 #.........
37 if (epoch + 1) % config.model_save_per_epoch == 0:
38 acc = evaluate(val_iter, model, config.device)
39 logging.info(f"Accuracy on val {acc:.3f}")
40 #.........
在上述代码中,第2-3行用来初始化一个基于BERT的文本分类模型;第9-19行则是载入相应的数据集;第20-39行则是整个模型的训练过程,完整示例代码可参见[6],掌柜也在代码中进行了详细的注释。
如下便是网络的训练结果:
xxxxxxxxxx
121-- INFO: Epoch: 0, Batch[0/4186], Train loss :2.862, Train acc: 0.125
2-- INFO: Epoch: 0, Batch[10/4186], Train loss :2.084, Train acc: 0.562
3-- INFO: Epoch: 0, Batch[20/4186], Train loss :1.136, Train acc: 0.812
4-- INFO: Epoch: 0, Batch[30/4186], Train loss :1.000, Train acc: 0.734
5...
6-- INFO: Epoch: 0, Batch[4180/4186], Train loss :0.418, Train acc: 0.875
7-- INFO: Epoch: 0, Train loss: 0.481, Epoch time = 1123.244s
8...
9-- INFO: Epoch: 9, Batch[4180/4186], Train loss :0.102, Train acc: 0.984
10-- INFO: Epoch: 9, Train loss: 0.100, Epoch time = 1130.071s
11-- INFO: Accurcay on val 0.884
12-- INFO: Accurcay on test 0.888
5 总结
在这篇文章中,掌柜首先从总体上介绍了建立基于BERT模型的文本分类模型的大致思路;然后介绍了模型的输入部分,以及如何从零开始来构建数据集;接着详细介绍了如何分析模型的参数,并将其载入到相应的模型中;最后介绍了如何在BERT网络模型的基础之上来添加一个分类层,来完成最后的文本分类任务。在下一篇文章中,掌柜将会介绍如何在文本蕴含任务,即输入两个句子来进行分类的场景下进行BERT预训练模型的微调。
本次内容就到此结束,感谢您的阅读!如果你觉得上述内容对你有所帮助,欢迎分享至一位你的朋友!若有任何疑问与建议,请添加掌柜微信nulls8或加群进行交流。青山不改,绿水长流,我们月来客栈见!
引用
[3] https://github.com/aceimnorstuvwxz/toutiao-text-classfication-dataset
[4]https://huggingface.co/bert-base-chinese/tree/main
[6] 示例代码 https://github.com/moon-hotel/BertWithPretrained