⼿把⼿教你⽤Pytorch-Transformers——部分源码解读及相关说
明(⼀)
简介
Transformers是⼀个⽤于⾃然语⾔处理(NLP)的Python第三⽅库,实现Bert、GPT-2和XLNET等⽐较新的模型,⽀持TensorFlow和PyTorch。本⽂介对这个库进⾏部分代码解读,⽬前⽂章只针对Bert,其他模型看⼼情。
github:
⼿把⼿教你⽤PyTorch-Transformers是我记录和分享⾃⼰使⽤ Transformers 的经验和想法,因为个⼈时间原因不能⾯⾯俱到,有时间再填本⽂是《⼿把⼿教你⽤Pytorch-Transformers》的第⼀篇,主要对⼀些源码进⾏讲解
⽬前只对 Bert 相关的代码和原理进⾏说明,GPT2 和 XLNET 应该是没空写了
实战篇已经完成⼀部分
Model相关
BertConfig
BertConfig 是⼀个配置类,存放了 BertModel 的配置。⽐如:
vocab_size_or_config_json_file:字典⼤⼩,默认30522
hidden_size:Encoder 和 Pooler 层的⼤⼩,默认768
num_hidden_layers:Encoder 的隐藏层数,默认12
num_attention_heads:每个 Encoder 中 attention 层的 head 数,默认12
完整内容可以参考:
BertModel
实现了基本的Bert模型,从构造函数可以看到⽤到了embeddings,encoder和pooler。
下⾯是允许输⼊到模型中的参数,模型⾄少需要有1个输⼊: input_ids 或 input_embeds。
input_ids 就是⼀连串 token 在字典中的对应id。形状为 (batch_size, quence_length)。
token_type_ids 可选。就是 token 对应的句⼦id,值为0或1(0表⽰对应的token属于第⼀句,1表⽰属于第⼆句)。形状为
(batch_size, quence_length)。
Bert 的输⼊需要⽤ [CLS] 和 [SEP] 进⾏标记,开头⽤ [CLS],句⼦结尾⽤ [SEP]
两个句⼦:
tokens:[CLS] is this jack ##son ##ville ? [SEP] no it is not . [SEP]
token_type_ids:0 0 0 0 0 0 0 0 1 1 1 1 1 1
⼀个句⼦:
tokens:[CLS] the dog is hairy . [SEP]
token_type_ids:0 0 0 0 0 0 0
attention_mask 可选。各元素的值为 0 或 1 ,避免在 padding 的 token 上计算 attention(1不进⾏masked,0则masked)。形状为(batch_size, quence_length)。
position_ids 可选。表⽰ token 在句⼦中的位置id。形状为(batch_size, quence_length)。形状为(batch_size, quence_length)。
head_mask 可选。各元素的值为 0 或 1 ,1 表⽰ head 有效,0⽆效。形状为(num_heads,)或(num_layers, num_heads)。
input_embeds 可选。替代 input_ids,我们可以直接输⼊ Embedding 后的 Tensor。形状为(batch_size, quence_length,
embedding_dim)。
encoder_hidden_states 可选。encoder 最后⼀层输出的隐藏状态序列,模型配置为 decoder 时使⽤。形状为(batch_size,
quence_length, hidden_size)。
encoder_attention_mask 可选。避免在 padding 的 token 上计算 attention,模型配置为 decoder 时使⽤。形状为(batch_size, quence_length)。
encoder_hidden_states 和 encoder_attention_mask 可以结合论⽂中的Figure 1理解,左边为 encoder,右边为 decoder。
论⽂《Attention Is All You Need》:
如果要作为 decoder ,模型需要通过 BertConfig 设置 is_decoder 为 True
def __init__(lf, config):
super(BertModel, lf).__init__(config)
lf.pooler = BertPooler(config)
lf.init_weights()
def forward(lf, input_ids=None, attention_mask=None, token_type_ids=None, position_ids=None,
head_mask=None, inputs_embeds=None, encoder_hidden_states=None, encoder_attention_mask=None):
...
BertPooler
在Bert中,pool的作⽤是,输出的时候,⽤⼀个全连接层将整个句⼦的信息⽤第⼀个token来表⽰,源码如下
每个 token 上的输出⼤⼩都是hidden_size (在BERT Ba中是768)
class BertPooler(nn.Module):
def __init__(lf, config):
super(BertPooler, lf).__init__()
lf.den = nn.Linear(config.hidden_size, config.hidden_size)
lf.activation = nn.Tanh()
def forward(lf, hidden_states):
# We "pool" the model by simply taking the hidden state corresponding
# to the first token.
first_token_tensor = hidden_states[:, 0]
pooled_output = lf.den(first_token_tensor)
pooled_output = lf.activation(pooled_output)
return pooled_output
所以在分类任务中,Bert只取出第⼀个token的输出再经过⼀个⽹络进⾏分类就可以了,就像之前的⽂章中谈到的垃圾邮件识别
BertForSequenceClassification
BertForSequenceClassification 是⼀个已经实现好的⽤来进⾏⽂本分类的类,⼀般⽤来进⾏⽂本分类任务。构造函数如下
def __init__(lf, config):
super(BertForSequenceClassification, lf).__init__(config)
lf.num_labels = config.num_labels
lf.bert = BertModel(config)
lf.dropout = nn.Dropout(config.hidden_dropout_prob)
lf.classifier = nn.Linear(config.hidden_size, lf.config.num_labels)
lf.init_weights()
我们可以通过 num_labels 传递分类的类别数,从构造函数可以看出这个类⼤致由3部分组成,1个是Bert,1个是Dropout,1个是⽤于分类的线性分类器Linear。
Bert⽤于提取⽂本特征进⾏Embedding,Dropout防⽌过拟合,Linear是⼀个弱分类器,进⾏分类,如果需要⽤更复杂的⽹络结构进⾏分类可以参考它进⾏改写。
他的 forward() 函数⾥⾯已经定义了损失函数,训练时可以不⽤⾃⼰额外实现,返回值包括4个内容
def forward(...):
...
if labels is not None:
if lf.num_labels == 1:
# We are doing regression
loss_fct = MSELoss()
loss = loss_fct(logits.view(-1), labels.view(-1))
el:
loss_fct = CrossEntropyLoss()
loss = loss_fct(logits.view(-1, lf.num_labels), labels.view(-1))
outputs = (loss,) + outputs
return outputs # (loss), logits, (hidden_states), (attentions)
其中 hidden-states 和 attentions 不⼀定存在
BertForTokenClassification
BertForSequenceClassification 是⼀个已经实现好的在 token 级别上进⾏⽂本分类的类,⼀般⽤来进⾏序列标注任务。构造函数如下。
代码基本和 BertForSequenceClassification 是⼀样的
def __init__(lf, config):
super(BertForTokenClassification, lf).__init__(config)
lf.num_labels = config.num_labels
lf.bert = BertModel(config)
lf.dropout = nn.Dropout(config.hidden_dropout_prob)
lf.classifier = nn.Linear(config.hidden_size, config.num_labels)
lf.init_weights()
不同点在于 BertForSequenceClassification 我们只⽤到了第⼀个 token 的输出(经过 pooler 包含了整个句⼦的信息)
下⾯是 BertForSequenceClassification 的中 forward() 函数的部分代码
outputs = lf.bert(input_ids,
attention_mask=attention_mask,
token_type_ids=token_type_ids,
position_ids=position_ids,
head_mask=head_mask,
inputs_embeds=inputs_embeds)
pooled_output = outputs[1]
bert 是⼀个 BertModel 的实例,它的输出有4个部分,如下所⽰
def forward(...):
...
return outputs # quence_output, pooled_output, (hidden_states), (attentions)
从上⾯可以看到 BertForSequenceClassification ⽤到的是 pooled_output,即⽤1个位置上的输出表⽰整个句⼦的含义
下⾯是 BertForTokenClassification 的中 forward() 函数的部分代码,它⽤到的是全部 token 上的输出。
outputs = lf.bert(input_ids,
attention_mask=attention_mask,
token_type_ids=token_type_ids,
position_ids=position_ids,
head_mask=head_mask,
inputs_embeds=inputs_embeds)
quence_output = outputs[0]
BertForQuestionAnswering
实现好的⽤来做QA(span extraction,⽚段提取)任务的类。
有多种⽅法可以根据⽂本回答问题,⼀个简单的情况就是将任务简化为⽚段提取
这种任务,输⼊以上下⽂+问题的形式出现。输出是⼀对整数,表⽰答案在⽂本中的开头和结束位置
图⽚来⾃:
参考⽂章:
example:
⽂本:
Architecturally, the school has a Catholic character. Atop the Main Building's gold dome is a golden statue of the Virgin Mary. Immediately in front of the Main Building and facing it, is a copper statue of Christ with arms upraid with the legend "Venite Ad Me Omnes". Next to the Main Building is the Basilica of the Sacred Heart. Immediately behind the basilica is the Grotto, a Marian place of prayer and reflection. It is a replica of the grotto at Lourdes, France where the Virgin Mary reputedly appeared to Saint Bernadette Soubirous in 1858. At the end of the main drive (and in a direct line that connects through 3 statues and the Gold Dome), is a simple, modern stone statue of Mary.
问题:
The Basilica of the Sacred heart at Notre Dame is beside to which structure?
答案:
start_position: 49,end_position: 51(按单词计算的)
49-51 是 the Main Building 这3个单词在句中的索引
下⾯是它的构造函数,和 Classification 相⽐,这⾥没有 Dropout 层
def __init__(lf, config):
super(BertForQuestionAnswering, lf).__init__(config)
lf.num_labels = config.num_labels
lf.bert = BertModel(config)
lf.qa_outputs = nn.Linear(config.hidden_size, config.num_labels)
lf.init_weights()
模型的输⼊多了两个,start_positions 和 end_positions ,它们的形状都是 (batch_size,)
start_positions 标记 span 的开始位置(索引),end_positions 标记 span 的结束位置(索引),被标记的 token ⽤于计算损失即答案在⽂本中开始的位置和结束的位置,如果答案不在⽂本中,应设为0
除了 start 和 end 标记的那段序列外,其他位置上的 token 不会被⽤来计算损失。
def forward(lf, input_ids=None, attention_mask=None, token_type_ids=None, position_ids=None, head_mask=None,
inputs_embeds=None, start_positions=None, end_positions=None):
...
可以参考下图帮助理解
图⽚来⾃Bert原论⽂:
从图中可以看到,QA 任务的输⼊是两个句⼦,⽤ [SEP] 分隔,第⼀个句⼦是问题(Question),第⼆个句⼦是含有答案的上下⽂(Paragraph)
输出是作为答案开始和结束的可能性(Start/End Span)
BertForMultipleChoice
实现好的⽤来做多选任务的,⽐如SWAG和MRPC等,⽤来句⼦对判断语义、情感等是否相同
下⾯是它的构造函数,可以到看到只有1个输出,⽤来输出情感、语义相同的概率
def __init__(lf, config):
super(BertForMultipleChoice, lf).__init__(config)
lf.bert = BertModel(config)
lf.dropout = nn.Dropout(config.hidden_dropout_prob)
lf.classifier = nn.Linear(config.hidden_size, 1)
lf.init_weights()
⼀个简单的例⼦
example:
tokenizer = BertTokenizer("")
model = BertForMultipleChoice.from_pretrained("bert-ba-uncad")
choices = ["Hello, my dog is cute", "Hello, my cat is pretty"]
input_ids = sor([de(s) for s in choices]).unsqueeze(0) # 形状为[1, 2, 7]
labels = sor(1).unsqueeze(0)
outputs = model(input_ids, labels=labels)
BertForMultipleChoice 也是⽤到经过 Pooled 的 Bert 输出,forward() 函数同样返回 4 个内容
def forward(lf, input_ids=None, attention_mask=None, token_type_ids=None,
position_ids=None, head_mask=None, inputs_embeds=None, labels=None):
...
pooled_output = outputs[1]
...
return outputs # (loss), reshaped_logits, (hidden_states), (attentions)
tokenization相关
对于⽂本,常见的操作是分词然后将词-id ⽤字典保存,再将分词后的词⽤ id 表⽰,然后经过 Embedding 输⼊到模型中。
Bert 也不例外,但是 Bert 能以字级别作为输⼊,在处理中⽂⽂本时我们可以不⽤先分词,直接⽤ Bert 将⽂本转换为 token,然后⽤相应的id 表⽰。
tokenization 库就是⽤来将⽂本切割成为字或词的,下⾯对其进⾏简单的介绍
BasicTokenizer
基本的 tokenization 类,构造函数可以接收以下3个参数
do_lower_ca:是否将输⼊转换为⼩写,默认True
never_split:可选。输⼊⼀个列表,列表内容为不进⾏ tokenization 的单词
tokenize_chine_chars:可选。是否对中⽂进⾏ tokenization,默认True
tokenize() 函数是⽤来 tokenization 的,这⾥的 tokenize 仅仅使⽤空格作为分隔符,输⼊的⽂本会先进⾏⼀些数据处理,处理掉⽆效字符并将空⽩符(“\t”,“\n”等)统⼀替换为空格。如果 tokenize_chine_chars 为 True,则会在每个中⽂“字”的前后增加空格,然后⽤whitespace_tokenize() 进⾏ tokenization,因为增加了空格,空⽩符⼜都统⼀换成了空格,实际上 whitespace_tokenize() 就是⽤了 Python ⾃带的 split() 函数,处理前⽤先 strip() 去除了⽂本前后的空⽩符。whitespace_tokenize() 的函数内容如下:
def whitespace_tokenize(text):
"""Runs basic whitespace cleaning and splitting on a piece of text."""
text = text.strip()
if not text:
return []
tokens = text.split()
return tokens
⽤ split() 进⾏拆分后,还会将标点符号从⽂本中拆分出来(不是去除)